Integrate typed ports and schema 3 migration

# Conflicts:
#	crates/oa-core/src/projection.rs
This commit is contained in:
2026-09-05 18:26:48 +03:00
34 changed files with 9915 additions and 408 deletions
+42 -8
View File
@@ -34,11 +34,33 @@ fn occurrence(
id
}
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
let id = add(p, Kind::Port, Layer::Sa, name, Some(owner));
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
direction,
interface: Some(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 {
outgoing_items: items,
}
} else {
PortContract::FunctionInput {
incoming_items: items,
}
}
} else {
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: match direction {
PortDirection::In => ComponentPortOrientation::In,
PortDirection::Out => ComponentPortOrientation::Out,
PortDirection::InOut => ComponentPortOrientation::InOut,
},
}
};
let id = Id::new_v4();
let element = crate::ports::make_port(p, id, owner, name, contract).unwrap();
p.elements.insert(id, element);
id
}
fn diagram(
@@ -369,8 +391,8 @@ pub fn coastal_rescue() -> Project {
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
source: transmit,
target: coordinate,
source_port: Some(function_out),
target_port: Some(function_in),
source_port: function_out,
target_port: function_in,
items: vec![information],
};
let boundary = p.relate(
@@ -384,6 +406,18 @@ pub fn coastal_rescue() -> Project {
{
items.push(information);
}
p.relate(
RelationKind::PortAllocation,
"Vessel output represents report output",
vessel_port,
function_out,
);
p.relate(
RelationKind::PortAllocation,
"System input represents assessment input",
system_port,
function_in,
);
p.relate(
RelationKind::ExchangeAllocation,
"Report carried by connection",
@@ -393,13 +427,13 @@ pub fn coastal_rescue() -> Project {
p.relate(
RelationKind::InterfaceProvision,
"Reporting service provided",
actor,
vessel_port,
interface,
);
p.relate(
RelationKind::InterfaceRequirement,
"Reporting service required",
system,
system_port,
interface,
);
let sa_occurrence = occurrence(
+70 -19
View File
@@ -1,4 +1,6 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1 migration.
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration.
#[path = "migration_v2/source.rs"]
mod v2_source;
use crate::*;
use serde_json::{Map, Value};
use std::{collections::BTreeMap, fmt};
@@ -317,6 +319,8 @@ fn reference_field(key: &str) -> bool {
| "branches"
| "Named"
| "items"
| "incoming_items"
| "outgoing_items"
| "triggers"
| "effects"
| "interface"
@@ -351,6 +355,8 @@ fn enum_field(key: &str) -> bool {
key,
"kind"
| "direction"
| "orientation"
| "contract"
| "layer"
| "projection"
| "relation"
@@ -364,13 +370,24 @@ fn enum_field(key: &str) -> bool {
)
}
/// Parse V2, or explicitly migrate a valid V1 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)
}
pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<Diagnostic>> {
parse_with_report_limited(source, limits).map(|decoded| decoded.project)
}
pub fn parse_with_report(
source: &str,
) -> Result<crate::persistence::DecodeResult, Vec<Diagnostic>> {
parse_with_report_limited(source, ParseLimits::UNLIMITED)
}
pub fn parse_with_report_limited(
source: &str,
limits: ParseLimits,
) -> Result<crate::persistence::DecodeResult, Vec<Diagnostic>> {
let parsed = parse_document(source, limits)?;
let errors: Vec<_> = parsed
.diagnostics(source)
@@ -378,7 +395,10 @@ pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<D
.filter(|d| d.severity == validation::Severity::Error)
.collect();
if errors.is_empty() {
Ok(parsed.project)
Ok(crate::persistence::DecodeResult {
project: parsed.project,
migration: parsed.migration,
})
} else {
Err(errors)
}
@@ -386,6 +406,7 @@ pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<D
struct ParsedDocument {
project: Project,
migration: Option<crate::persistence::MigrationReport>,
locations: BTreeMap<Id, usize>,
fields: BTreeMap<(Id, String), usize>,
}
@@ -460,22 +481,31 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
})
.collect::<Vec<_>>()
})?;
let project = crate::persistence::migrate_v1_limited(old, limits).map_err(|message| {
vec![diagnostic(
source,
Error {
code: "invalid_source",
offset: 0,
message,
},
)]
})?;
let decoded =
crate::persistence::migrate_v1_with_report_limited(old, limits).map_err(|error| {
vec![diagnostic(
source,
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"invalid_source"
},
offset: 0,
message: error.to_string(),
},
)]
})?;
return Ok(ParsedDocument {
project,
project: decoded.project,
migration: decoded.migration,
locations: BTreeMap::new(),
fields: BTreeMap::new(),
});
}
if tokens.get(1).is_some_and(|t| t.text(source) == "2") {
return v2_source::parse(source, tokens, limits);
}
Parser {
source,
tokens,
@@ -510,7 +540,7 @@ pub fn format(project: &Project) -> String {
.map(|(id, a)| (id, a.to_owned()))
.collect::<BTreeMap<_, _>>();
let mut out = std::format!(
"arcadia 2;\nproject {} {} id \"{}\" {{\n description {};\n\n",
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n",
project.alias,
quote(&project.name),
project.id,
@@ -1000,8 +1030,8 @@ impl Parser<'_> {
}
fn document(&mut self) -> Result<ParsedDocument, Error> {
self.take("arcadia")?;
if self.text() != "2" {
return Err(self.error("Unsupported schema version: expected 'arcadia 2;'."));
if self.text() != "3" {
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'."));
}
self.cursor += 1;
self.take(";")?;
@@ -1116,7 +1146,22 @@ impl Parser<'_> {
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let parent =
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
let mut initial = ElementBody::new(kind, layer).unwrap();
let mut initial = if kind == Kind::Port {
if !fields.contains_key("contract") {
return Err(conversion_error(
"Missing required field 'contract'.".into(),
));
}
ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: None,
},
)
} else {
ElementBody::new(kind, layer).unwrap()
};
// Schema-2 sources predate explicit scenario viewpoints.
// Preserve their mixed lifelines instead of reinterpreting
// old source using a new graphical factory's typed default.
@@ -1151,7 +1196,12 @@ impl Parser<'_> {
DeclKind::Relation(kind) => {
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
for required in ["source", "target"] {
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
&["source", "target", "source_port", "target_port"]
} else {
&["source", "target"]
};
for &required in required {
if !fields.contains_key(required) {
return Err(conversion_error(std::format!(
"Missing required field '{required}'."
@@ -1246,6 +1296,7 @@ impl Parser<'_> {
})?;
Ok(ParsedDocument {
project,
migration: None,
locations,
fields: field_locations,
})
+2
View File
@@ -2,12 +2,14 @@
pub mod language;
pub mod model;
pub mod persistence;
pub mod ports;
pub mod projection;
pub mod references;
pub mod session;
pub mod transitions;
pub mod validation;
pub use model::*;
pub use ports::*;
pub use session::*;
mod demo;
mod state_validation;
+21
View File
@@ -0,0 +1,21 @@
# Frozen version 2 reader
`model.rs`, `validation.rs`, `state_validation.rs`, `references.rs`, the drift
comparison in `transitions.rs`, and the typed declaration reader in `source.rs`
were captured from commit `c20a207` before the writable model changed to schema 3.
The modules are private; they are not another authoring model.
Imports point at the frozen modules, and the unused demo factory is excluded.
The declaration reader shares lexical/AST mechanics with the current language,
but constructs and validates the frozen DTO, preserves located old-contract
errors, then calls `adapter.rs`. Original staged scenario/state defaults and
generic port/optional FE endpoint contracts therefore remain explicit.
`adapter.rs` budgets generated data, validates V2, transforms only the changed
payloads, and validates schema 3. Serialization between the two typed DTOs copies
unchanged fields; original input bytes are never deserialized into the new
`Project`. V1 normalization also constructs this V2 DTO before the shared stage.
Do not update these frozen shapes when extending the current model. Add another
versioned migration when the persistent contract changes again. Golden V2
source/JSON fixtures remain unchanged.
+214
View File
@@ -0,0 +1,214 @@
use crate::language::ParseLimits;
use crate::persistence::{
DecodeResult, FileError, GeneratedEndpoint, MigrationReport, OrientationDefault, PreservedPort,
};
use crate::*;
use serde_json::Value;
use std::collections::BTreeSet;
/// Validate the real V2 graph, then explicitly transform its changed payloads.
/// The JSON bridge below copies unchanged DTO fields; it never reads old bytes
/// as a current Project and never lets current constructors supply old defaults.
pub(crate) fn migrate(
old: super::model::Project,
limits: ParseLimits,
) -> Result<DecodeResult, FileError> {
super::check_limits(&old, limits).map_err(FileError::Limit)?;
let mut generated = 0usize;
let mut additional_references = 0usize;
for r in old.relations.values() {
if let super::model::RelationBody::FunctionalExchange {
source_port,
target_port,
items,
..
} = &r.body
{
let missing = usize::from(source_port.is_none()) + usize::from(target_port.is_none());
generated = generated.saturating_add(missing);
additional_references = additional_references
.saturating_add(missing.saturating_mul(items.len().saturating_add(2)));
}
}
let (graph, records, references) = super::counts(&old);
limits
.check_counts(
graph.saturating_add(generated),
records.saturating_add(generated),
references.saturating_add(additional_references),
)
.map_err(FileError::Limit)?;
if let Some(issue) = super::validation::validate(&old)
.into_iter()
.find(|i| i.severity == super::validation::Severity::Error)
{
let message = format!(
"V2 {} at {}.{}: {}",
issue.code,
issue
.element
.map_or_else(|| "project".into(), |id| id.to_string()),
issue.field.as_deref().unwrap_or_default(),
issue.message
);
return Err(FileError::Model {
message,
issues: vec![validation::Issue {
severity: validation::Severity::Error,
element: issue.element,
code: issue.code,
field: issue.field,
message: issue.message,
}],
});
}
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(SCHEMA_VERSION);
for element in old.elements.values() {
if let Some(super::model::SharedBody::Port {
direction,
interface,
}) = element.shared()
{
let layer = match element.layer() {
super::model::Layer::Oa => "Oa",
super::model::Layer::Sa => "Sa",
};
value["elements"][element.id.to_string()]["body"][layer]["Shared"]["Port"] = serde_json::json!({"contract":"Legacy", "legacy":{"direction":direction,"interface":interface}});
let required_resolution = if element.layer() == super::model::Layer::Oa {
"Keep this OA Legacy contract unresolved or review an explicit SA counterpart; typed ports are SA-only."
} else {
"Review the preserved generic direction/interface contract before selecting a typed SA port contract."
};
report.preserved_ports.push(PreservedPort {
id: element.id,
alias: element.alias.clone(),
required_resolution: required_resolution.into(),
});
report.required_resolutions.push(element.id);
}
}
let mut used_ids: BTreeSet<_> = old.identities().into_iter().map(|(id, _)| id).collect();
// Use the existing alias convention against the old graph plus each newly
// planned endpoint, so cross-category alias collisions are deterministic.
let mut alias_project = old.clone();
for relation in old.relations.values() {
match &relation.body {
super::model::RelationBody::FunctionalExchange {
source,
target,
source_port,
target_port,
items,
} => {
for (side, owner, existing, output) in [
("source", *source, source_port, true),
("target", *target, target_port, false),
] {
if existing.is_some() {
continue;
}
let id = Id::new_v5(
&old.id,
format!("v2-fe-port:{}:{side}", relation.id).as_bytes(),
);
if !used_ids.insert(id) {
let message = format!(
"V2 migration identity_collision at {}.{}_port: deterministic endpoint {} already belongs to another object.",
relation.id, side, id
);
return Err(FileError::Model {
message: message.clone(),
issues: vec![validation::Issue {
severity: validation::Severity::Error,
element: Some(relation.id),
code: "identity_collision".into(),
field: Some(format!("{side}_port")),
message,
}],
});
}
let role = if output { "output" } else { "input" };
let name = if relation.name.trim().is_empty() {
format!("Function {role}")
} else {
format!("{} {role}", relation.name)
};
let alias = alias_project.fresh_alias(&name, "port", id);
// Only identity/name/alias enter this planning-only V2 map.
alias_project.elements.insert(
id,
super::model::Element {
id,
alias: alias.clone(),
name: name.clone(),
description: String::new(),
parent: Some(owner),
body: super::model::ElementBody::Sa(super::model::SaBody::Function),
},
);
let contract = if output {
PortContract::FunctionOutput {
outgoing_items: items.clone(),
}
} else {
PortContract::FunctionInput {
incoming_items: items.clone(),
}
};
let element = Element {
id,
alias: alias.clone(),
name,
description: String::new(),
parent: Some(owner),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract,
legacy: None,
},
),
};
value["elements"]
.as_object_mut()
.expect("V2 elements map")
.insert(id.to_string(), serde_json::to_value(element)?);
value["relations"][relation.id.to_string()]["body"]["FunctionalExchange"]
[format!("{side}_port")] = serde_json::to_value(id)?;
report.generated_endpoints.push(GeneratedEndpoint {
exchange: relation.id,
side: side.into(),
port: id,
owner,
alias,
});
}
}
super::model::RelationBody::BoundaryExchange { .. } => {
value["relations"][relation.id.to_string()]["body"]["BoundaryExchange"]["oriented"] =
Value::Bool(true);
report.orientation_defaults.push(OrientationDefault {
exchange: relation.id,
oriented: true,
});
}
_ => {}
}
}
let project: Project = serde_json::from_value(value)?;
limits.check_model(&project).map_err(FileError::Limit)?;
super::super::validate_project(&project)?;
Ok(DecodeResult {
project,
migration: Some(report),
})
}
+33
View File
@@ -0,0 +1,33 @@
//! Version-2 DTO and validation snapshot. Kept separate from the writable model
//! so accepting an old file cannot accidentally inherit version-3 defaults.
#![allow(dead_code)]
pub(crate) mod adapter;
pub(crate) mod model;
mod references;
mod state_validation;
mod transitions;
pub(crate) mod validation;
pub(crate) fn counts(project: &model::Project) -> (usize, usize, usize) {
let graph = project
.elements
.len()
.saturating_add(project.relations.len());
let records = graph.saturating_add(project.identities().len().saturating_sub(graph));
// Keep the established ordered-reference budget for original records.
// The adapter separately charges every reference it would generate.
let references = project
.elements
.values()
.map(|e| e.steps().map_or(0, Vec::len))
.fold(0usize, usize::saturating_add);
(graph, records, references)
}
pub(crate) fn check_limits(
project: &model::Project,
limits: crate::language::ParseLimits,
) -> Result<(), String> {
let (graph, records, references) = counts(project);
limits.check_counts(graph, records, references)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
// Frozen schema-2 contract from c20a207. Private migration reader; do not evolve with schema 3.
use super::model::*;
use std::collections::BTreeSet;
/// Every live reference in a typed element, including structural ownership.
pub fn element_references(e: &Element) -> Vec<Id> {
let mut copy = e.clone();
let mut refs = Vec::new();
visit_element_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn relation_references(r: &Relation) -> Vec<Id> {
let mut copy = r.clone();
let mut refs = Vec::new();
visit_relation_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
if let Some(parent) = &mut e.parent {
visit(parent);
}
match &mut e.body {
ElementBody::Oa(OaBody::Process { steps })
| ElementBody::Sa(SaBody::FunctionalChain { steps }) => {
for id in steps {
visit(id);
}
}
_ => {}
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::DataField {
type_ref: TypeRef::Named(id),
..
} => visit(id),
SharedBody::Interface { items } => {
for id in items {
visit(id);
}
}
SharedBody::Event {
body: EventBody::Signal { items },
} => {
for id in items {
visit(id);
}
}
SharedBody::Port {
interface: Some(id),
..
} => visit(id),
SharedBody::Scenario { context, steps, .. } => {
if let Some(id) = context {
visit(id);
}
for id in steps {
visit(id);
}
}
SharedBody::Lifeline {
represented: Some(id),
} => visit(id),
SharedBody::Occurrence { body, .. } => match body {
OccurrenceBody::Exchange {
exchange,
sender,
receiver,
reply_to,
values,
..
} => {
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
visit(id);
}
for value in values {
visit(&mut value.field);
}
}
OccurrenceBody::Action { action, lifeline } => {
for id in [action, lifeline].into_iter().flatten() {
visit(id);
}
}
OccurrenceBody::SequenceReference { sequence } => {
if let Some(id) = sequence {
visit(id);
}
}
OccurrenceBody::Control { .. } => {}
OccurrenceBody::ScenarioUse { scenario, bindings } => {
if let Some(id) = scenario {
visit(id);
}
for binding in bindings {
visit(&mut binding.referenced);
visit(&mut binding.local);
}
}
OccurrenceBody::State { state, lifeline } => {
for id in [state, lifeline].into_iter().flatten() {
visit(id);
}
}
OccurrenceBody::Fragment { branches, .. } => {
for id in branches {
visit(id);
}
}
OccurrenceBody::Branch { occurrences, .. } => {
for id in occurrences {
visit(id);
}
}
},
SharedBody::Constraint { constrained, .. } => {
for id in constrained {
visit(id);
}
}
_ => {}
}
}
}
pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
let (mut source, mut target) = r.body.endpoints();
visit(&mut source);
visit(&mut target);
r.body.set_endpoints(source, target);
match &mut r.body {
RelationBody::StateTransition {
triggers, effects, ..
} => {
for id in triggers.iter_mut().chain(effects) {
visit(id);
}
}
RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => {
for id in items {
visit(id);
}
}
RelationBody::FunctionalExchange {
source_port,
target_port,
items,
..
} => {
for id in [source_port, target_port].into_iter().flatten() {
visit(id);
}
for id in items {
visit(id);
}
}
_ => {}
}
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
for e in p.elements.values_mut() {
visit_element_refs(e, |id| {
if *id == old {
*id = new;
}
});
}
for r in p.relations.values_mut() {
visit_relation_refs(r, |id| {
if *id == old {
*id = new;
}
});
}
for d in p.diagrams.values_mut() {
for n in d.nodes.values_mut() {
if n.element == old {
n.element = new;
}
}
}
for a in p.artifact_links.values_mut() {
if a.owner == old {
a.owner = new;
}
}
for t in p.traces.values_mut() {
if t.oa == old {
t.oa = new;
}
if t.sa == old {
t.sa = new;
}
}
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
let mut removed = BTreeSet::from([id]);
loop {
let before = removed.len();
for e in p.elements.values() {
let dependent_type = matches!(e.shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(t),..}) if removed.contains(t));
if e.parent.is_some_and(|parent| removed.contains(&parent)) || dependent_type {
removed.insert(e.id);
}
if matches!(e.shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{exchange:Some(r),..},..}) if removed.contains(r))
{
removed.insert(e.id);
}
}
for r in p.relations.values() {
if removed.contains(&r.source()) || removed.contains(&r.target()) {
removed.insert(r.id);
}
}
for d in p.diagrams.values() {
for n in d.nodes.values() {
if removed.contains(&d.id) || removed.contains(&n.element) {
removed.insert(n.id);
}
}
}
for t in p.traces.values() {
if removed.contains(&t.oa) || removed.contains(&t.sa) {
removed.insert(t.id);
}
}
for a in p.artifact_links.values() {
if removed.contains(&a.owner) {
removed.insert(a.id);
}
}
if removed.len() == before {
break;
}
}
removed
}
pub fn delete_cascade(p: &mut Project, id: Id) {
let removed = deletion_impact(p, id);
let removed_names: std::collections::BTreeMap<_, _> = removed
.iter()
.map(|id| (*id, p.named(*id).to_string()))
.collect();
p.elements.retain(|id, _| !removed.contains(id));
p.relations.retain(|id, _| !removed.contains(id));
p.diagrams.retain(|id, _| !removed.contains(id));
p.traces.retain(|id, _| !removed.contains(id));
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.nodes.retain(|id, _| !removed.contains(id));
}
let clear = |v: &mut Option<Id>| {
if v.is_some_and(|id| removed.contains(&id)) {
*v = None;
}
};
for e in p.elements.values_mut() {
if let Some(steps) = e.steps_mut() {
steps.retain(|id| !removed.contains(id));
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
SharedBody::Event {
body: EventBody::Signal { items },
} => items.retain(|id| !removed.contains(id)),
SharedBody::Port { interface, .. } => clear(interface),
SharedBody::Scenario { context, .. } => clear(context),
SharedBody::Lifeline { represented } => clear(represented),
SharedBody::Constraint { constrained, .. } => {
constrained.retain(|id| !removed.contains(id))
}
SharedBody::Occurrence { body, .. } => match body {
OccurrenceBody::Exchange {
exchange,
sender,
receiver,
reply_to,
values,
..
} => {
clear(exchange);
clear(sender);
clear(receiver);
clear(reply_to);
values.retain(|value| !removed.contains(&value.field));
}
OccurrenceBody::Action { action, lifeline } => {
clear(action);
clear(lifeline);
}
OccurrenceBody::SequenceReference { sequence } => clear(sequence),
OccurrenceBody::Control { .. } => {}
OccurrenceBody::ScenarioUse { scenario, bindings } => {
clear(scenario);
bindings.retain(|binding| {
!removed.contains(&binding.referenced)
&& !removed.contains(&binding.local)
});
}
OccurrenceBody::State { state, lifeline } => {
clear(state);
clear(lifeline);
}
OccurrenceBody::Fragment { branches, .. } => {
branches.retain(|id| !removed.contains(id))
}
OccurrenceBody::Branch { .. } => {}
},
_ => {}
}
}
}
for r in p.relations.values_mut() {
match &mut r.body {
RelationBody::StateTransition {
triggers,
effects,
trigger,
..
} => {
let deleted: Vec<_> = triggers
.iter()
.filter(|id| removed.contains(id))
.filter_map(|id| removed_names.get(id).cloned())
.collect();
triggers.retain(|id| !removed.contains(id));
effects.retain(|id| !removed.contains(id));
if !deleted.is_empty() && triggers.is_empty() && trigger.trim().is_empty() {
*trigger = format!(
"Removed trigger: {}. Choose a replacement trigger.",
deleted.join(", ")
);
}
}
RelationBody::Interaction { items, .. }
| RelationBody::BoundaryExchange { items, .. } => {
items.retain(|id| !removed.contains(id))
}
RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} => {
items.retain(|id| !removed.contains(id));
clear(source_port);
clear(target_port);
}
_ => {}
}
}
// Removing an item or a containing payload field can disconnect a still-live
// record field from an exchange. Its contextual annotation is no longer valid.
let allowed: std::collections::BTreeMap<_, _> = p
.elements
.values()
.filter_map(|e| match e.shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(id), ..
},
..
}) => p
.relations
.get(id)
.map(|r| (e.id, super::validation::carried_fields(p, r))),
_ => None,
})
.collect();
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.retain(|value| fields.contains(&value.field));
}
}
}
+546
View File
@@ -0,0 +1,546 @@
//! Frozen schema-2 declaration reader from c20a207, with explicit migration and located diagnostics.
use super::{
Ast, Error, Lexeme, ParseLimits, TokenClass, merge_fields, payload, replace_payload,
take_optional_id, take_required_id, take_string,
};
use crate::persistence::v2::model::*;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy)]
enum DeclKind {
Element(Kind, Layer),
Relation(RelationKind),
Diagram,
Node,
Artifact,
Trace,
}
fn snake(value: &str) -> String {
let mut out = String::new();
for (i, c) in value.chars().enumerate() {
if c.is_ascii_uppercase() {
if i > 0 {
out.push('_');
}
out.push(c.to_ascii_lowercase());
} else {
out.push(c)
}
}
out
}
fn element_word(kind: Kind, layer: Layer) -> String {
let stem = match kind {
Kind::SystemActor => "actor".into(),
Kind::SystemCapability => "capability".into(),
Kind::OperationalMission => "mission".into(),
_ => snake(serde_json::to_value(kind).unwrap().as_str().unwrap()),
};
std::format!("{}_{stem}", if layer == Layer::Oa { "oa" } else { "sa" })
}
fn relation_word(kind: RelationKind) -> String {
snake(serde_json::to_value(kind).unwrap().as_str().unwrap())
}
fn declaration_kind(word: &str) -> Option<DeclKind> {
static KINDS: std::sync::OnceLock<BTreeMap<String, DeclKind>> = std::sync::OnceLock::new();
KINDS
.get_or_init(|| {
let mut kinds = BTreeMap::from([
("diagram".into(), DeclKind::Diagram),
("node".into(), DeclKind::Node),
("artifact".into(), DeclKind::Artifact),
("trace".into(), DeclKind::Trace),
]);
for layer in [Layer::Oa, Layer::Sa] {
for kind in Kind::ALL {
if kind.supports(layer) {
kinds.insert(element_word(kind, layer), DeclKind::Element(kind, layer));
}
}
}
for kind in RelationKind::ALL {
kinds.insert(relation_word(kind), DeclKind::Relation(kind));
}
kinds
})
.get(word)
.copied()
}
struct Declaration {
kind: DeclKind,
alias: String,
name: String,
id: Id,
fields: BTreeMap<String, Ast>,
offset: usize,
}
struct Parser2<'a> {
source: &'a str,
tokens: Vec<Lexeme>,
cursor: usize,
value_depth: usize,
limits: ParseLimits,
semantic_error: Option<crate::validation::Issue>,
}
impl Parser2<'_> {
fn current(&self) -> Lexeme {
self.tokens[self.cursor.min(self.tokens.len() - 1)]
}
fn text(&self) -> &str {
self.current().text(self.source)
}
fn error(&self, message: impl Into<String>) -> Error {
Error {
code: "invalid_source",
offset: self.current().span.start,
message: message.into(),
}
}
fn take(&mut self, word: &str) -> Result<(), Error> {
if self.text() != word {
return Err(self.error(std::format!("Expected '{word}'.")));
}
self.cursor += 1;
Ok(())
}
fn quoted(&mut self) -> Result<String, Error> {
if self.current().class != TokenClass::String {
return Err(self.error("Expected a double-quoted string."));
}
let value = serde_json::from_str(self.text()).unwrap();
self.cursor += 1;
Ok(value)
}
fn identifier(&mut self) -> Result<String, Error> {
let value = self.text().to_owned();
if !valid_alias(&value) {
return Err(self.error(std::format!("Invalid or reserved alias '{value}'.")));
}
self.cursor += 1;
Ok(value)
}
fn optional_id(&mut self) -> Result<Id, Error> {
if self.text() != "id" {
return Ok(Id::new_v4());
}
self.cursor += 1;
let offset = self.current().span.start;
let value = self.quoted()?;
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|i| value.as_bytes()[i] == b'-')
{
return Err(Error {
code: "invalid_source",
offset,
message: "Expected UUID in 8-4-4-4-12 hexadecimal form.".into(),
});
}
Id::parse_str(&value).map_err(|_| Error {
code: "invalid_source",
offset,
message: "Invalid UUID identity.".into(),
})
}
fn fields(&mut self) -> Result<BTreeMap<String, Ast>, Error> {
self.take("{")?;
let mut fields = BTreeMap::new();
while self.text() != "}" {
let key = self.text().to_owned();
let offset = self.current().span.start;
if !matches!(
self.current().class,
TokenClass::Keyword | TokenClass::Identifier
) {
return Err(self.error("Expected a field name or '}'."));
}
self.cursor += 1;
let value = self.value()?;
self.take(";")?;
if fields.insert(key.clone(), value).is_some() {
return Err(Error {
code: "invalid_source",
offset,
message: std::format!("Duplicate field '{key}'."),
});
}
}
self.take("}")?;
Ok(fields)
}
fn value(&mut self) -> Result<Ast, Error> {
if self.value_depth >= 64 {
return Err(self.error("Typed values may not nest more than 64 levels."));
}
self.value_depth += 1;
let result = self.value_inner();
self.value_depth -= 1;
result
}
fn value_inner(&mut self) -> Result<Ast, Error> {
let token = self.current();
let offset = token.span.start;
match self.text() {
"@" => {
self.cursor += 1;
Ok(Ast::Reference(self.identifier()?, offset))
}
"{" => Ok(Ast::Object(self.fields()?, offset)),
"[" => {
self.cursor += 1;
let mut values = Vec::new();
if self.text() != "]" {
loop {
values.push(self.value()?);
if self.text() != "," {
break;
}
self.cursor += 1;
}
}
self.take("]")?;
Ok(Ast::Array(values, offset))
}
"none" => {
self.cursor += 1;
Ok(Ast::Literal(Value::Null, offset))
}
"true" | "false" => {
let value = self.text() == "true";
self.cursor += 1;
Ok(Ast::Literal(Value::Bool(value), offset))
}
_ => match token.class {
TokenClass::String => Ok(Ast::Literal(Value::String(self.quoted()?), offset)),
TokenClass::Number => {
let value = serde_json::from_str(self.text())
.map_err(|_| self.error("Expected a finite JSON decimal number."))?;
self.cursor += 1;
Ok(Ast::Literal(value, offset))
}
TokenClass::Identifier | TokenClass::Keyword => {
let value = self.text().to_owned();
self.cursor += 1;
Ok(Ast::Word(value, offset))
}
_ => Err(self.error("Expected a value, @alias reference, list, or typed record.")),
},
}
}
fn document(&mut self) -> Result<super::ParsedDocument, Error> {
self.take("arcadia")?;
if self.text() != "2" {
return Err(self.error("Unsupported schema version: expected 'arcadia 2;'."));
}
self.cursor += 1;
self.take(";")?;
self.take("project")?;
let alias = self.identifier()?;
let name = self.quoted()?;
let id = self.optional_id()?;
self.take("{")?;
let mut project = Project {
schema_version: SCHEMA_VERSION,
id,
alias: alias.clone(),
name,
description: String::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
};
let mut aliases = BTreeMap::from([(alias, id)]);
let mut locations = BTreeMap::from([(id, 0)]);
let mut declarations = Vec::new();
let mut graph_count = 0usize;
let mut field_locations = BTreeMap::new();
let mut has_description = false;
while self.text() != "}" {
if self.text() == "description" {
if has_description {
return Err(self.error("Duplicate project description."));
}
has_description = true;
self.cursor += 1;
project.description = self.quoted()?;
self.take(";")?;
continue;
}
let offset = self.current().span.start;
let word = self.text().to_owned();
let kind = declaration_kind(&word)
.ok_or_else(|| self.error(std::format!("Unknown declaration '{word}'.")))?;
graph_count += usize::from(matches!(
kind,
DeclKind::Element(..) | DeclKind::Relation(..)
));
self.limits
.check_counts(graph_count, declarations.len().saturating_add(2), 0)
.map_err(|message| Error {
code: "model_limit",
offset: self.current().span.start,
message,
})?;
self.cursor += 1;
let alias = self.identifier()?;
let name = if matches!(kind, DeclKind::Node) {
String::new()
} else {
self.quoted()?
};
let id = self.optional_id()?;
let fields = self.fields()?;
for (field, value) in &fields {
field_locations.insert((id, field.clone()), value.offset());
}
if aliases.insert(alias.clone(), id).is_some() {
return Err(Error {
code: "invalid_source",
offset,
message: std::format!("Duplicate alias '{alias}'."),
});
}
if locations.insert(id, offset).is_some() {
return Err(Error {
code: "invalid_source",
offset,
message: std::format!("Duplicate identity '{id}'."),
});
}
declarations.push(Declaration {
kind,
alias,
name,
id,
fields,
offset,
});
}
self.take("}")?;
if !self.text().is_empty() {
return Err(self.error("Unexpected content after the project closing brace."));
}
let mut nodes = Vec::new();
for declaration in declarations {
let mut fields = declaration
.fields
.iter()
.map(|(k, v)| Ok((k.clone(), v.resolve(k, &aliases)?)))
.collect::<Result<Map<_, _>, Error>>()?;
let d = &declaration;
let conversion_error = |message: String| Error {
code: "invalid_source",
offset: d
.fields
.iter()
.find(|(field, _)| message.contains(&std::format!("'{field}'")))
.map_or(d.offset, |(_, value)| value.offset()),
message,
};
match d.kind {
DeclKind::Element(kind, layer) => {
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let parent =
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
let mut initial = ElementBody::new(kind, layer).unwrap();
// Schema-2 sources predate explicit scenario viewpoints.
// Preserve their mixed lifelines instead of reinterpreting
// old source using a new graphical factory's typed default.
if !fields.contains_key("viewpoint")
&& let Some(SharedBody::Scenario { viewpoint, .. }) = initial.shared_mut()
{
*viewpoint = ScenarioViewpoint::MixedLegacy;
}
let mut body = serde_json::to_value(initial).unwrap();
let body_fields =
merge_fields(payload(&body), fields).map_err(&conversion_error)?;
replace_payload(&mut body, body_fields);
let body: ElementBody = serde_json::from_value(body)
.map_err(|e| conversion_error(e.to_string()))?;
if body.kind() != kind || body.layer() != layer {
return Err(conversion_error(
"Declaration kind disagrees with its typed body.".into(),
));
}
project.elements.insert(
d.id,
Element {
id: d.id,
alias: d.alias.clone(),
name: d.name.clone(),
description,
parent,
body,
},
);
}
DeclKind::Relation(kind) => {
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
for required in ["source", "target"] {
if !fields.contains_key(required) {
return Err(conversion_error(std::format!(
"Missing required field '{required}'."
)));
}
}
let mut body =
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
.unwrap();
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
replace_payload(&mut body, merged);
let body = serde_json::from_value(body)
.map_err(|e| conversion_error(e.to_string()))?;
project.relations.insert(
d.id,
Relation {
id: d.id,
alias: d.alias.clone(),
name: d.name.clone(),
description,
body,
},
);
}
DeclKind::Diagram => {
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview"});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
record.insert("nodes".into(), serde_json::json!({}));
let diagram: Diagram = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
project.diagrams.insert(d.id, diagram);
}
DeclKind::Node => {
let diagram =
take_required_id(&mut fields, "diagram").map_err(&conversion_error)?;
if !fields.contains_key("element") {
return Err(conversion_error("Missing required field 'element'.".into()));
}
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, false);
let node: NodePlacement = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
nodes.push((diagram, node, d.offset));
}
DeclKind::Artifact => {
if !fields.contains_key("owner") {
return Err(conversion_error("Missing required field 'owner'.".into()));
}
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let artifact = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
project.artifact_links.insert(d.id, artifact);
}
DeclKind::Trace => {
for required in ["oa", "sa"] {
if !fields.contains_key(required) {
return Err(conversion_error(std::format!(
"Missing required field '{required}'."
)));
}
}
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let trace = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
project.traces.insert(d.id, trace);
}
}
}
for (diagram, node, offset) in nodes {
let target = project.diagrams.get_mut(&diagram).ok_or_else(|| Error {
code: "invalid_source",
offset,
message: "Node 'diagram' must reference a diagram declaration.".into(),
})?;
target.nodes.insert(node.id, node);
}
crate::persistence::v2::check_limits(&project, self.limits).map_err(|message| Error {
code: "model_limit",
offset: 0,
message,
})?;
let decoded =
crate::persistence::migrate_v2_limited(project, self.limits).map_err(|error| {
let offset = match &error {
crate::persistence::FileError::Model { issues, .. } => {
self.semantic_error = issues.first().cloned();
issues
.first()
.and_then(|issue| {
issue.element.and_then(|id| {
issue
.field
.as_ref()
.and_then(|field| field_locations.get(&(id, field.clone())))
.or_else(|| locations.get(&id))
})
})
.copied()
.unwrap_or(0)
}
_ => 0,
};
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"invalid_source"
},
offset,
message: error.to_string(),
}
})?;
Ok(super::ParsedDocument {
project: decoded.project,
migration: decoded.migration,
locations,
fields: field_locations,
})
}
}
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
fields.insert("id".into(), Value::String(d.id.to_string()));
fields.insert("alias".into(), Value::String(d.alias.clone()));
if named {
fields.insert("name".into(), Value::String(d.name.clone()));
}
}
pub(super) fn parse(
source: &str,
tokens: Vec<Lexeme>,
limits: ParseLimits,
) -> Result<super::ParsedDocument, Vec<super::Diagnostic>> {
let mut parser = Parser2 {
source,
tokens,
cursor: 0,
value_depth: 0,
limits,
semantic_error: None,
};
parser.document().map_err(|error| {
let mut diagnostic = super::diagnostic(source, error);
if let Some(issue) = parser.semantic_error {
diagnostic.code = issue.code;
diagnostic.object_id = issue.element;
diagnostic.field = issue.field;
}
vec![diagnostic]
})
}
@@ -0,0 +1,470 @@
// Frozen schema-2 contract from c20a207. Private migration reader; do not evolve with schema 3.
//! Structural state-machine rules. Expressions describe intent; they are not executed.
use super::{
model::*,
validation::{Issue, ancestor_of_kind, error, warning},
};
use std::collections::BTreeSet;
fn ancestors(p: &Project, id: Id) -> Vec<Id> {
let mut result = Vec::new();
let mut current = p.elements.get(&id).and_then(|e| e.parent);
let mut seen = BTreeSet::from([id]);
while let Some(id) = current {
if !seen.insert(id) {
break;
}
result.push(id);
current = p.elements.get(&id).and_then(|e| e.parent);
}
result
}
fn within(p: &Project, id: Id, owner: Id) -> bool {
ancestors(p, id).contains(&owner)
}
fn boundary(p: &Project, vertex: &Element) -> Option<Id> {
vertex
.parent
.and_then(|region| p.elements.get(&region))
.and_then(|region| region.parent)
}
fn first_region(p: &Project, vertex: Id, owner: Id) -> Option<Id> {
ancestors(p, vertex).into_iter().find(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind() == Kind::Region && e.parent == Some(owner))
})
}
fn edges<'a>(
p: &'a Project,
candidate: Option<&'a Relation>,
) -> impl Iterator<Item = &'a Relation> {
p.relations
.values()
.filter(move |r| candidate.is_none_or(|c| c.id != r.id))
.chain(candidate)
.filter(|r| r.kind() == RelationKind::StateTransition)
}
fn guard(r: &Relation) -> &str {
match &r.body {
RelationBody::StateTransition { guard, .. } => guard.trim(),
_ => "",
}
}
fn orthogonal(p: &Project, arms: &[Id]) -> bool {
let Some(first) = arms.first() else {
return true;
};
if arms.len() < 2 {
return true;
}
let Some(owner) = ancestors(p, *first).into_iter().find(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind().is_stable_state() || e.kind() == Kind::StateMachine)
&& arms.iter().all(|arm| within(p, *arm, *id))
}) else {
return false;
};
let regions: Option<BTreeSet<_>> = arms
.iter()
.map(|arm| first_region(p, *arm, owner))
.collect();
regions.is_some_and(|regions| regions.len() == arms.len())
}
pub(crate) fn validate_transition(
p: &Project,
r: &Relation,
a: &Element,
b: &Element,
out: &mut Vec<Issue>,
) {
let RelationBody::StateTransition {
transition_kind,
trigger,
triggers,
guard,
..
} = &r.body
else {
return;
};
let machine = ancestor_of_kind(p, a.id, Kind::StateMachine);
if machine.is_none() || machine != ancestor_of_kind(p, b.id, Kind::StateMachine) {
error(
out,
r.id,
"transition_machine",
"endpoints",
"State transition endpoints belong to one state machine.",
);
}
if !a.kind().is_state() || !b.kind().is_state() {
return;
}
if a.id == b.id && !a.kind().is_stable_state() {
error(
out,
r.id,
"pseudostate_self",
"endpoints",
"Only a State or Mode can have a self-transition.",
);
}
let valid_kind = match transition_kind {
TransitionKind::Internal => a.kind().is_stable_state() && a.id == b.id,
TransitionKind::Local if a.kind() == Kind::EntryPoint => {
boundary(p, a).is_some_and(|owner| within(p, b.id, owner))
}
TransitionKind::Local => {
a.kind().is_stable_state()
&& p.children(a.id).any(|e| e.kind() == Kind::Region)
&& within(p, b.id, a.id)
}
TransitionKind::External => a.kind() != Kind::EntryPoint,
};
if !valid_kind {
error(
out,
r.id,
"transition_kind",
"transition_kind",
"Internal requires one stable state; Local goes from a composite or EntryPoint into its boundary; EntryPoint routing must be Local.",
);
}
if matches!(a.kind(), Kind::Final | Kind::Terminate) || b.kind() == Kind::Initial {
error(
out,
r.id,
"pseudostate_direction",
"endpoints",
"Final/Terminate have no outgoing transitions; Initial has no incoming transition.",
);
}
let triggered = !trigger.trim().is_empty() || !triggers.is_empty();
if !a.kind().is_stable_state() && triggered {
error(
out,
r.id,
if a.kind() == Kind::Initial {
"initial_transition"
} else {
"pseudostate_trigger"
},
"triggers",
"Pseudostate routing cannot wait for an event/work trigger; put the trigger on the incoming stable-state transition.",
);
}
if (matches!(
a.kind(),
Kind::Initial | Kind::Fork | Kind::ShallowHistory | Kind::DeepHistory
) || b.kind() == Kind::Join)
&& (!guard.trim().is_empty() || (b.kind() == Kind::Join && triggered))
{
error(
out,
r.id,
if a.kind() == Kind::Initial {
"initial_transition"
} else {
"pseudostate_segment"
},
"guard",
"Initial/history fallback and Fork output/Join input segments have no guard or trigger.",
);
}
if a.kind() == Kind::Fork && !b.kind().is_stable_state()
|| b.kind() == Kind::Join && !a.kind().is_stable_state()
{
error(
out,
r.id,
"orthogonal_endpoint",
"endpoints",
"Fork outputs and Join inputs connect stable States/Modes in distinct orthogonal regions.",
);
}
if matches!(a.kind(), Kind::ShallowHistory | Kind::DeepHistory) {
let scope = a.parent.is_some_and(|region| {
if a.kind() == Kind::ShallowHistory {
b.parent == Some(region)
} else {
within(p, b.id, region)
}
});
if !b.kind().is_stable_state() || !scope {
error(
out,
r.id,
"history_scope",
"target",
"Shallow history falls back to a direct stable child of its region; deep history to a stable descendant within that region.",
);
}
}
for (point, other, outgoing) in [(a, b, true), (b, a, false)] {
if !matches!(point.kind(), Kind::EntryPoint | Kind::ExitPoint) {
continue;
}
let Some(owner) = boundary(p, point) else {
continue;
};
let needs_inside = (point.kind() == Kind::EntryPoint) == outgoing;
if within(p, other.id, owner) != needs_inside || other.id == owner {
error(
out,
r.id,
"boundary_transition",
"endpoints",
"EntryPoint routes from outside to inside its owner boundary; ExitPoint routes from inside to outside it.",
);
}
}
}
pub(crate) fn validate_vertex(
p: &Project,
e: &Element,
candidate: Option<&Relation>,
out: &mut Vec<Issue>,
) {
if !e.kind().is_state() {
return;
}
let incoming: Vec<_> = edges(p, candidate).filter(|r| r.target() == e.id).collect();
let outgoing: Vec<_> = edges(p, candidate).filter(|r| r.source() == e.id).collect();
let (ins, outs) = (incoming.len(), outgoing.len());
let max_in = matches!(e.kind(), Kind::Choice | Kind::Fork).then_some(1);
let max_out = matches!(
e.kind(),
Kind::Initial | Kind::Join | Kind::ShallowHistory | Kind::DeepHistory | Kind::ExitPoint
)
.then_some(1);
if max_in.is_some_and(|max| ins > max) || max_out.is_some_and(|max| outs > max) {
error(
out,
e.id,
if e.kind() == Kind::Initial {
"initial_transition_count"
} else {
"pseudostate_cardinality"
},
"body",
format!(
"{} has too many {} transitions.",
e.kind(),
if max_in.is_some_and(|max| ins > max) {
"incoming"
} else {
"outgoing"
}
),
);
}
let incomplete = match e.kind() {
Kind::Initial | Kind::ShallowHistory | Kind::DeepHistory => outs == 0,
Kind::Final | Kind::Terminate => ins == 0,
Kind::Choice | Kind::Fork => ins == 0 || outs < 2,
Kind::Join => ins < 2 || outs == 0,
Kind::Junction => ins == 0 || outs == 0,
Kind::EntryPoint => outs == 0,
Kind::ExitPoint => {
ins == 0
|| (outs == 0
&& boundary(p, e)
.and_then(|id| p.elements.get(&id))
.is_some_and(|e| e.kind() != Kind::StateMachine))
}
_ => false,
};
if incomplete {
warning(
out,
e.id,
"incomplete_state_vertex",
"body",
format!(
"Finish the required {} incoming/outgoing routing; incomplete vertices can be saved while wiring.",
e.kind()
),
);
}
if e.kind() == Kind::Choice {
let defaults = outgoing.iter().filter(|r| guard(r) == "else").count();
if defaults > 1 {
error(
out,
e.id,
"choice_default",
"guard",
"Choice has at most one literal else branch.",
);
}
if defaults == 0 {
warning(
out,
e.id,
"missing_choice_default",
"guard",
"Add an else branch, or review and document why no alternative may apply.",
);
}
if outgoing.iter().any(|r| guard(r).is_empty()) {
warning(
out,
e.id,
"missing_choice_guard",
"guard",
"Each Choice alternative needs an explicit guard or literal else.",
);
}
}
if e.kind() == Kind::Junction && outs > 1 && outgoing.iter().any(|r| guard(r).is_empty()) {
warning(
out,
e.id,
"junction_guard",
"guard",
"Review the unguarded Junction alternatives; guard expressions are not interpreted for disjointness.",
);
}
if matches!(e.kind(), Kind::Fork | Kind::Join) {
let arms: Vec<_> = if e.kind() == Kind::Fork {
outgoing.iter().map(|r| r.target()).collect()
} else {
incoming.iter().map(|r| r.source()).collect()
};
if !orthogonal(p, &arms) {
error(
out,
e.id,
"orthogonal_regions",
"endpoints",
"Each Fork/Join arm must occupy a distinct first region below their nearest common composite or machine; deeper nesting does not create concurrency.",
);
}
}
if e.kind() == Kind::EntryPoint
&& let Some(owner) = boundary(p, e)
{
let regions: Vec<_> = outgoing
.iter()
.filter_map(|r| first_region(p, r.target(), owner))
.collect();
let distinct: BTreeSet<_> = regions.iter().copied().collect();
if distinct.len() != regions.len() {
error(
out,
e.id,
"entry_point_cardinality",
"target",
"EntryPoint has at most one outgoing transition per destination region.",
);
}
if p.children(owner)
.filter(|r| r.kind() == Kind::Region)
.any(|r| {
!distinct.contains(&r.id) && !p.children(r.id).any(|v| v.kind() == Kind::Initial)
})
{
warning(
out,
e.id,
"entry_region_default",
"target",
"Provide an EntryPoint route or Initial default entry for every concurrent region in this boundary.",
);
}
}
}
pub(crate) fn validate_graph(p: &Project, out: &mut Vec<Issue>) {
for e in p.elements.values() {
validate_vertex(p, e, None, out);
if e.kind() == Kind::Region {
for (kind, code) in [
(Kind::Initial, "multiple_initial"),
(Kind::ShallowHistory, "multiple_shallow_history"),
(Kind::DeepHistory, "multiple_deep_history"),
] {
if p.children(e.id).filter(|c| c.kind() == kind).count() > 1 {
error(
out,
e.id,
code,
"parent",
format!("A region has at most one {kind}."),
);
}
}
let explicit_entry =
edges(p, None).any(|r| within(p, r.target(), e.id) && !within(p, r.source(), e.id));
if !p
.children(e.id)
.any(|v| matches!(v.kind(), Kind::Initial | Kind::EntryPoint))
&& !explicit_entry
{
warning(
out,
e.id,
"missing_initial",
"parent",
"Add an Initial default entry or an explicit incoming entry path for this region.",
);
}
}
if e.kind() == Kind::StateMachine {
validate_reachability(p, e.id, out);
}
}
}
fn validate_reachability(p: &Project, machine: Id, out: &mut Vec<Issue>) {
let mut pending: Vec<_> = p
.children(machine)
.filter(|e| e.kind() == Kind::Region)
.flat_map(|region| p.children(region.id))
.filter(|e| matches!(e.kind(), Kind::Initial | Kind::EntryPoint))
.map(|e| e.id)
.collect();
let mut reached = BTreeSet::new();
while let Some(id) = pending.pop() {
if !reached.insert(id) {
continue;
}
pending.extend(
edges(p, None)
.filter(|r| r.source() == id)
.map(Relation::target),
);
// Entering a nested vertex activates its composite ancestors. Reaching a
// composite also permits each region's default entry; this deliberately
// over-approximates guards/history instead of pretending to execute them.
pending.extend(ancestors(p, id).into_iter().filter(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind().is_stable_state())
}));
pending.extend(
p.children(id)
.filter(|e| e.kind() == Kind::Region)
.flat_map(|region| p.children(region.id))
.filter(|e| e.kind() == Kind::Initial)
.map(|e| e.id),
);
}
for e in p.elements.values().filter(|e| {
e.kind().is_state() && ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine)
}) {
if !reached.contains(&e.id) {
warning(
out,
e.id,
"unreachable_state",
"body",
"No structural path from a machine entry reaches this vertex; review entry, nesting and transitions (guards are not executed).",
);
}
}
}
@@ -0,0 +1,24 @@
use super::model::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Drift {
Unreviewed,
Unchanged,
OaChanged,
SaChanged,
BothChanged,
}
pub fn trace_drift(p: &Project, t: &Trace) -> Drift {
let Some(base) = &t.baseline else {
return Drift::Unreviewed;
};
let (Some(oa), Some(sa)) = (p.properties(t.oa), p.properties(t.sa)) else {
return Drift::Unreviewed;
};
match (oa != base.oa, sa != base.sa) {
(false, false) => Drift::Unchanged,
(true, false) => Drift::OaChanged,
(false, true) => Drift::SaChanged,
(true, true) => Drift::BothChanged,
}
}
File diff suppressed because it is too large Load Diff
+105 -12
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 2;
pub const SCHEMA_VERSION: u32 = 3;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -293,6 +293,54 @@ pub enum PortDirection {
InOut,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ComponentPortKind {
Standard,
Flow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ComponentPortOrientation {
Unset,
In,
Out,
InOut,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LegacyPortContract {
pub direction: PortDirection,
pub interface: Option<Id>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum PortContract {
FunctionInput {
incoming_items: Vec<Id>,
},
FunctionOutput {
outgoing_items: Vec<Id>,
},
Component {
kind: ComponentPortKind,
orientation: ComponentPortOrientation,
},
Legacy,
}
impl PortContract {
pub fn items(&self) -> &[Id] {
match self {
Self::FunctionInput { incoming_items } => incoming_items,
Self::FunctionOutput { outgoing_items } => outgoing_items,
_ => &[],
}
}
pub fn is_function(&self) -> bool {
matches!(
self,
Self::FunctionInput { .. } | Self::FunctionOutput { .. }
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StateKind {
State,
Mode,
@@ -468,8 +516,8 @@ pub enum SharedBody {
items: Vec<Id>,
},
Port {
direction: PortDirection,
interface: Option<Id>,
contract: PortContract,
legacy: Option<LegacyPortContract>,
},
Scenario {
#[serde(default)]
@@ -635,10 +683,8 @@ impl ElementBody {
unit: String::new(),
},
Kind::Interface => SharedBody::Interface { items: vec![] },
Kind::Port => SharedBody::Port {
direction: PortDirection::InOut,
interface: None,
},
// Port creation requires an explicit contract and owner.
Kind::Port => return None,
Kind::Scenario => SharedBody::Scenario {
viewpoint: if layer == Layer::Oa {
ScenarioViewpoint::OperationalActivity
@@ -707,6 +753,7 @@ impl SharedBody {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationKind {
Allocation,
PortAllocation,
Interaction,
CapabilityInvolvement,
ChainInvolvement,
@@ -728,8 +775,9 @@ pub enum RelationKind {
StateAction,
}
impl RelationKind {
pub const ALL: [Self; 20] = [
pub const ALL: [Self; 21] = [
Self::Allocation,
Self::PortAllocation,
Self::Interaction,
Self::CapabilityInvolvement,
Self::ChainInvolvement,
@@ -753,6 +801,7 @@ impl RelationKind {
pub fn permits(self, from: Kind, to: Kind) -> bool {
match self {
Self::Allocation => from.is_performer() && to.is_work(),
Self::PortAllocation => from == Kind::Port && to == Kind::Port,
Self::Interaction => from == Kind::Activity && to == Kind::Activity,
Self::CapabilityInvolvement => {
(from.is_performer() || from.is_work() || from == Kind::Role)
@@ -807,6 +856,7 @@ impl RelationKind {
pub fn hint(self) -> &'static str {
match self {
Self::Allocation => "Performer → leaf activity/function",
Self::PortAllocation => "Component port → allocated function port",
Self::Interaction => "OA activity → OA activity",
Self::CapabilityInvolvement => "Participant/activity/function → capability",
Self::ChainInvolvement => "Process/chain → capability",
@@ -837,6 +887,7 @@ impl fmt::Display for RelationKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Allocation => "Allocation",
Self::PortAllocation => "Port allocation",
Self::Interaction => "Interaction",
Self::CapabilityInvolvement => "Capability involvement",
Self::ChainInvolvement => "Chain involvement",
@@ -875,6 +926,10 @@ pub enum RelationBody {
source: Id,
target: Id,
},
PortAllocation {
source: Id,
target: Id,
},
Interaction {
source: Id,
target: Id,
@@ -899,13 +954,14 @@ pub enum RelationBody {
FunctionalExchange {
source: Id,
target: Id,
source_port: Option<Id>,
target_port: Option<Id>,
source_port: Id,
target_port: Id,
items: Vec<Id>,
},
BoundaryExchange {
source: Id,
target: Id,
oriented: bool,
items: Vec<Id>,
},
MissionCapability {
@@ -984,6 +1040,7 @@ impl RelationBody {
pub fn kind(&self) -> RelationKind {
match self {
Self::Allocation { .. } => RelationKind::Allocation,
Self::PortAllocation { .. } => RelationKind::PortAllocation,
Self::Interaction { .. } => RelationKind::Interaction,
Self::CapabilityInvolvement { .. } => RelationKind::CapabilityInvolvement,
Self::ChainInvolvement { .. } => RelationKind::ChainInvolvement,
@@ -1008,6 +1065,7 @@ impl RelationBody {
pub fn endpoints(&self) -> (Id, Id) {
match self {
Self::Allocation { source, target }
| Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target }
| Self::ChainInvolvement { source, target }
@@ -1032,6 +1090,7 @@ impl RelationBody {
pub fn set_endpoints(&mut self, a: Id, b: Id) {
match self {
Self::Allocation { source, target }
| Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target }
| Self::ChainInvolvement { source, target }
@@ -1067,6 +1126,7 @@ impl RelationBody {
pub fn new(kind: RelationKind, source: Id, target: Id) -> Self {
match kind {
RelationKind::Allocation => Self::Allocation { source, target },
RelationKind::PortAllocation => Self::PortAllocation { source, target },
RelationKind::Interaction => Self::Interaction {
source,
target,
@@ -1079,13 +1139,15 @@ impl RelationBody {
RelationKind::FunctionalExchange => Self::FunctionalExchange {
source,
target,
source_port: None,
target_port: None,
// Unbound drafts must be completed before candidate admission.
source_port: Id::nil(),
target_port: Id::nil(),
items: vec![],
},
RelationKind::BoundaryExchange => Self::BoundaryExchange {
source,
target,
oriented: true,
items: vec![],
},
RelationKind::MissionCapability => Self::MissionCapability { source, target },
@@ -1584,6 +1646,37 @@ impl Project {
pub fn relate(&mut self, kind: RelationKind, name: &str, source: Id, target: Id) -> Id {
let id = Id::new_v4();
let alias = self.fresh_alias(name, "relation", id);
if kind == RelationKind::FunctionalExchange
&& self.elements.contains_key(&source)
&& self.elements.contains_key(&target)
{
let request = crate::ports::FunctionalExchangeRequest {
id,
alias,
name: name.into(),
description: String::new(),
source,
target,
source_port: crate::ports::PortChoice::Auto,
target_port: crate::ports::PortChoice::Auto,
items: vec![],
};
// Project is the unchecked builder; Session validates the complete batch.
for command in crate::ports::functional_exchange_commands(self, &request)
.expect("fresh exchange identities")
{
match command {
crate::Command::CreateElement(e) => {
self.elements.insert(e.id, e);
}
crate::Command::CreateRelation(r) => {
self.relations.insert(r.id, r);
}
_ => unreachable!("functional creation only creates ports and an exchange"),
}
}
return id;
}
let mut body = RelationBody::new(kind, source, target);
if self
.elements
+107 -22
View File
@@ -1,6 +1,48 @@
use crate::*;
use serde::{Deserialize, Serialize};
use std::{io::Write, path::Path};
use thiserror::Error;
#[path = "migration_v2/mod.rs"]
pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MigrationReport {
pub from_schema: u32,
pub to_schema: u32,
pub preserved_ports: Vec<PreservedPort>,
pub generated_endpoints: Vec<GeneratedEndpoint>,
pub orientation_defaults: Vec<OrientationDefault>,
pub required_resolutions: Vec<Id>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PreservedPort {
pub id: Id,
pub alias: String,
pub required_resolution: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GeneratedEndpoint {
pub exchange: Id,
pub side: String,
pub port: Id,
pub owner: Id,
pub alias: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OrientationDefault {
pub exchange: Id,
pub oriented: bool,
}
#[derive(Debug, Clone)]
pub struct DecodeResult {
pub project: Project,
pub migration: Option<MigrationReport>,
}
#[derive(Debug, Error)]
pub enum FileError {
#[error("File operation failed: {0}")]
@@ -11,6 +53,11 @@ pub enum FileError {
Text(String),
#[error("Invalid project: {0}")]
Invalid(String),
#[error("Invalid project: {message}")]
Model {
message: String,
issues: Vec<validation::Issue>,
},
#[error(
"This file changed outside Arcadia Studio. Save as a new file, or reopen it to review the external changes."
)]
@@ -19,21 +66,30 @@ pub enum FileError {
Limit(String),
}
pub fn decode(bytes: &[u8]) -> Result<Project, FileError> {
decode_limited(bytes, crate::language::ParseLimits::UNLIMITED)
decode_with_report(bytes).map(|decoded| decoded.project)
}
pub fn decode_with_report(bytes: &[u8]) -> Result<DecodeResult, FileError> {
decode_with_report_limited(bytes, crate::language::ParseLimits::UNLIMITED)
}
/// Apply admission budgets before validating a candidate or its migrated form.
pub fn decode_limited(
bytes: &[u8],
limits: crate::language::ParseLimits,
) -> Result<Project, FileError> {
decode_with_report_limited(bytes, limits).map(|decoded| decoded.project)
}
/// Admission budgets cover the original graph and all generated migration data.
/// Decoding never rewrites a file or advances a hosted revision.
pub fn decode_with_report_limited(
bytes: &[u8],
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if bytes.len() > limits.max_source_bytes {
return Err(FileError::Limit(format!(
"Input exceeds {} bytes.",
limits.max_source_bytes
)));
}
let p: Project = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
let header: serde_json::Value = serde_json::from_slice(bytes)?;
match header
.get("schema_version")
@@ -42,43 +98,63 @@ pub fn decode_limited(
Some(1) => {
let old = serde_json::from_slice(bytes)?;
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
migrate_v1_limited(old, limits).map_err(FileError::Invalid)?
migrate_v1_with_report_limited(old, limits)?
}
Some(2) => serde_json::from_slice(bytes)?,
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => DecodeResult {
project: serde_json::from_slice(bytes)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1 or 2.".into(),
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
));
}
}
} else {
let text = std::str::from_utf8(bytes).map_err(|e| FileError::Text(e.to_string()))?;
crate::language::parse_limited(text, limits).map_err(|diagnostics| {
crate::language::parse_with_report_limited(text, limits).map_err(|diagnostics| {
let detail = diagnostics
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
if diagnostics.iter().any(|diagnostic| {
matches!(diagnostic.code.as_str(), "model_limit" | "source_limit")
}) {
if diagnostics
.iter()
.any(|d| matches!(d.code.as_str(), "model_limit" | "source_limit"))
{
FileError::Limit(detail)
} else {
FileError::Text(detail)
}
})?
};
limits.check_model(&p).map_err(FileError::Limit)?;
validate_project(&p)?;
Ok(p)
limits
.check_model(&decoded.project)
.map_err(FileError::Limit)?;
validate_project(&decoded.project)?;
Ok(decoded)
}
pub(crate) fn migrate_v1_with_report_limited(
old: v1::Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
let normalized = normalize_v1_to_v2(old, limits).map_err(FileError::Invalid)?;
let mut decoded = migrate_v2_limited(normalized, limits)?;
if let Some(report) = &mut decoded.migration {
report.from_schema = 1;
}
Ok(decoded)
}
/// Explicit V1 adapter. Old identities, labels, descriptions, endpoints and
/// coordinates survive; every newly required identity is deterministic.
pub(crate) fn migrate_v1_limited(
fn normalize_v1_to_v2(
mut old: v1::Project,
limits: crate::language::ParseLimits,
) -> Result<Project, String> {
) -> Result<v2::model::Project, String> {
use v2::model::*;
check_v1_limits(&old, limits)?;
if let Some(issue) = v1::validation::validate(&old).into_iter().next() {
return Err(issue.message);
@@ -180,7 +256,10 @@ pub(crate) fn migrate_v1_limited(
id,
alias,
element: e.id,
position: e.position,
position: Position {
x: e.position.x,
y: e.position.y,
},
size: Size {
width: 224.,
height: 94.,
@@ -236,9 +315,9 @@ pub(crate) fn migrate_v1_limited(
}
*p.elements.get_mut(&e.id).unwrap().steps_mut().unwrap() = steps;
}
if let Some(issue) = validation::validate(&p)
if let Some(issue) = v2::validation::validate(&p)
.into_iter()
.find(|i| i.severity == validation::Severity::Error)
.find(|i| i.severity == v2::validation::Severity::Error)
{
return Err(std::format!(
"V1 migration cannot satisfy V2 validation: {}",
@@ -263,7 +342,7 @@ pub(crate) fn check_v1_limits(
let records = graph.saturating_add(old.elements.len()).saturating_add(2);
limits.check_counts(graph, records, steps)
}
fn migration_id(p: &Project, role: &str) -> Id {
fn migration_id(p: &v2::model::Project, role: &str) -> Id {
let mut suffix = 0;
loop {
let id = Id::new_v5(
@@ -478,12 +557,18 @@ fn validate_project(p: &Project) -> Result<(), FileError> {
.into_iter()
.find(|i| i.severity == validation::Severity::Error)
{
return Err(FileError::Invalid(i.message));
return Err(FileError::Model {
message: i.message.clone(),
issues: vec![i],
});
}
Ok(())
}
pub fn load(path: &Path) -> Result<Project, FileError> {
decode(&std::fs::read(path)?)
load_with_report(path).map(|decoded| decoded.project)
}
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
decode_with_report(&std::fs::read(path)?)
}
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
File diff suppressed because it is too large Load Diff
+72 -13
View File
@@ -20,6 +20,7 @@ pub struct ProjectedEdge {
pub source: Id,
pub target: Id,
pub trace: bool,
pub oriented: bool,
pub source_port: Option<Id>,
pub target_port: Option<Id>,
}
@@ -29,7 +30,36 @@ pub struct ProjectedPort {
/// Placement identity of the owning visible function/participant.
pub owner: Id,
pub position: Position,
pub direction: PortDirection,
pub side: PortAnchorSide,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortAnchorSide {
Left,
Right,
Bottom,
}
pub fn port_anchor_side(e: &Element) -> Option<PortAnchorSide> {
let Some(SharedBody::Port { contract, legacy }) = e.shared() else {
return None;
};
Some(match contract {
PortContract::FunctionInput { .. } => PortAnchorSide::Left,
PortContract::FunctionOutput { .. } => PortAnchorSide::Right,
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
} => PortAnchorSide::Left,
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
} => PortAnchorSide::Right,
PortContract::Legacy => match legacy.as_ref().map(|l| l.direction) {
Some(PortDirection::In) => PortAnchorSide::Left,
Some(PortDirection::Out) => PortAnchorSide::Right,
_ => PortAnchorSide::Bottom,
},
_ => PortAnchorSide::Bottom,
})
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ProjectedView {
@@ -38,10 +68,25 @@ pub struct ProjectedView {
pub ports: Vec<ProjectedPort>,
}
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
let count = [PortDirection::In, PortDirection::Out].into_iter().map(|direction| p.children(element).filter(|port| matches!(port.shared(), Some(SharedBody::Port {direction:d,..}) if *d==direction)).count()).max().unwrap_or(0);
let count = [PortAnchorSide::Left, PortAnchorSide::Right]
.into_iter()
.map(|side| {
p.children(element)
.filter(|port| port_anchor_side(port) == Some(side))
.count()
})
.max()
.unwrap_or(0);
if count > 0 {
size.height = size.height.max(76. + 28. * (count + 1) as f32);
}
let bottom = p
.children(element)
.filter(|port| port_anchor_side(port) == Some(PortAnchorSide::Bottom))
.count();
if bottom > 0 {
size.width = size.width.max(28. * (bottom + 1) as f32);
}
size
}
pub(crate) fn port_footer(p: &Project, element: Id) -> f32 {
@@ -250,8 +295,15 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
.filter(|e| e.kind() == Kind::Port)
.collect();
children.sort_by_key(|e| (&e.alias, e.id));
for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] {
let side: Vec<_> = children.iter().filter(|e| matches!(e.shared(), Some(SharedBody::Port {direction: d, ..}) if *d == direction)).collect();
for anchor_side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Bottom,
] {
let side: Vec<_> = children
.iter()
.filter(|e| port_anchor_side(e) == Some(anchor_side))
.collect();
for (index, port) in side.iter().enumerate() {
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
let y = if p.elements[&node.element].kind().is_performer()
@@ -263,16 +315,16 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
} else {
node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction
};
let position = match direction {
PortDirection::In => Position {
let position = match anchor_side {
PortAnchorSide::Left => Position {
x: node.position.x,
y,
},
PortDirection::Out => Position {
PortAnchorSide::Right => Position {
x: node.position.x + node.size.width,
y,
},
PortDirection::InOut => Position {
PortAnchorSide::Bottom => Position {
x: node.position.x + node.size.width * fraction,
y: node.position.y + node.size.height,
},
@@ -281,7 +333,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
element: port.id,
owner: node.id,
position,
direction,
side: anchor_side,
});
}
}
@@ -324,10 +376,9 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
source_port,
target_port,
..
} => (*source_port, *target_port),
RelationBody::BoundaryExchange { source, target, .. } => {
(Some(*source), Some(*target))
}
} => (Some(*source_port), Some(*target_port)),
RelationBody::BoundaryExchange { source, target, .. }
| RelationBody::PortAllocation { source, target } => (Some(*source), Some(*target)),
_ => (None, None),
};
edges.push(ProjectedEdge {
@@ -335,6 +386,13 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
source,
target,
trace: false,
oriented: !matches!(
&r.body,
RelationBody::BoundaryExchange {
oriented: false,
..
}
),
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
});
@@ -350,6 +408,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
source,
target,
trace: true,
oriented: true,
source_port: None,
target_port: None,
});
+73 -15
View File
@@ -45,10 +45,20 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
visit(id);
}
}
SharedBody::Port {
interface: Some(id),
..
} => visit(id),
SharedBody::Port { contract, legacy } => {
match contract {
PortContract::FunctionInput { incoming_items } => {
incoming_items.iter_mut().for_each(&mut visit)
}
PortContract::FunctionOutput { outgoing_items } => {
outgoing_items.iter_mut().for_each(&mut visit)
}
_ => {}
}
if let Some(id) = legacy.as_mut().and_then(|old| old.interface.as_mut()) {
visit(id);
}
}
SharedBody::Scenario { context, steps, .. } => {
if let Some(id) = context {
visit(id);
@@ -145,7 +155,7 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
items,
..
} => {
for id in [source_port, target_port].into_iter().flatten() {
for id in [source_port, target_port] {
visit(id);
}
for id in items {
@@ -207,7 +217,49 @@ pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
}
}
for r in p.relations.values() {
if removed.contains(&r.source()) || removed.contains(&r.target()) {
let port_removed = matches!(&r.body, RelationBody::FunctionalExchange { source_port, target_port, .. } if removed.contains(source_port) || removed.contains(target_port));
let work_allocation_removed = r.kind() == RelationKind::PortAllocation
&& p.relations.values().any(|allocation| {
allocation.kind() == RelationKind::Allocation
&& removed.contains(&allocation.id)
&& p.elements.get(&r.source()).and_then(|e| e.parent)
== Some(allocation.source())
&& p.elements.get(&r.target()).and_then(|e| e.parent)
== Some(allocation.target())
});
if removed.contains(&r.source())
|| removed.contains(&r.target())
|| port_removed
|| work_allocation_removed
{
removed.insert(r.id);
}
}
// Exchange allocation depends on both explicit endpoint allocations.
for r in p
.relations
.values()
.filter(|r| r.kind() == RelationKind::ExchangeAllocation)
{
let (Some(boundary), Some(work)) =
(p.relations.get(&r.source()), p.relations.get(&r.target()))
else {
continue;
};
let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = &work.body
else {
continue;
};
if p.relations.values().any(|allocation| {
allocation.kind() == RelationKind::PortAllocation
&& removed.contains(&allocation.id)
&& [boundary.source(), boundary.target()].contains(&allocation.source())
&& [*source_port, *target_port].contains(&allocation.target())
}) {
removed.insert(r.id);
}
}
@@ -263,7 +315,20 @@ pub fn delete_cascade(p: &mut Project, id: Id) {
SharedBody::Event {
body: EventBody::Signal { items },
} => items.retain(|id| !removed.contains(id)),
SharedBody::Port { interface, .. } => clear(interface),
SharedBody::Port { contract, legacy } => {
match contract {
PortContract::FunctionInput { incoming_items } => {
incoming_items.retain(|id| !removed.contains(id))
}
PortContract::FunctionOutput { outgoing_items } => {
outgoing_items.retain(|id| !removed.contains(id))
}
_ => {}
}
if let Some(old) = legacy {
clear(&mut old.interface);
}
}
SharedBody::Scenario { context, .. } => clear(context),
SharedBody::Lifeline { represented } => clear(represented),
SharedBody::Constraint { constrained, .. } => {
@@ -336,15 +401,8 @@ pub fn delete_cascade(p: &mut Project, id: Id) {
| RelationBody::BoundaryExchange { items, .. } => {
items.retain(|id| !removed.contains(id))
}
RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} => {
RelationBody::FunctionalExchange { items, .. } => {
items.retain(|id| !removed.contains(id));
clear(source_port);
clear(target_port);
}
_ => {}
}
+18
View File
@@ -453,6 +453,24 @@ fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result<Option<Id
Some(*id)
}
Command::Connect(kind, name, source, target) => {
if *kind == RelationKind::FunctionalExchange {
let id = Id::new_v4();
let commands = crate::ports::functional_exchange_commands(
p,
&FunctionalExchangeRequest {
id,
alias: p.fresh_alias(name, "relation", id),
name: name.clone(),
description: String::new(),
source: *source,
target: *target,
source_port: PortChoice::Auto,
target_port: PortChoice::Auto,
items: vec![],
},
)?;
return apply_command(p, &Command::Batch(commands), depth + 1);
}
Some(p.relate(*kind, name, *source, *target))
}
Command::Delete(id) => {
+147 -6
View File
@@ -196,6 +196,11 @@ fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option<Kind> {
})
}
fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
if layer == Layer::Oa
&& crate::ports::port_contract(e).is_some_and(|contract| *contract != PortContract::Legacy)
{
return None;
}
if let Some(shared) = e.shared()
&& kind == e.kind()
{
@@ -232,8 +237,8 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
RelationBody::FunctionalExchange {
source,
target,
source_port: None,
target_port: None,
source_port: Id::nil(),
target_port: Id::nil(),
items: items.clone(),
}
}
@@ -320,6 +325,47 @@ fn value(props: &TracedProperties, field: TransitionField) -> &String {
TransitionField::Description => &props.description,
}
}
/// SA port implementation details have no fabricated OA counterpart. Propagate
/// omissions through dependent records, except the FE whose ports are removed
/// by its explicit Interaction mapping.
fn sa_port_omissions(p: &Project) -> BTreeSet<Id> {
let mut omitted: BTreeSet<_> = p
.elements
.values()
.filter(|e| {
e.layer() == Layer::Sa
&& crate::ports::port_contract(e).is_some_and(|c| *c != PortContract::Legacy)
})
.map(|e| e.id)
.collect();
loop {
let before = omitted.len();
for r in p
.relations
.values()
.filter(|r| r.kind() != RelationKind::FunctionalExchange)
{
if crate::references::relation_references(r)
.iter()
.any(|id| omitted.contains(id))
{
omitted.insert(r.id);
}
}
for e in p.elements.values().filter(|e| e.layer() == Layer::Sa) {
if crate::references::element_references(e)
.iter()
.any(|id| omitted.contains(id))
{
omitted.insert(e.id);
}
}
if before == omitted.len() {
return omitted;
}
}
}
fn set(props: &mut TracedProperties, field: TransitionField, value: String) {
match field {
TransitionField::Name => props.name = value,
@@ -341,6 +387,12 @@ pub fn plan_transition(
commands: vec![],
};
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
let omitted = if request.direction == TransitionDirection::SaToOa {
sa_port_omissions(project)
} else {
BTreeSet::new()
};
let mut reported_omissions = BTreeSet::new();
if selected.len() != request.sources.len() {
conflict(
&mut plan,
@@ -364,6 +416,9 @@ pub fn plan_transition(
let before = selected.len();
let existing: Vec<_> = selected.iter().copied().collect();
for id in existing {
if omitted.contains(&id) {
continue;
}
if let Some(e) = project.elements.get(&id) {
selected.extend(crate::references::element_references(e));
// Contextual links are owned by the process/chain shared by
@@ -398,7 +453,27 @@ pub fn plan_transition(
selected.extend(project.children(id).map(|e| e.id));
}
} else if let Some(r) = project.relations.get(&id) {
selected.extend(crate::references::relation_references(r));
if request.direction == TransitionDirection::SaToOa
&& let RelationBody::FunctionalExchange {
source,
target,
source_port,
target_port,
items,
} = &r.body
{
// An Interaction needs the work/items, never either SA
// endpoint port, including an unresolved legacy one.
selected.extend([*source, *target]);
selected.extend(items);
for port in [*source_port, *target_port] {
if reported_omissions.insert(port) {
plan.warnings.push(format!("SA-only omission: functional endpoint {} ({port}) is not copied into the portless OA interaction; the original is preserved.", project.named(port)));
}
}
} else {
selected.extend(crate::references::relation_references(r));
}
}
}
if selected.len() == before {
@@ -406,6 +481,12 @@ pub fn plan_transition(
}
}
}
selected.retain(|id| {
if omitted.contains(id) {
if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); }
false
} else { true }
});
let mut choices = BTreeMap::new();
for mapping in &request.mappings {
if choices.insert(mapping.source, mapping).is_some() {
@@ -569,7 +650,17 @@ pub fn plan_transition(
}
}
let mut working = project.clone();
for source in selected.iter().filter(|id| creating.contains(id)) {
let ordered_creations: Vec<_> = selected
.iter()
.filter(|id| creating.contains(id) && project.elements.contains_key(id))
.chain(
selected
.iter()
.filter(|id| creating.contains(id) && project.relations.contains_key(id)),
)
.copied()
.collect();
for source in &ordered_creations {
let Some(target) = targets.get(source).copied() else {
continue;
};
@@ -643,6 +734,12 @@ pub fn plan_transition(
}
});
for dependency in crate::references::relation_references(&created) {
if dependency.is_nil()
&& request.direction == TransitionDirection::OaToSa
&& r.kind() == RelationKind::Interaction
{
continue;
}
if !targets.values().any(|id| *id == dependency)
&& working.layer_of(dependency) != Some(request.direction.target_layer())
{
@@ -660,8 +757,52 @@ pub fn plan_transition(
if !request.fields.contains(&TransitionField::Description) {
created.description.clear();
}
working.relations.insert(target, created.clone());
plan.commands.push(Command::CreateRelation(created));
if request.direction == TransitionDirection::OaToSa
&& let RelationBody::FunctionalExchange {
source,
target: destination,
items,
..
} = &created.body
{
let request = FunctionalExchangeRequest {
id: created.id,
alias: created.alias.clone(),
name: created.name.clone(),
description: created.description.clone(),
source: *source,
target: *destination,
source_port: PortChoice::Auto,
target_port: PortChoice::Auto,
items: items.clone(),
};
match crate::ports::functional_exchange_commands(&working, &request) {
Ok(commands) => {
for command in &commands {
match command {
Command::CreateElement(e) => {
working.elements.insert(e.id, e.clone());
}
Command::CreateRelation(r) => {
working.relations.insert(r.id, r.clone());
}
_ => unreachable!("functional creation only creates objects"),
}
}
plan.commands.extend(commands);
}
Err(err) => conflict(
&mut plan,
Some(*source),
Some(target),
None,
format!("Functional endpoint creation: {err}"),
),
}
} else {
working.relations.insert(target, created.clone());
plan.commands.push(Command::CreateRelation(created));
}
}
}
for source in &selected {
+3 -189
View File
@@ -450,21 +450,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
});
}
}
SharedBody::Port { interface, .. } => {
if let Some(id) = interface {
expect_element(p, &mut out, e.id, *id, "interface", |k| {
k == Kind::Interface
});
} else {
warning(
&mut out,
e.id,
"missing_interface",
"interface",
"Choose a port interface or add an explicit provision/requirement contract.",
);
}
}
SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out),
SharedBody::Scenario {
viewpoint,
context,
@@ -2140,82 +2126,7 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
for item in r.body.items() {
expect_element(p, out, r.id, *item, "items", |k| k == Kind::ExchangeItem);
}
if let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = &r.body
{
for (port, owner, outgoing, field) in [
(*source_port, source, true, "source_port"),
(*target_port, target, false, "target_port"),
] {
if let Some(port) = port {
match p.elements.get(&port) {
Some(e)
if e.parent == Some(owner)
&& matches!(e.shared(),Some(SharedBody::Port{direction,..})if if outgoing{*direction!=PortDirection::In}else{*direction!=PortDirection::Out}) =>
{}
_ => error(
out,
r.id,
"functional_port",
field,
"Functional port must belong to the endpoint function with compatible direction.",
),
}
}
}
}
if r.kind() == RelationKind::BoundaryExchange {
for (e, outgoing, field) in [(a, true, "source"), (b, false, "target")] {
match e.shared() {
Some(SharedBody::Port { direction, .. })
if e.parent
.and_then(|id| p.elements.get(&id))
.is_some_and(|e| e.kind().is_performer())
&& (if outgoing {
*direction != PortDirection::In
} else {
*direction != PortDirection::Out
}) => {}
_ => error(
out,
r.id,
"boundary_port",
field,
"Boundary exchange uses participant ports with output/input compatible direction.",
),
}
}
let interface_items = |port: &Element| {
if let Some(SharedBody::Port {
interface: Some(id),
..
}) = port.shared()
&& let Some(SharedBody::Interface { items }) =
p.elements.get(id).and_then(Element::shared)
{
return Some(items);
}
None
};
for port in [a, b] {
if let Some(items) = interface_items(port) {
for item in r.body.items() {
if !items.contains(item) {
error(
out,
r.id,
"interface_contract",
"items",
"Exchange item is not included in a referenced port interface.",
);
}
}
}
}
}
crate::ports::validate_port_relation(p, r, out);
if let RelationBody::StateTransition {
trigger,
guard,
@@ -2417,51 +2328,7 @@ pub fn allocation_summary(p: &Project, work: Id) -> Option<Id> {
}
fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target()))
else {
return;
};
for (function, port, field) in [
(work.source(), boundary.source(), "source"),
(work.target(), boundary.target(), "target"),
] {
let performer = p
.relations
.values()
.find(|a| a.kind() == RelationKind::Allocation && a.target() == function)
.map(Relation::source);
let owner = p.elements.get(&port).and_then(|e| e.parent);
if let (Some(performer), Some(owner)) = (performer, owner) {
if performer != owner {
error(
out,
r.id,
"exchange_allocation_performer",
field,
"Boundary port owner must perform the corresponding work-exchange endpoint.",
);
}
} else {
warning(
out,
r.id,
"incomplete_exchange_allocation",
field,
"Allocate work endpoints to the boundary-port participants.",
);
}
}
for item in work.body.items() {
if !boundary.body.items().contains(item) {
error(
out,
r.id,
"exchange_allocation_items",
"items",
"The boundary exchange must carry every item of the allocated work exchange.",
);
}
}
crate::ports::validate_exchange_allocation(p, r, out);
}
pub fn effective_performer(p: &Project, work: Id) -> Option<Id> {
@@ -2534,59 +2401,6 @@ fn validate_relation_context(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
"A boundary exchange connects different participants.",
);
}
let ports: Vec<_> = match &r.body {
RelationBody::BoundaryExchange { source, target, .. } => vec![*source, *target],
RelationBody::FunctionalExchange {
source_port,
target_port,
..
} => [*source_port, *target_port].into_iter().flatten().collect(),
_ => vec![],
};
for port in ports {
let mut interfaces = BTreeSet::new();
if let Some(SharedBody::Port {
interface: Some(id),
..
}) = p.elements.get(&port).and_then(Element::shared)
{
interfaces.insert(*id);
}
interfaces.extend(
p.relations
.values()
.filter(|rel| {
matches!(
rel.kind(),
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement
) && rel.source() == port
})
.map(Relation::target),
);
if !interfaces.is_empty() {
let allowed: BTreeSet<_> = interfaces
.iter()
.filter_map(|id| p.elements.get(id))
.filter_map(|e| match e.shared() {
Some(SharedBody::Interface { items }) => Some(items),
_ => None,
})
.flatten()
.copied()
.collect();
for item in r.body.items() {
if !allowed.contains(item) {
error(
out,
r.id,
"port_interface_items",
"items",
"The exchange carries an item outside a port's declared interface contract.",
);
}
}
}
}
if matches!(
r.kind(),
RelationKind::Availability | RelationKind::StateAction
+72 -3
View File
@@ -252,6 +252,63 @@ project mission "Harbor safety — 港 🌊" {
.unwrap()
.push(id);
}
let owner = p
.elements
.values()
.find(|e| e.alias == "safety_system")
.unwrap()
.id;
let function = p
.elements
.values()
.find(|e| e.alias == "detect")
.unwrap()
.id;
let component_port = Id::new_v4();
let function_port = Id::new_v4();
for (id, alias, parent, contract) in [
(
component_port,
"typed_component_port",
owner,
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
),
(
function_port,
"typed_function_output",
function,
PortContract::FunctionOutput {
outgoing_items: vec![],
},
),
] {
p.elements.insert(
id,
Element {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
parent: Some(parent),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract,
legacy: None,
},
),
},
);
}
p.relate(
RelationKind::PortAllocation,
"Typed port allocation",
component_port,
function_port,
);
p
}
@@ -404,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 3; 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",
@@ -708,6 +765,8 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
assert_eq!(from_source, from_json);
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
let mut retained = serde_json::to_value(&from_json).unwrap();
assert_eq!(retained["schema_version"], 3);
retained["schema_version"] = 2.into();
for element in retained["elements"].as_object_mut().unwrap().values_mut() {
for layer in ["Oa", "Sa"] {
let Some(shared) = element["body"]
@@ -716,6 +775,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
else {
continue;
};
if let Some(port) = shared.get_mut("Port") {
assert_eq!(port["contract"], "Legacy");
*port = port["legacy"].clone();
}
if let Some(scenario) = shared
.get_mut("Scenario")
.and_then(serde_json::Value::as_object_mut)
@@ -735,6 +798,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
}
}
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);
boundary.as_object_mut().unwrap().remove("oriented");
}
if let Some(transition) = relation["body"]
.get_mut("StateTransition")
.and_then(serde_json::Value::as_object_mut)
@@ -828,7 +895,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, 2);
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() {
@@ -1027,7 +1094,9 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
break;
}
}
if code.trim_start().starts_with("arcadia 2;") {
if code.trim_start().starts_with("arcadia 2;")
|| code.trim_start().starts_with("arcadia 3;")
{
read(&code);
examples += 1;
}
+364
View File
@@ -0,0 +1,364 @@
use oa_core::{
language::{self, ParseLimits},
persistence::{self, FileError},
*,
};
use serde_json::{Value, json};
const ORIGINAL: &[u8] = include_bytes!("fixtures/v2-coastal-rescue.json");
fn original() -> Value {
serde_json::from_slice(ORIGINAL).unwrap()
}
fn exchange_id(old: &Value) -> String {
old["relations"]
.as_object()
.unwrap()
.iter()
.find(|(_, r)| r["body"].get("FunctionalExchange").is_some())
.unwrap()
.0
.clone()
}
fn without_endpoints() -> Value {
let mut old = original();
let id = exchange_id(&old);
old["relations"][&id]["body"]["FunctionalExchange"]["source_port"] = Value::Null;
old["relations"][&id]["body"]["FunctionalExchange"]["target_port"] = Value::Null;
old
}
fn decode(old: &Value) -> persistence::DecodeResult {
persistence::decode_with_report(&serde_json::to_vec(old).unwrap()).unwrap()
}
#[test]
fn frozen_original_v2_reader_preserves_every_original_record() {
let old = original();
let decoded = persistence::decode_with_report(ORIGINAL).unwrap();
let from_source =
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
assert_eq!(decoded.project, from_source.project);
assert_eq!(decoded.migration, from_source.migration);
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (2, 3));
assert_eq!(report.preserved_ports.len(), 4);
assert!(report.generated_endpoints.is_empty());
assert_eq!(report.orientation_defaults.len(), 1);
assert_eq!(
decoded.project.identities().len(),
old["elements"].as_object().unwrap().len()
+ old["relations"].as_object().unwrap().len()
+ old["diagrams"]
.as_object()
.unwrap()
.values()
.map(|d| 1 + d["nodes"].as_object().unwrap().len())
.sum::<usize>()
+ old["traces"].as_object().unwrap().len()
+ old["artifact_links"].as_object().unwrap().len()
+ 1
);
let current = serde_json::to_value(&decoded.project).unwrap();
for field in [
"id",
"alias",
"name",
"description",
"diagrams",
"traces",
"artifact_links",
] {
assert_eq!(current[field], old[field], "preserve {field}");
}
for (id, before) in old["elements"].as_object().unwrap() {
let after = &current["elements"][id];
for field in ["id", "alias", "name", "description", "parent"] {
assert_eq!(after[field], before[field], "{id}.{field}");
}
for layer in ["Oa", "Sa"] {
if let Some(port) = before["body"][layer]["Shared"].get("Port") {
assert_eq!(
after["body"][layer]["Shared"]["Port"],
json!({"contract":"Legacy", "legacy":port})
);
}
}
}
for (id, before) in old["relations"].as_object().unwrap() {
let after = &current["relations"][id];
for field in ["id", "alias", "name", "description"] {
assert_eq!(after[field], before[field]);
}
if before["body"].get("StateTransition").is_none() {
let mut expected = before.clone();
if let Some(body) = expected["body"].get_mut("BoundaryExchange") {
body["oriented"] = true.into();
}
assert_eq!(*after, expected, "relation {id}");
}
}
}
#[test]
fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
let old = without_endpoints();
let first = decode(&old);
let second = decode(&old);
assert_eq!(first.project, second.project);
assert_eq!(first.migration, second.migration);
let report = first.migration.as_ref().unwrap();
assert_eq!(report.generated_endpoints.len(), 2);
let fe: Id = exchange_id(&old).parse().unwrap();
let items = first.project.relations[&fe].body.items();
for endpoint in &report.generated_endpoints {
assert_eq!(
endpoint.port,
Id::new_v5(
&first.project.id,
format!("v2-fe-port:{fe}:{}", endpoint.side).as_bytes()
)
);
let port = &first.project.elements[&endpoint.port];
assert_eq!(port.parent, Some(endpoint.owner));
assert_eq!(port.alias, endpoint.alias);
match port.shared().unwrap() {
SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
legacy: None,
} => {
assert_eq!(endpoint.side, "source");
assert_eq!(outgoing_items, items);
}
SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
legacy: None,
} => {
assert_eq!(endpoint.side, "target");
assert_eq!(incoming_items, items);
}
other => panic!("unexpected migrated endpoint {other:?}"),
}
}
let canonical = language::format(&first.project);
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);
assert_eq!(canonical, language::format(&reopened.project));
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("migrated.json");
persistence::save(&path, &first.project).unwrap();
let bytes = std::fs::read(&path).unwrap();
let loaded = persistence::load_with_report(&path).unwrap();
assert!(loaded.migration.is_none());
persistence::save(&path, &loaded.project).unwrap();
assert_eq!(std::fs::read(path).unwrap(), bytes);
}
#[test]
fn generated_endpoint_collision_in_any_object_category_is_located_and_rejected() {
let mut old = without_endpoints();
let fe = exchange_id(&old);
let project: Id = old["id"].as_str().unwrap().parse().unwrap();
let collision = Id::new_v5(&project, format!("v2-fe-port:{fe}:source").as_bytes());
old["artifact_links"].as_object_mut().unwrap().insert(collision.to_string(), json!({"id":collision,"alias":"collision_artifact","name":"Keep this identity","description":"Never overwrite me","owner":project,"relation":"References","uri":"docs/evidence.md","revision":null,"fragment":null}));
let bytes = serde_json::to_vec(&old).unwrap();
let error = persistence::decode(&bytes).unwrap_err().to_string();
assert!(
error.contains("identity_collision")
&& error.contains(&fe)
&& error.contains("source_port")
&& error.contains(&collision.to_string()),
"{error}"
);
assert_eq!(serde_json::to_vec(&old).unwrap(), bytes);
}
#[test]
fn generated_alias_collision_is_deterministically_suffixed() {
let mut old = without_endpoints();
let first = decode(&old);
let generated = &first.migration.unwrap().generated_endpoints[0];
let original_port = old["elements"]
.as_object_mut()
.unwrap()
.values_mut()
.find(|e| e["body"]["Sa"]["Shared"].get("Port").is_some())
.unwrap();
original_port["alias"] = generated.alias.clone().into();
let decoded = decode(&old);
let endpoint = &decoded.migration.unwrap().generated_endpoints[0];
assert_eq!(endpoint.port, generated.port);
assert_ne!(endpoint.alias, generated.alias);
assert!(endpoint.alias.starts_with(&generated.alias));
assert_eq!(decoded.project, decode(&old).project);
}
#[test]
fn migration_admission_counts_generated_objects_records_and_references() {
let old = without_endpoints();
let bytes = serde_json::to_vec(&old).unwrap();
let graph =
old["elements"].as_object().unwrap().len() + old["relations"].as_object().unwrap().len();
let migrated = decode(&old).project;
let records = migrated.identities().len();
let reference_count = migrated
.elements
.values()
.map(|e| e.steps().map_or(0, Vec::len))
.sum::<usize>()
+ decode(&old)
.migration
.unwrap()
.generated_endpoints
.iter()
.map(|endpoint| {
migrated.elements[&endpoint.port]
.shared()
.map_or(0, |body| match body {
SharedBody::Port { contract, .. } => contract.items().len() + 2,
_ => 0,
})
})
.sum::<usize>();
for limits in [
ParseLimits {
max_graph_objects: graph + 1,
..ParseLimits::UNLIMITED
},
ParseLimits {
max_records: records - 1,
..ParseLimits::UNLIMITED
},
ParseLimits {
max_step_references: reference_count - 1,
..ParseLimits::UNLIMITED
},
] {
assert!(matches!(
persistence::decode_with_report_limited(&bytes, limits),
Err(FileError::Limit(_))
));
}
let accepted = ParseLimits {
max_graph_objects: graph + 2,
max_records: records,
max_step_references: reference_count,
..ParseLimits::UNLIMITED
};
assert!(persistence::decode_with_report_limited(&bytes, accepted).is_ok());
}
#[test]
fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
let mut old = original();
let fe = exchange_id(&old);
let port = old["relations"][&fe]["body"]["FunctionalExchange"]["source_port"]
.as_str()
.unwrap()
.to_owned();
old["elements"][&port]["body"]["Sa"]["Shared"]["Port"]["direction"] = "In".into();
let error = persistence::decode(&serde_json::to_vec(&old).unwrap())
.unwrap_err()
.to_string();
assert!(error.contains("V2") && error.contains(&fe), "{error}");
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
let result = persistence::decode_with_report(v1).unwrap();
assert_eq!(result.project.schema_version, 3);
assert_eq!(result.migration.unwrap().from_schema, 1);
assert_eq!(persistence::decode(v1).unwrap(), result.project);
}
#[test]
fn loading_v2_does_not_rewrite_original_bytes() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("original.json");
std::fs::write(&path, ORIGINAL).unwrap();
let loaded = persistence::load_with_report(&path).unwrap();
assert!(loaded.migration.is_some());
assert_eq!(std::fs::read(path).unwrap(), ORIGINAL);
}
#[test]
fn legacy_oa_inout_ports_attachments_and_traces_remain_unclassified() {
let source = r##"arcadia 2;
project old_ports "Existing engineering" id "10000000-0000-0000-0000-000000000001" {
oa_entity observer "Observer" {}
oa_actor operator_one "Operator" {}
oa_port old_oa "OA channel" { parent @observer; direction InOut; }
oa_port peer_oa "OA peer" { parent @operator_one; direction InOut; }
boundary_exchange old_oa_link "Operational communication" { source @old_oa; target @peer_oa; }
oa_activity observe "Observe" {}
oa_activity react "React" {}
interaction operational_message "Operational message" { source @observe; target @react; }
sa_function send "Send" {}
sa_function respond "Respond" {}
sa_exchange_item payload "Payload" {}
sa_interface old_interface "Original association" { items [@payload]; }
sa_port directed "Original directed port" { parent @send; direction Out; interface @old_interface; description "Retain — описание"; }
sa_port bidirectional "Original InOut port" id "10000000-0000-0000-0000-000000000002" { parent @respond; direction InOut; interface @old_interface; }
functional_exchange request_message "Request" { source @send; target @respond; source_port @directed; target_port @bidirectional; items [@payload]; }
functional_exchange reply_message "Reply" id "10000000-0000-0000-0000-000000000003" { source @respond; target @send; source_port @bidirectional; items [@payload]; }
interface_provision legacy_role "Preserved role" { source @bidirectional; target @old_interface; }
sa_constraint port_constraint "Original requirement" { constrained [@bidirectional]; expression "review exact meaning"; }
artifact attachment "Attached rationale" { owner @bidirectional; uri "docs/original.md"; revision "v2"; fragment "#port"; }
trace original_trace "Original correspondence" { oa @old_oa; sa @bidirectional; kind Item; }
diagram sa_view "Existing placement" { layer Sa; }
node original_node "" { diagram @sa_view; element @bidirectional; position { x -0; y 23.5; }; size { width 44; height 22; }; }
}
"##;
// Node declarations intentionally have no display-name string.
let source = source.replace("node original_node \"\"", "node original_node");
let decoded = language::parse_with_report(&source).unwrap();
let p = decoded.project;
let report = decoded.migration.unwrap();
assert_eq!(report.preserved_ports.len(), 4);
assert_eq!(report.generated_endpoints.len(), 1);
assert_eq!(report.orientation_defaults.len(), 1);
let original: Id = "10000000-0000-0000-0000-000000000002".parse().unwrap();
assert!(matches!(
p.elements[&original].shared(),
Some(SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: Some(_)
})
})
));
assert!(
p.elements
.values()
.filter(|e| e.layer() == Layer::Oa && e.kind() == Kind::Port)
.all(|e| matches!(
e.shared(),
Some(SharedBody::Port {
contract: PortContract::Legacy,
..
})
))
);
assert_eq!(p.artifact_links.values().next().unwrap().owner, original);
assert_eq!(p.traces.values().next().unwrap().sa, original);
let node = p
.diagrams
.values()
.flat_map(|d| d.nodes.values())
.next()
.unwrap();
assert_eq!(node.element, original);
assert_eq!(node.position.x.to_bits(), (-0.0f32).to_bits());
assert_eq!(node.position.y, 23.5);
assert!(
p.relations
.values()
.any(|r| r.kind() == RelationKind::InterfaceProvision && r.source() == original)
);
assert_eq!(
p.relations
.values()
.filter(|r| r.kind() == RelationKind::PortAllocation)
.count(),
0
);
assert_eq!(p, language::parse(&language::format(&p)).unwrap());
}
+109
View File
@@ -0,0 +1,109 @@
use oa_core::{language, persistence, *};
const SOURCE: &str = r#"arcadia 3;
project typed_ports "Typed ports" {
sa_system producer "Producer" {}
sa_actor consumer "Consumer" {}
sa_function send "Send" {}
sa_function receive "Receive" {}
allocation sends "Produces" { source @producer; target @send; }
allocation receives "Consumes" { source @consumer; target @receive; }
sa_exchange_item data "Data" {}
sa_interface service "Service" { items [@data]; }
sa_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; }; }; }
sa_port client "Client port" { parent @consumer; contract { Component { kind Standard; orientation Unset; }; }; }
interface_provision provided "Provides service" { source @provider; target @service; }
interface_requirement required "Requires service" { source @client; target @service; }
functional_exchange message "Message" { source @send; target @receive; source_port @output; target_port @input; items [@data]; }
boundary_exchange connection "Connection" { source @provider; target @client; oriented false; items [@data]; }
port_allocation output_allocation "Output allocation" { source @provider; target @output; }
port_allocation input_allocation "Input allocation" { source @client; target @input; }
exchange_allocation carried "Carried message" { source @connection; target @message; }
sa_port preserved "Preserved generic port" { parent @send; contract Legacy; legacy { direction InOut; interface @service; }; }
}
"#;
#[test]
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);
for field in [
"incoming_items [@data]",
"outgoing_items [@data]",
"interface @service",
"source_port @output",
"target_port @input",
"oriented false",
"port_allocation",
] {
assert!(canonical.contains(field), "{field}\n{canonical}");
}
assert_eq!(language::parse(&canonical).unwrap(), decoded.project);
assert_eq!(
persistence::decode(&serde_json::to_vec(&decoded.project).unwrap()).unwrap(),
decoded.project
);
let preserved = decoded
.project
.elements
.values()
.find(|e| e.alias == "preserved")
.unwrap();
let service = decoded
.project
.elements
.values()
.find(|e| e.alias == "service")
.unwrap()
.id;
assert!(references::element_references(preserved).contains(&service));
assert!(
language::lint(&canonical)
.iter()
.any(|d| d.object_id == Some(preserved.id)
&& d.severity == validation::Severity::Warning)
);
}
#[test]
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"),
(
"contract { FunctionOutput { outgoing_items [@data]; }; };",
"contract",
),
] {
let candidate = SOURCE.replace(remove, "");
let errors = language::parse(&candidate).unwrap_err();
assert!(
errors.iter().any(|d| d.message.contains(expected)),
"{errors:?}"
);
assert!(errors.iter().all(|d| d.line > 0 && d.column > 0));
}
assert!(language::parse(&SOURCE.replace("source_port @output", "source_port none")).is_err());
}
#[test]
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;",
);
assert!(language::parse(&mixed).is_err());
let typo = SOURCE.replace("interface @service;", "interface @missing;");
let error = language::parse(&typo).unwrap_err();
assert!(error.iter().any(|d| d.message.contains("@missing")));
let item_typo = SOURCE.replace("incoming_items [@data]", "incoming_items [@missing]");
assert!(
language::parse(&item_typo)
.unwrap_err()
.iter()
.any(|d| d.message.contains("@missing"))
);
}
+601
View File
@@ -0,0 +1,601 @@
use oa_core::{projection::*, transitions::*, validation, *};
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn request(p: &Project, source: Id, target: Id, items: Vec<Id>) -> FunctionalExchangeRequest {
let id = Id::new_v4();
FunctionalExchangeRequest {
id,
alias: p.fresh_alias("message", "exchange", id),
name: "Message".into(),
description: "Engineering rationale".into(),
source,
target,
source_port: PortChoice::Auto,
target_port: PortChoice::Auto,
items,
}
}
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let a = add(&mut p, Kind::Function, Layer::Sa, "Send");
let b = add(&mut p, Kind::Function, Layer::Sa, "Receive");
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Information");
let request = request(&p, a, b, vec![item]);
p = execute_batch(&p, &plan_functional_exchange(&p, &request).unwrap()).unwrap();
(p, a, b, item, request.id)
}
fn endpoints(p: &Project, id: Id) -> (Id, Id) {
match p.relations[&id].body {
RelationBody::FunctionalExchange {
source_port,
target_port,
..
} => (source_port, target_port),
_ => panic!("not a functional exchange"),
}
}
fn allocate(p: &mut Project, a: Id, b: Id) -> (Id, Id) {
let first = add(p, Kind::System, Layer::Sa, "System");
let second = add(p, Kind::SystemActor, Layer::Sa, "Partner");
p.relate(RelationKind::Allocation, "Send allocation", first, a);
p.relate(RelationKind::Allocation, "Receive allocation", second, b);
(first, second)
}
fn component_request(p: &Project, fe: Id, kind: ComponentPortKind) -> ComponentExchangeRequest {
let id = Id::new_v4();
ComponentExchangeRequest {
functional_exchange: fe,
boundary: ComponentExchangeChoice::Create {
id,
alias: p.fresh_alias("boundary", "exchange", id),
name: "Boundary".into(),
description: "Physical realization".into(),
port_kind: kind,
oriented: kind == ComponentPortKind::Flow,
source_port: PortChoice::Auto,
target_port: PortChoice::Auto,
},
}
}
fn boundary_id(request: &ComponentExchangeRequest) -> Id {
match request.boundary {
ComponentExchangeChoice::Create { id, .. } | ComponentExchangeChoice::Reuse(id) => id,
}
}
fn code(p: &Project, expected: &str) -> bool {
validation::validate(p)
.iter()
.any(|issue| issue.code == expected && issue.severity == validation::Severity::Error)
}
#[test]
fn functional_creation_reuse_and_item_sync_are_atomic_and_identity_safe() {
let (p, a, b, item, fe) = fixture();
let (output, input) = endpoints(&p, fe);
assert!(
matches!(port_contract(&p.elements[&output]), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items == &[item])
);
assert!(
matches!(port_contract(&p.elements[&input]), Some(PortContract::FunctionInput { incoming_items }) if incoming_items == &[item])
);
let next = request(&p, a, b, vec![item]);
let commands = plan_functional_exchange(&p, &next).unwrap();
assert_eq!(commands.len(), 1);
let mut session = Session::new(p.clone());
session.execute(Command::Batch(commands)).unwrap();
assert_eq!(endpoints(session.project(), next.id), (output, input));
assert_eq!(session.revision(), 1);
session.undo();
assert_eq!(session.project(), &p);
let mut wrong = request(&p, a, b, vec![item]);
wrong.source_port = PortChoice::Reuse(input);
assert!(plan_functional_exchange(&p, &wrong).is_err());
assert!(plan_function_port_items(&p, output, vec![]).is_err());
assert_eq!(session.project(), &p);
assert!(ElementBody::new(Kind::Port, Layer::Sa).is_none());
}
#[test]
fn ambiguous_reuse_creates_a_fresh_port_and_collisions_never_overwrite() {
let (mut p, a, b, item, _) = fixture();
let other = Id::new_v4();
p.elements.insert(
other,
make_port(
&p,
other,
a,
"Alternative output",
PortContract::FunctionOutput {
outgoing_items: vec![item],
},
)
.unwrap(),
);
let req = request(&p, a, b, vec![item]);
let expected = Id::new_v5(&req.id, b"function-source-port");
let commands = plan_functional_exchange(&p, &req).unwrap();
assert_eq!(
serde_json::to_value(&commands).unwrap(),
serde_json::to_value(plan_functional_exchange(&p, &req).unwrap()).unwrap()
);
let result = execute_batch(&p, &commands).unwrap();
assert_eq!(endpoints(&result, req.id).0, expected);
let occupied = p.elements[&other].clone();
let mut blocker = occupied.clone();
blocker.id = expected;
blocker.alias = "block_generated_identity".into();
p.elements.insert(expected, blocker);
assert!(
plan_functional_exchange(&p, &req)
.unwrap_err()
.0
.contains("port_id_collision")
);
assert_eq!(p.elements[&other], occupied);
}
#[test]
fn component_generation_requires_real_allocations_and_reuses_existing_facts() {
let (mut p, a, b, _, fe) = fixture();
let initial = component_request(&p, fe, ComponentPortKind::Flow);
assert!(
plan_component_exchange(&p, &initial)
.unwrap_err()
.0
.contains("port_allocation_owner")
);
allocate(&mut p, a, b);
let req = component_request(&p, fe, ComponentPortKind::Flow);
let boundary = boundary_id(&req);
let result = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
assert_eq!(
result
.relations
.values()
.filter(|r| r.kind() == RelationKind::PortAllocation)
.count(),
2
);
assert_eq!(
result
.relations
.values()
.filter(|r| r.kind() == RelationKind::ExchangeAllocation)
.count(),
1
);
let again = ComponentExchangeRequest {
functional_exchange: fe,
boundary: ComponentExchangeChoice::Reuse(boundary),
};
assert!(plan_component_exchange(&result, &again).unwrap().is_empty());
let mut wrong = result.clone();
let source = wrong.relations[&boundary].source();
if let Some(SharedBody::Port { contract, .. }) =
wrong.elements.get_mut(&source).unwrap().shared_mut()
{
*contract = PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
};
}
assert!(code(&wrong, "boundary_port"));
assert!(code(&wrong, "port_allocation_contract"));
let mut session = Session::new(p.clone());
session
.execute(Command::Batch(plan_component_exchange(&p, &req).unwrap()))
.unwrap();
session.undo();
assert_eq!(session.project(), &p);
}
#[test]
fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_rejected() {
let (mut p, a, b, item, fe) = fixture();
allocate(&mut p, a, b);
let req = component_request(&p, fe, ComponentPortKind::Standard);
let boundary = boundary_id(&req);
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
let (source, target) = p.relations[&boundary].body.endpoints();
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service");
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
let requirement = p.relate(
RelationKind::InterfaceRequirement,
"Caller requires service",
source,
interface,
);
let provider = p.relate(
RelationKind::InterfaceProvision,
"Callee provides service",
target,
interface,
);
validate_candidate(&p).unwrap();
assert!(
!validation::validate(&p)
.iter()
.any(|i| i.code == "unsatisfied_interface_requirement")
);
assert!(matches!(
p.relations[&boundary].body,
RelationBody::BoundaryExchange {
oriented: false,
..
}
));
let without_provider = execute_batch(&p, &[Command::Delete(provider)]).unwrap();
assert!(
validation::validate(&without_provider).iter().any(
|i| i.element == Some(requirement) && i.code == "unsatisfied_interface_requirement"
)
);
if let Some(SharedBody::Port { contract, .. }) =
p.elements.get_mut(&source).unwrap().shared_mut()
{
*contract = PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
};
}
assert!(code(&p, "mixed_component_ports"));
}
#[test]
fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_and_trace() {
let (mut p, a, b, item, forward) = fixture();
let (old, _) = endpoints(&p, forward);
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface");
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into();
*p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: Some(interface),
}),
};
let reverse_req = request(&p, b, a, vec![item]);
p = execute_batch(&p, &plan_functional_exchange(&p, &reverse_req).unwrap()).unwrap();
if let RelationBody::FunctionalExchange { target_port, .. } =
&mut p.relations.get_mut(&reverse_req.id).unwrap().body
{
*target_port = old;
}
let artifact = Id::new_v4();
p.artifact_links.insert(
artifact,
ArtifactLink {
id: artifact,
alias: "rationale".into(),
name: "Evidence".into(),
description: "Attached original evidence".into(),
owner: old,
relation: ArtifactRelation::References,
uri: "docs/evidence.md".into(),
revision: Some("v2".into()),
fragment: None,
},
);
let constraint = add(&mut p, Kind::Constraint, Layer::Sa, "Port requirement");
*p.elements
.get_mut(&constraint)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Constraint {
expression: "original meaning".into(),
constrained: vec![old],
};
let oa_work = add(&mut p, Kind::Activity, Layer::Oa, "Original work");
let oa_port = Id::new_v4();
let mut legacy_oa = p.elements[&old].clone();
legacy_oa.id = oa_port;
legacy_oa.alias = "original_oa_port".into();
legacy_oa.parent = Some(oa_work);
legacy_oa.body = ElementBody::shared_in(
Layer::Oa,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
);
p.elements.insert(oa_port, legacy_oa);
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "port_trace".into(),
name: "Original correspondence".into(),
description: String::new(),
oa: oa_port,
sa: old,
kind: TraceKind::Item,
baseline: None,
},
);
validate_candidate(&p).unwrap();
let req = LegacyWorkPortSplitRequest {
port: old,
primary: FunctionPortSide::Input,
incoming_items: vec![item],
outgoing_items: vec![item],
};
let commands = plan_legacy_work_port_split(&p, &req).unwrap();
let mut session = Session::new(p.clone());
session.execute(Command::Batch(commands)).unwrap();
let result = session.project();
let opposite = Id::new_v5(&old, b"split-opposite-port");
assert_eq!(endpoints(result, forward).0, opposite);
assert_eq!(endpoints(result, reverse_req.id).1, old);
assert_eq!(
result.elements[&old].description,
p.elements[&old].description
);
assert!(
matches!(result.elements[&old].shared(), Some(SharedBody::Port { contract: PortContract::FunctionInput { .. }, legacy: Some(LegacyPortContract { direction: PortDirection::InOut, interface: Some(id) }) }) if *id == interface)
);
assert_eq!(
result.artifact_links[&artifact],
p.artifact_links[&artifact]
);
assert_eq!(result.traces[&trace], p.traces[&trace]);
assert_eq!(result.elements[&constraint], p.elements[&constraint]);
session.undo();
assert_eq!(session.project(), &p);
assert!(
plan_legacy_port_resolution(
&p,
&LegacyPortResolution {
port: oa_port,
contract: PortContract::FunctionInput {
incoming_items: vec![]
}
}
)
.is_err()
);
}
#[test]
fn deletion_preserves_independent_ports_but_removes_required_dependents() {
let (mut p, a, b, _, fe) = fixture();
allocate(&mut p, a, b);
let req = component_request(&p, fe, ComponentPortKind::Flow);
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
let (output, input) = endpoints(&p, fe);
let allocation = p
.relations
.values()
.find(|r| r.kind() == RelationKind::PortAllocation)
.unwrap()
.id;
let removed_allocation = execute_batch(&p, &[Command::Delete(allocation)]).unwrap();
assert!(removed_allocation.elements.contains_key(&output));
assert!(
removed_allocation
.relations
.values()
.all(|r| r.kind() != RelationKind::ExchangeAllocation)
);
let removed_fe = execute_batch(&p, &[Command::Delete(fe)]).unwrap();
assert!(removed_fe.elements.contains_key(&output) && removed_fe.elements.contains_key(&input));
let removed_port = execute_batch(&p, &[Command::Delete(output)]).unwrap();
assert!(!removed_port.relations.contains_key(&fe));
assert!(removed_port.elements.contains_key(&input));
let new = Id::new_v4();
let changed = execute_batch(&p, &[Command::Reidentify(output, new)]).unwrap();
assert_eq!(endpoints(&changed, fe).0, new);
assert!(
changed
.relations
.values()
.any(|r| r.kind() == RelationKind::PortAllocation && r.target() == new)
);
}
#[test]
fn directed_legacy_resolution_retains_unclassified_interface_and_role_identity() {
let (mut p, _, _, item, fe) = fixture();
let output = endpoints(&p, fe).0;
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service");
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::Out,
interface: Some(interface),
}),
};
let role = p.relate(
RelationKind::InterfaceRequirement,
"Original unreviewed role",
output,
interface,
);
let req = LegacyPortResolution {
port: output,
contract: PortContract::FunctionOutput {
outgoing_items: vec![item],
},
};
let result = execute_batch(&p, &plan_legacy_port_resolution(&p, &req).unwrap()).unwrap();
assert_eq!(result.relations[&role], p.relations[&role]);
assert_eq!(result.elements[&output].alias, p.elements[&output].alias);
assert!(
validation::validate(&result)
.iter()
.any(|issue| issue.element == Some(role) && issue.code == "legacy_interface_role")
);
let replacement = Id::new_v4();
let remapped = execute_batch(&result, &[Command::Reidentify(interface, replacement)]).unwrap();
assert!(
matches!(remapped.elements[&output].shared(), Some(SharedBody::Port { legacy: Some(LegacyPortContract { interface: Some(id), .. }), .. }) if *id == replacement)
);
let removed = execute_batch(&remapped, &[Command::Delete(replacement)]).unwrap();
assert!(removed.elements.contains_key(&item));
assert!(!removed.relations.contains_key(&role));
assert!(
matches!(removed.elements[&output].shared(), Some(SharedBody::Port { contract: PortContract::FunctionOutput { outgoing_items }, legacy: Some(LegacyPortContract { interface: None, .. }) }) if outgoing_items == &[item])
);
}
#[test]
fn transitions_create_sa_ports_and_omit_sa_implementation_from_portless_oa() {
let mut oa = Project::empty();
let a = add(&mut oa, Kind::Activity, Layer::Oa, "Send");
let b = add(&mut oa, Kind::Activity, Layer::Oa, "Receive");
let interaction = oa.relate(RelationKind::Interaction, "Operational exchange", a, b);
let mut req = TransitionRequest::new(TransitionDirection::OaToSa, vec![interaction]);
req.scope = DependencyScope::Required;
let plan = plan_transition(&oa, &req).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let sa = apply_transition(&oa, &plan).unwrap();
assert_eq!(
sa.elements
.values()
.filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Sa)
.count(),
2
);
assert_eq!(
sa.elements
.values()
.filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa)
.count(),
0
);
let (mut p, a, b, _, fe) = fixture();
allocate(&mut p, a, b);
let component_req = component_request(&p, fe, ComponentPortKind::Flow);
let boundary = boundary_id(&component_req);
p = execute_batch(&p, &plan_component_exchange(&p, &component_req).unwrap()).unwrap();
let mut reverse = TransitionRequest::new(TransitionDirection::SaToOa, vec![fe, boundary]);
reverse.scope = DependencyScope::Required;
let plan = plan_transition(&p, &reverse).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
let result = apply_transition(&p, &plan).unwrap();
assert_eq!(
result
.elements
.values()
.filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa)
.count(),
0
);
assert!(
result
.relations
.values()
.any(|r| r.kind() == RelationKind::Interaction)
);
for e in p.elements.values() {
assert_eq!(result.elements[&e.id], *e);
}
for r in p.relations.values() {
assert_eq!(result.relations[&r.id], *r);
}
// A required FE dependency is portless in OA even while an SA endpoint
// retains unresolved legacy semantics. No incidental legacy copy is made.
let (mut legacy, _, _, _, legacy_fe) = fixture();
let legacy_port = endpoints(&legacy, legacy_fe).0;
*legacy
.elements
.get_mut(&legacy_port)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
};
let mut req = TransitionRequest::new(TransitionDirection::SaToOa, vec![legacy_fe]);
req.scope = DependencyScope::Required;
let plan = plan_transition(&legacy, &req).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let result = apply_transition(&legacy, &plan).unwrap();
assert!(
!result
.elements
.values()
.any(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa)
);
}
#[test]
fn projection_anchors_are_contract_aware_and_unoriented_connections_keep_their_identity() {
let (mut p, a, b, _, fe) = fixture();
let (system, actor) = allocate(&mut p, a, b);
let req = component_request(&p, fe, ComponentPortKind::Standard);
let boundary = boundary_id(&req);
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
let diagram = p.ensure_diagram(Layer::Sa);
for (index, id) in [system, actor, a, b].into_iter().enumerate() {
p.place(
diagram,
id,
Position {
x: 50. + index as f32 * 280.,
y: 100.,
},
);
}
let view = project_view(&p, diagram);
let edge = view.edges.iter().find(|e| e.id == boundary).unwrap();
assert!(!edge.oriented);
assert_eq!(edge.source_port, Some(p.relations[&boundary].source()));
assert!(
view.ports
.iter()
.filter(|port| [
p.relations[&boundary].source(),
p.relations[&boundary].target()
]
.contains(&port.element))
.all(|port| port.side == PortAnchorSide::Bottom)
);
let (output, input) = endpoints(&p, fe);
assert_eq!(
view.ports
.iter()
.find(|port| port.element == output)
.unwrap()
.side,
PortAnchorSide::Right
);
assert_eq!(
view.ports
.iter()
.find(|port| port.element == input)
.unwrap()
.side,
PortAnchorSide::Left
);
assert!(
p.diagrams[&diagram]
.nodes
.values()
.all(|node| p.elements[&node.element].kind() != Kind::Port)
);
}
+41 -3
View File
@@ -122,6 +122,26 @@ fn fixture(layer: Layer) -> Fixture {
| RelationBody::FunctionalExchange { items, .. } => items.push(item),
_ => unreachable!(),
}
if let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = p.relations[&exchange].body
{
for port in [source_port, target_port] {
match p.elements.get_mut(&port).unwrap().shared_mut() {
Some(SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
}) => incoming_items.push(item),
Some(SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
}) => outgoing_items.push(item),
_ => unreachable!(),
}
}
}
let call = add_occurrence(
&mut p,
scenario,
@@ -1010,9 +1030,27 @@ fn role_lifelines_require_explicit_work_assignment_not_just_a_shared_realizing_e
validation::semantic_representation_compatible(&p, target, work),
Some(false)
);
let port = p
.add_element(Kind::Port, Layer::Oa, "Controller port", Some(entity))
.unwrap();
let port = Id::new_v4();
p.elements.insert(
port,
Element {
id: port,
alias: "legacy_controller_port".into(),
name: "Controller port".into(),
description: String::new(),
parent: Some(entity),
body: ElementBody::shared_in(
Layer::Oa,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
assert_eq!(
validation::semantic_representation_compatible(&p, entity, port),
Some(true)
+53 -10
View File
@@ -334,11 +334,29 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
let port = |p: &mut Project, name: &str, parent, direction| {
let id = add(p, Kind::Port, Layer::Sa, name, Some(parent));
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
direction,
interface: Some(interface),
let id = Id::new_v4();
let contract = if p.elements[&parent].kind() == Kind::Function {
if direction == PortDirection::Out {
PortContract::FunctionOutput {
outgoing_items: vec![item],
}
} else {
PortContract::FunctionInput {
incoming_items: vec![item],
}
}
} else {
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: if direction == PortDirection::Out {
ComponentPortOrientation::Out
} else {
ComponentPortOrientation::In
},
}
};
p.elements
.insert(id, make_port(p, id, parent, name, contract).unwrap());
id
};
let out = port(&mut p, "System output", system, PortDirection::Out);
@@ -355,10 +373,14 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
source: f1,
target: f2,
source_port: Some(fp1),
target_port: Some(fp2),
source_port: fp1,
target_port: fp2,
items: vec![item],
};
p.relate(RelationKind::Allocation, "Send allocation", system, f1);
p.relate(RelationKind::Allocation, "Receive allocation", actor, f2);
p.relate(RelationKind::PortAllocation, "Output allocation", out, fp1);
p.relate(RelationKind::PortAllocation, "Input allocation", input, fp2);
p.relate(
RelationKind::ExchangeAllocation,
"Carried on",
@@ -381,7 +403,7 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
if let RelationBody::FunctionalExchange { source_port, .. } =
&mut p.relations.get_mut(&exchange).unwrap().body
{
*source_port = Some(input);
*source_port = input;
}
rejected(&p, "functional_port");
}
@@ -721,15 +743,36 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
};
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
let port = add(&mut p, Kind::Port, Layer::Sa, "Output", Some(function));
let port = Id::new_v4();
p.elements.insert(
port,
make_port(
&p,
port,
function,
"Output",
PortContract::FunctionOutput {
outgoing_items: vec![item],
},
)
.unwrap(),
);
let rel = p.relate(RelationKind::FunctionalExchange, "Data", function, other);
if let RelationBody::FunctionalExchange {
source_port, items, ..
} = &mut p.relations.get_mut(&rel).unwrap().body
{
*source_port = Some(port);
*source_port = port;
items.push(item);
}
if let RelationBody::FunctionalExchange { target_port, .. } = p.relations[&rel].body
&& let Some(SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
}) = p.elements.get_mut(&target_port).unwrap().shared_mut()
{
incoming_items.push(item);
}
let dtype_new = Id::new_v4();
let port_new = Id::new_v4();
let result = execute_batch(
@@ -744,7 +787,7 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
matches!(result.elements[&field].shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(id),..})if *id==dtype_new)
);
assert!(
matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:Some(id),..}if *id==port_new)
matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:id,..}if *id==port_new)
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ axum = { version = "0.8", default-features = false, features = ["http1", "json",
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "io-util"] }
tower-http = { version = "0.6", features = ["limit"] }
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
uuid.workspace = true
thiserror.workspace = true
tempfile = "3"
+56 -35
View File
@@ -182,13 +182,13 @@ fn not_modified(stored: &StoredProject, headers: &HeaderMap) -> Option<Response>
fn summary(stored: &StoredProject) -> Value {
json!({"resource_id":stored.resource_id,"revision":stored.revision,"model_id":stored.model.id,
"schema_version":stored.model.schema_version,"name":stored.model.name,
"elements":stored.model.elements.len(),"relations":stored.model.relations.len()})
"elements":stored.model.elements.len(),"relations":stored.model.relations.len(),"migration":stored.migration})
}
fn parse_source(body: &[u8]) -> ApiResult<Project> {
fn parse_source(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
let source =
std::str::from_utf8(body).map_err(|_| ApiError::bad_request("Source must be UTF-8."))?;
oa_core::language::parse_limited(source, MODEL_LIMITS).map_err(|diagnostics| {
oa_core::language::parse_with_report_limited(source, MODEL_LIMITS).map_err(|diagnostics| {
let code = if diagnostics.iter().any(|d| d.code == "model_limit") {
"model_limit"
} else {
@@ -215,22 +215,41 @@ fn parse_source(body: &[u8]) -> ApiResult<Project> {
})
}
fn import_json(body: &[u8]) -> ApiResult<Project> {
// Format is chosen by HTTP. The shared decoder owns explicit schema-1 migration.
fn import_json(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
// Format is chosen by HTTP. The shared decoder owns explicit schema-1 and schema-2 migration.
let _: Value = json_body(body)?;
oa_core::persistence::decode_limited(body, MODEL_LIMITS).map_err(|error| match error {
oa_core::persistence::FileError::Limit(detail) => {
ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "model_limit", detail)
}
oa_core::persistence::FileError::Json(error) => {
ApiError::bad_request(format!("Invalid model JSON: {error}"))
}
error => ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid_model",
error.to_string(),
),
})
oa_core::persistence::decode_with_report_limited(body, MODEL_LIMITS).map_err(
|error| match error {
oa_core::persistence::FileError::Limit(detail) => {
ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "model_limit", detail)
}
oa_core::persistence::FileError::Model { message, issues } => {
let mut error =
ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "invalid_model", message);
error.issues = issues
.into_iter()
.map(|issue| IssueDto {
severity: "error",
code: issue.code,
message: issue.message,
field: issue.field,
object_id: issue.element,
line: None,
column: None,
})
.collect();
error
}
oa_core::persistence::FileError::Json(error) => {
ApiError::bad_request(format!("Invalid model JSON: {error}"))
}
error => ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid_model",
error.to_string(),
),
},
)
}
#[derive(Deserialize)]
@@ -282,7 +301,7 @@ async fn import_project(
} else {
parse_source(&body)?
};
created(store.create(model)?)
created(store.create_decoded(model)?)
})
.await
}
@@ -477,15 +496,15 @@ async fn replace_model(
blocking(state, move |store| {
store.checked(id, &expected)?;
let model = if is_json {
json_body(&body)?
import_json(&body)?
} else {
parse_source(&body)?
};
let (stored, changed) = store.replace(id, &expected, model)?;
let (stored, changed) = store.replace_decoded(id, &expected, model)?;
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}),
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration,"migration":stored.migration}),
))
})
.await
@@ -822,10 +841,11 @@ async fn get_validation(State(state): State<AppState>, id: ProjectPath) -> ApiRe
}
#[derive(Deserialize)]
#[serde(tag = "format", rename_all = "snake_case", deny_unknown_fields)]
enum ValidationRequest {
Json { model: Project },
Arcadia { source: String },
#[serde(deny_unknown_fields)]
struct ValidationRequest {
format: String,
model: Option<Box<serde_json::value::RawValue>>,
source: Option<String>,
}
async fn validate_candidate(
@@ -841,15 +861,16 @@ async fn validate_candidate(
let snapshot = store.get(id)?;
let stored = &snapshot;
let request: ValidationRequest = json_body(&body)?;
let parsed = match request {
ValidationRequest::Json { model } => Ok(model),
ValidationRequest::Arcadia { source } => parse_source(source.as_bytes()),
let parsed = match (request.format.as_str(), request.model, request.source) {
("json", Some(model), None) => import_json(model.get().as_bytes()),
("arcadia", None, Some(source)) => parse_source(source.as_bytes()),
_ => return Err(ApiError::bad_request("Validation requires either format json with only model, or format arcadia with only source.")),
};
let (valid, issues) = match parsed.and_then(|model| {
validate_model(&model)?;
Ok(model)
let (valid, issues, migration) = match parsed.and_then(|decoded| {
validate_model(&decoded.project)?;
Ok(decoded)
}) {
Ok(model) => (true, issues(&model)),
Ok(decoded) => (true, issues(&decoded.project), decoded.migration),
Err(error) => {
let mut diagnostics = error.issues;
if diagnostics.is_empty() {
@@ -863,13 +884,13 @@ async fn validate_candidate(
column: None,
});
}
(false, diagnostics)
(false, diagnostics, None)
}
};
Ok(response(
stored,
StatusCode::OK,
json!({"revision":stored.revision,"valid":valid,"issues":issues}),
json!({"revision":stored.revision,"valid":valid,"issues":issues,"migration":migration}),
))
})
.await
+52 -5
View File
@@ -1,6 +1,7 @@
//! Single-owner on-disk envelopes. All methods run on a blocking worker.
use crate::error::{ApiError, ApiResult, issues};
use axum::http::StatusCode;
use oa_core::persistence::{DecodeResult, MigrationReport};
use oa_core::{Id, Project};
use serde::{Deserialize, Serialize};
use std::{
@@ -29,6 +30,8 @@ pub struct StoredProject {
pub resource_id: Id,
pub revision: u64,
pub model: Project,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub migration: Option<MigrationReport>,
}
impl StoredProject {
@@ -46,6 +49,7 @@ pub(crate) struct ProjectSummary {
pub name: String,
pub elements: usize,
pub relations: usize,
pub migration: Option<MigrationReport>,
}
impl From<&StoredProject> for ProjectSummary {
fn from(stored: &StoredProject) -> Self {
@@ -57,6 +61,7 @@ impl From<&StoredProject> for ProjectSummary {
name: stored.model.name.clone(),
elements: stored.model.elements.len(),
relations: stored.model.relations.len(),
migration: stored.migration.clone(),
}
}
}
@@ -88,11 +93,11 @@ struct StoreInner {
/// Constructed only after complete candidate validation, outside the catalog
/// mutex. Commit methods cannot accidentally accept an unchecked candidate.
struct PreparedModel(Project);
struct PreparedModel(Project, Option<MigrationReport>);
impl PreparedModel {
fn new(model: Project) -> ApiResult<Self> {
validate_model(&model)?;
Ok(Self(model))
Ok(Self(model, None))
}
}
@@ -129,6 +134,22 @@ impl Store {
self.lock()?.create(prepared)
}
pub(crate) fn create_decoded(&self, decoded: DecodeResult) -> ApiResult<StoredProject> {
let mut prepared = PreparedModel::new(decoded.project)?;
prepared.1 = decoded.migration;
self.lock()?.create(prepared)
}
pub(crate) fn replace_decoded(
&self,
id: Id,
expected: &str,
decoded: DecodeResult,
) -> ApiResult<(StoredProject, bool)> {
let mut prepared = PreparedModel::new(decoded.project)?;
prepared.1 = decoded.migration;
self.lock()?.replace(id, expected, prepared)
}
pub(crate) fn replace(
&self,
id: Id,
@@ -201,8 +222,26 @@ impl Store {
"At most 128 models and 256 MiB of serialized envelopes are supported.".into(),
));
}
let stored: StoredProject =
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Envelope {
resource_id: Id,
revision: u64,
model: Box<serde_json::value::RawValue>,
}
let envelope: Envelope =
serde_json::from_slice(&bytes).map_err(|error| corrupt(error.to_string()))?;
let decoded = oa_core::persistence::decode_with_report_limited(
envelope.model.get().as_bytes(),
MODEL_LIMITS,
)
.map_err(|error| corrupt(error.to_string()))?;
let stored = StoredProject {
resource_id: envelope.resource_id,
revision: envelope.revision,
model: decoded.project,
migration: decoded.migration,
};
if stored.resource_id != filename_id || stored.revision == 0 {
return Err(corrupt(
"Resource ID/filename mismatch or zero revision.".into(),
@@ -254,6 +293,7 @@ impl StoreInner {
resource_id: Id::new_v4(),
revision: 1,
model,
migration: prepared.1,
};
let size = self.write(&stored, true)?;
self.adopt(stored.clone(), size);
@@ -270,7 +310,11 @@ impl StoreInner {
let current = self.checked(id, expected)?;
let model = prepared.0;
if current.model == model {
return Ok((current.clone(), false));
let mut unchanged = current.clone();
if prepared.1.is_some() {
unchanged.migration = prepared.1;
}
return Ok((unchanged, false));
}
let revision = current.revision.checked_add(1).ok_or_else(|| {
ApiError::new(
@@ -283,6 +327,7 @@ impl StoreInner {
resource_id: id,
revision,
model,
migration: prepared.1,
};
let size = self.write(&stored, false)?;
self.adopt(stored.clone(), size);
@@ -312,7 +357,9 @@ impl StoreInner {
}
fn write(&self, stored: &StoredProject, create: bool) -> ApiResult<usize> {
let bytes = serde_json::to_vec(stored)
// Migration observations are response metadata, never persisted facts.
let envelope = serde_json::json!({"resource_id": stored.resource_id, "revision": stored.revision, "model": stored.model});
let bytes = serde_json::to_vec(&envelope)
.map_err(|_| ApiError::internal("Could not serialize model."))?;
if bytes.len() > MAX_ENVELOPE_BYTES {
return Err(ApiError::new(
+168
View File
@@ -8,6 +8,20 @@ use serde_json::{Value, json};
use std::{path::Path, sync::Arc};
use tokio::{sync::oneshot, task::JoinHandle};
fn v2_missing_ports() -> Value {
let mut old: Value = serde_json::from_slice(include_bytes!(
"../../oa-core/tests/fixtures/v2-coastal-rescue.json"
))
.unwrap();
for relation in old["relations"].as_object_mut().unwrap().values_mut() {
if let Some(exchange) = relation["body"].get_mut("FunctionalExchange") {
exchange["source_port"] = Value::Null;
exchange["target_port"] = Value::Null;
}
}
old
}
struct TestServer {
base: String,
client: Client,
@@ -1318,6 +1332,7 @@ fn storage_rejects_second_owners_corruption_and_identity_mismatch() {
std::fs::write(&path, "{broken").unwrap();
assert!(Store::open(directory.path()).is_err());
let mismatched = StoredProject {
migration: None,
resource_id: Id::new_v4(),
revision: 1,
model: Project::empty(),
@@ -1325,6 +1340,7 @@ fn storage_rejects_second_owners_corruption_and_identity_mismatch() {
std::fs::write(&path, serde_json::to_vec(&mismatched).unwrap()).unwrap();
assert!(Store::open(directory.path()).is_err());
let invalid = StoredProject {
migration: None,
resource_id: filename,
revision: 0,
model: Project::empty(),
@@ -1484,6 +1500,7 @@ async fn fixed_store_capacity_is_enforced_on_http_and_restart() {
let mut last = None;
for _ in 0..oa_server::store::MAX_PROJECTS {
let envelope = StoredProject {
migration: None,
resource_id: Id::new_v4(),
revision: 1,
model: Project::empty(),
@@ -1624,3 +1641,154 @@ async fn legacy_json_import_and_model_reidentification_keep_the_service_address(
);
server.stop().await;
}
#[tokio::test]
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();
let expected =
oa_core::persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
let imported = server
.client
.post(server.url("/projects/import"))
.json(&old)
.send()
.await
.unwrap();
assert_eq!(imported.status(), StatusCode::CREATED);
let etag = imported.headers()["etag"].to_str().unwrap().to_owned();
let summary: Value = imported.json().await.unwrap();
assert_eq!(summary["schema_version"], 3);
assert_eq!(
summary["migration"],
serde_json::to_value(&expected.migration).unwrap()
);
let id: Id = summary["resource_id"].as_str().unwrap().parse().unwrap();
let stored = server.get(id).await;
assert_eq!(stored.model, expected.project);
assert_eq!(stored.migration, expected.migration);
let filename = directory.path().join(format!("{id}.json"));
let before = std::fs::read(&filename).unwrap();
let envelope: Value = serde_json::from_slice(&before).unwrap();
assert!(envelope.get("migration").is_none());
assert_eq!(envelope["model"]["schema_version"], 3);
let validation = server
.client
.post(server.url(&format!("/projects/{id}/validation")))
.json(&json!({"format":"json", "model":old}))
.send()
.await
.unwrap();
assert_eq!(validation.status(), StatusCode::OK);
let validation: Value = validation.json().await.unwrap();
assert_eq!(validation["valid"], true);
assert_eq!(validation["migration"], summary["migration"]);
let mut invalid = stored
.model
.relations
.values()
.find(|r| r.kind() == RelationKind::FunctionalExchange)
.unwrap()
.clone();
if let oa_core::RelationBody::FunctionalExchange { source_port, .. } = &mut invalid.body {
*source_port = Id::nil();
}
let rejected = server
.client
.post(server.url(&format!("/projects/{id}/transactions")))
.header("if-match", &etag)
.json(&json!({"commands":[Command::EditRelation(invalid.id, invalid)]}))
.send()
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(server.get(id).await.revision, 1);
assert_eq!(std::fs::read(&filename).unwrap(), before);
let source = server
.client
.get(server.url(&format!("/projects/{id}/source")))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 3;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
let no_op = server
.client
.put(server.url(&format!("/projects/{id}/source")))
.header("content-type", "text/plain")
.header("if-match", &etag)
.body(source)
.send()
.await
.unwrap();
assert_eq!(no_op.status(), StatusCode::OK);
assert_eq!(no_op.headers()["etag"].to_str().unwrap(), etag);
assert_eq!(std::fs::read(&filename).unwrap(), before);
server.stop().await;
let restarted = TestServer::start(directory.path(), None).await;
let reopened = restarted.get(id).await;
assert_eq!(reopened.model, stored.model);
assert_eq!(reopened.revision, 1);
assert!(reopened.migration.is_none());
let no_op_legacy = restarted
.client
.put(restarted.url(&format!("/projects/{id}/model")))
.header("if-match", &etag)
.json(&old)
.send()
.await
.unwrap();
assert_eq!(no_op_legacy.status(), StatusCode::OK);
assert_eq!(no_op_legacy.headers()["etag"].to_str().unwrap(), etag);
let no_op_legacy: Value = no_op_legacy.json().await.unwrap();
assert_eq!(no_op_legacy["changed"], false);
assert_eq!(no_op_legacy["migration"], summary["migration"]);
assert_eq!(std::fs::read(&filename).unwrap(), before);
restarted.stop().await;
}
#[tokio::test]
async fn reopening_v2_envelope_reports_migration_without_saving_or_advancing_revision() {
let directory = tempfile::tempdir().unwrap();
let id = Id::new_v4();
let filename = directory.path().join(format!("{id}.json"));
let old = v2_missing_ports();
let original = serde_json::to_vec(&json!({"resource_id":id,"revision":7,"model":old})).unwrap();
std::fs::write(&filename, &original).unwrap();
let server = TestServer::start(directory.path(), None).await;
let migrated = server.get(id).await;
assert_eq!(migrated.resource_id, id);
assert_eq!(migrated.revision, 7);
assert_eq!(migrated.model.schema_version, 3);
assert_eq!(
migrated
.migration
.as_ref()
.unwrap()
.generated_endpoints
.len(),
2
);
assert_eq!(std::fs::read(&filename).unwrap(), original);
let rename = server
.client
.patch(server.url(&format!("/projects/{id}")))
.header("if-match", migrated.etag())
.json(&json!({"name":"Reviewed migrated model"}))
.send()
.await
.unwrap();
assert_eq!(rename.status(), StatusCode::OK);
let current = server.get(id).await;
assert_eq!(current.revision, 8);
assert_eq!(current.model.name, "Reviewed migrated model");
let saved: Value = serde_json::from_slice(&std::fs::read(&filename).unwrap()).unwrap();
assert_eq!(saved["revision"], 8);
assert_eq!(saved["model"]["schema_version"], 3);
assert!(saved.get("migration").is_none());
server.stop().await;
}
+121 -32
View File
@@ -1,7 +1,7 @@
Arcadia Studio OA / SA Language and Editing Contract
====================================================
:Version: 2
:Version: 3
:Status: Normative current implementation contract; first-release integration
:Core implementation: ``crates/oa-core``
:Text suffix: ``.arcadia``
@@ -11,17 +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 version 1. MUST, MUST NOT,
validation, persistence, and explicit migration from versions 1 and 2. MUST, MUST NOT,
and MAY indicate requirements, prohibitions, and permitted choices.
The first-release capability/mission, contextual process/chain, typed scenario
and state/event additions
are temporarily integrated under schema 2. This does not declare the first
release complete or waive its remaining scenario, state, data, transition and
usability requirements. A distinct release schema and explicit V2 migration
will be specified after those interfaces are frozen. This document describes
the current implemented records, rather than inventing compatibility rules for
that future schema.
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
@@ -42,7 +40,7 @@ Actual verification evidence is separate from this contract.
2. One authoritative model
--------------------------
A Project stores ``schema_version`` (2), ``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.
@@ -108,8 +106,8 @@ lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts
- ``items``: exchange-item refs
- ``[]``
* - ``oa_port``, ``sa_port``
- ``direction``: In, Out, InOut; ``interface``: ref?
- InOut; none
- ``contract``: required PortContract; ``legacy``: optional LegacyPortContract
- No implicit contract; ``legacy`` defaults none. OA admits Legacy only.
* - ``oa_scenario``, ``sa_scenario``
- ``viewpoint``: ScenarioViewpoint; ``context``: ref?; ``precondition``, ``postcondition``: strings; ``steps``: occurrence refs
- MixedLegacy for omitted schema-2 source; none; ``""``; ``""``; ``[]``
@@ -205,7 +203,7 @@ contain activities; functions contain functions. Capability grouping uses
same-layer capability parents; it does not mean inheritance or inclusion.
Operational and system missions are root semantic records; they do not own
capabilities by containment. MissionCapability expresses that association.
DataFields belong to ExchangeItems or Record DataTypes. Ports belong to
DataFields belong to ExchangeItems or Record DataTypes. Preserved generic ports belong to
participants or work. Lifelines belong to scenarios. State-machine ownership
uses participant → machine → region → state/mode; a state or mode can contain
a nested region. All other state vertices belong to Regions as well. Events
@@ -235,10 +233,13 @@ references. Their kind is the declaration keyword:
- ``items`` refs / []
* - ``functional_exchange``
- SA function → SA function
- ``source_port``, ``target_port`` / none; ``items`` / []
- Required ``source_port``, ``target_port`` refs; ``items`` / []
* - ``boundary_exchange``
- Participant-owned output-compatible port → input-compatible port
- ``items`` refs / []
- Different participant owners; typed component ports have matching kinds
- ``oriented`` bool / true; ``items`` refs / []
* - ``port_allocation``
- SA Component port → SA FunctionInput/FunctionOutput port
- Requires the function's actual work Allocation to that component
* - ``capability_involvement``
- Participant, activity/function, or OA role → capability
- None
@@ -297,12 +298,75 @@ imply a unique performer.
Allocation of a non-leaf work element is invalid. A computed graphical summary
of its allocated leaves MUST NOT create an extra semantic allocation.
Functional ports, when selected, belong to the corresponding endpoint function
and have compatible direction. Boundary ports belong to participants and have
compatible direction. An interface groups ExchangeItems; an exchange using
that port interface must carry items supported by it. Studio's OA boundary
exchange is a simplified representation of operational communication; it is
not a claim of complete Capella CommunicationMean equivalence.
Typed function ports belong directly to SA Functions. ``FunctionOutput``
declares ``outgoing_items``; ``FunctionInput`` declares ``incoming_items``.
The lists contain distinct same-layer ExchangeItem references. A functional
exchange MUST reference output/input ports owned by its respective functions,
and each carried item MUST fit both declarations. Empty declarations accept
no items. Item compatibility is identity equality; future specialization is
not inferred from names. Port declarations remain independent of incident
exchanges after creation.
A ``Component`` contract belongs to an SA System or SystemActor and stores
``kind`` (Standard or Flow) and ``orientation`` (Unset, In, Out, InOut).
Standard requires Unset. Flow Unset is an incomplete draft, not InOut. Typed
component connections require matching kinds: Standard/Flow mismatch is an
error. Standard connections normally use ``oriented false``; directional Flow
connections use true. Boundary orientation is separate from endpoint kind and
orientation. Oriented Flow endpoints permit Out at source and In at target;
unoriented Flow endpoints must permit at least one communication direction.
Provided/required interfaces are separate identified relations. A Standard
partner satisfies a requirement by providing the identical interface; stored
source/target order does not infer service roles. Component carried items must
fit the declared interface-item union when explicit declarations exist. An
explicitly empty interface is not a wildcard. Missing declarations and
unsatisfied required interfaces produce located completeness warnings.
``PortAllocation`` relates a Component port to a typed function port only when
that function has an actual Allocation to the component owner. Standard may
allocate either function direction; Flow directions must agree. A typed
ExchangeAllocation requires both corresponding PortAllocations and compatible
carried items. Unoriented boundary exchanges allow reversed correspondence.
Visual allocation nesting is never evidence of a stored allocation relation.
``Legacy`` requires ``legacy { direction ...; interface ...; }``. It preserves
generic direction/interface data and emits a located compatibility warning.
It is an explicit compatibility constructor in JSON/source; ordinary creation
pickers do not offer it. Resolution to a typed SA contract retains this record
without treating it as another active constraint. OA generic ports remain
Legacy; OA Interactions stay activity-to-activity without function ports.
Deleting a referenced legacy interface clears that optional live reference.
A source-3 functional exchange MUST specify both port aliases; the parser does
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.
For example::
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" { items [@payload]; }
sa_port output "Output" {
parent @send;
contract { FunctionOutput { outgoing_items [@payload]; }; };
}
sa_port input "Input" {
parent @receive;
contract { FunctionInput { incoming_items [@payload]; }; };
}
functional_exchange message "Message" {
source @send; target @receive;
source_port @output; target_port @input; items [@payload];
}
sa_port preserved "Unresolved original" {
parent @send; contract Legacy;
legacy { direction InOut; interface @old_service; };
}
An exclusive region has at most one initial state. Missing initial state is
a completeness warning. Initial states have no incoming transitions and at
@@ -814,16 +878,16 @@ exchanges it references. Cross-layer counterparts are not owned descendants.
Text edits describe a complete candidate and must remove or repair dependencies
explicitly before Apply succeeds.
9. Persistence and V1 migration
-------------------------------
9. Persistence and explicit migration
-------------------------------------
Loading detects JSON from its first non-whitespace ``{``; other input is parsed
as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses
canonical V2 text; other suffixes use V2 JSON. Both forms preserve the same
canonical V3 text (``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.
During first-release schema-2 integration, old Scenario bodies without a
The frozen schema-2 reader retains staged compatibility defaults. Old Scenario bodies without a
``viewpoint`` retain MixedLegacy, and old Exchange occurrence bodies without
``message_kind``, ``reply_to`` or ``values`` retain Asynchronous, none and [].
Old StateTransition bodies without ``transition_kind``, ``triggers`` or
@@ -836,8 +900,33 @@ 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. A future schema-3 adapter remains a separate required
migration and is not implied by this temporary compatibility behavior.
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
direction/interface record. Existing FE endpoint IDs remain unchanged. For
only a missing endpoint it creates an item-bearing FunctionOutput/Input owned
by the already specified function, using UUID v5 in the project namespace
with ``v2-fe-port:<fe-id>:source`` or ``v2-fe-port:<fe-id>:target``. Every
identity category is checked for collision; collisions are located errors,
never overwrites. New aliases follow the deterministic collision-free alias
convention. Every old boundary exchange receives ``oriented true``; no
PortAllocation is inferred.
``decode_with_report``, ``load_with_report`` and their limited/source variants
return the model plus a migration report containing original unresolved port
IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
orientation defaults. REST import, replacement, candidate validation and
project snapshots surface the same report. Opening an old server envelope
preserves its resource ID/revision and bytes; the next explicit edit writes
schema 3. Model/source exports contain only the canonical model. Migration
reports are transient response metadata, not persisted model facts.
Admission checks account for generated objects and their references before
construction. Both the old V2 contract and the resulting V3 graph are
validated. Migration itself does not save or increase a stored revision.
Canonical second saves are stable. V1 normalization produces the frozen V2
model and then uses this same V2-to-3 stage.
Save validates a snapshot, writes a sibling temporary file, flushes it, and
atomically replaces the destination. Failed serialization or validation must
@@ -856,7 +945,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 V2 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
@@ -864,7 +953,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
V2 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
------------------------------
@@ -874,7 +963,7 @@ the assigned UUIDs explicitly.
.. code-block:: text
arcadia 2;
arcadia 3;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity assess "Assess assistance" {}
+330 -35
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Arcadia Studio model API",
"version": "1.0.0",
"description": "Current schema-2 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."
"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."
},
"servers": [
{
@@ -389,7 +389,7 @@
}
}
},
"description": "Schema-2 native JSON is described below. Schema-1 JSON is also accepted via the core's explicit migration adapter; see docs/language.rst for the legacy contract. Export emits schema 2."
"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}": {
@@ -1711,7 +1711,8 @@
"CapabilityExtend",
"CapabilityGeneralization",
"MissionInvolvement",
"SequenceLink"
"SequenceLink",
"PortAllocation"
]
},
"Position": {
@@ -2269,19 +2270,13 @@
"Port": {
"type": "object",
"properties": {
"direction": {
"type": "string",
"enum": [
"In",
"Out",
"InOut"
]
"contract": {
"$ref": "#/components/schemas/PortContract"
},
"interface": {
"legacy": {
"anyOf": [
{
"type": "string",
"format": "uuid"
"$ref": "#/components/schemas/LegacyPortContract"
},
{
"type": "null"
@@ -2290,8 +2285,8 @@
}
},
"required": [
"direction",
"interface"
"contract",
"legacy"
],
"additionalProperties": false
}
@@ -3005,12 +3000,17 @@
"type": "string",
"format": "uuid"
}
},
"oriented": {
"type": "boolean",
"description": "Independent of component port kind/orientation. Old V2 exchanges migrate to true."
}
},
"required": [
"source",
"target",
"items"
"items",
"oriented"
],
"additionalProperties": false
}
@@ -3035,26 +3035,12 @@
"format": "uuid"
},
"source_port": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
]
"type": "string",
"format": "uuid"
},
"target_port": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
]
"type": "string",
"format": "uuid"
},
"items": {
"type": "array",
@@ -3322,6 +3308,33 @@
"SequenceLink"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"PortAllocation": {
"type": "object",
"properties": {
"source": {
"type": "string",
"format": "uuid"
},
"target": {
"type": "string",
"format": "uuid"
}
},
"required": [
"source",
"target"
],
"additionalProperties": false
}
},
"required": [
"PortAllocation"
],
"additionalProperties": false
}
]
},
@@ -3616,7 +3629,7 @@
"properties": {
"schema_version": {
"type": "integer",
"const": 2
"const": 3
},
"id": {
"type": "string",
@@ -3707,6 +3720,17 @@
},
"model": {
"$ref": "#/components/schemas/Project"
},
"migration": {
"anyOf": [
{
"$ref": "#/components/schemas/MigrationReport"
},
{
"type": "null"
}
],
"description": "Present after legacy import/reopen; excluded from persisted envelopes. Resource identity and revision are unchanged by reopening."
}
},
"required": [
@@ -3745,6 +3769,16 @@
"relations": {
"type": "integer",
"minimum": 0
},
"migration": {
"anyOf": [
{
"$ref": "#/components/schemas/MigrationReport"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -4618,6 +4652,16 @@
"items": {
"$ref": "#/components/schemas/Issue"
}
},
"migration": {
"anyOf": [
{
"$ref": "#/components/schemas/MigrationReport"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -4678,6 +4722,16 @@
"items": {
"$ref": "#/components/schemas/Issue"
}
},
"migration": {
"anyOf": [
{
"$ref": "#/components/schemas/MigrationReport"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -5438,6 +5492,247 @@
"additionalProperties": false
}
]
},
"PortContract": {
"oneOf": [
{
"type": "object",
"properties": {
"FunctionInput": {
"type": "object",
"properties": {
"incoming_items": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
},
"required": [
"incoming_items"
],
"additionalProperties": false
}
},
"required": [
"FunctionInput"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"FunctionOutput": {
"type": "object",
"properties": {
"outgoing_items": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
},
"required": [
"outgoing_items"
],
"additionalProperties": false
}
},
"required": [
"FunctionOutput"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"Component": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"Standard",
"Flow"
]
},
"orientation": {
"type": "string",
"enum": [
"Unset",
"In",
"Out",
"InOut"
]
}
},
"required": [
"kind",
"orientation"
],
"additionalProperties": false
}
},
"required": [
"Component"
],
"additionalProperties": false
},
{
"type": "string",
"enum": [
"Legacy"
]
}
],
"description": "FunctionInput/Output are SA Function-owned item contracts. Component is SA System/SystemActor-owned; Standard requires Unset. Legacy requires a preserved legacy record and emits compatibility warnings. Mixed Standard/Flow component connections are invalid."
},
"LegacyPortContract": {
"type": "object",
"properties": {
"direction": {
"type": "string",
"enum": [
"In",
"Out",
"InOut"
]
},
"interface": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
]
}
},
"required": [
"direction",
"interface"
],
"additionalProperties": false
},
"MigrationReport": {
"type": "object",
"properties": {
"from_schema": {
"type": "integer",
"enum": [
1,
2
]
},
"to_schema": {
"type": "integer",
"const": 3
},
"preserved_ports": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"alias": {
"type": "string"
},
"required_resolution": {
"type": "string"
}
},
"required": [
"id",
"alias",
"required_resolution"
],
"additionalProperties": false
}
},
"generated_endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"exchange": {
"type": "string",
"format": "uuid"
},
"side": {
"type": "string",
"enum": [
"source",
"target"
]
},
"port": {
"type": "string",
"format": "uuid"
},
"owner": {
"type": "string",
"format": "uuid"
},
"alias": {
"type": "string"
}
},
"required": [
"exchange",
"side",
"port",
"owner",
"alias"
],
"additionalProperties": false
}
},
"orientation_defaults": {
"type": "array",
"items": {
"type": "object",
"properties": {
"exchange": {
"type": "string",
"format": "uuid"
},
"oriented": {
"type": "boolean",
"const": true
}
},
"required": [
"exchange",
"oriented"
],
"additionalProperties": false
}
},
"required_resolutions": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
},
"required": [
"from_schema",
"to_schema",
"preserved_ports",
"generated_endpoints",
"orientation_defaults",
"required_resolutions"
],
"additionalProperties": false,
"description": "Transient observations from explicit V1→V2→V3 or V2→V3 migration. No save or revision increase occurs merely by reading an old envelope."
}
}
}
+46
View File
@@ -0,0 +1,46 @@
# Typed port core milestone handoff
Implemented in `D:/CodexWorktrees/Capella2-typed-ports`, branch `codex/typed-port-contracts`, based on `c20a207`. This is the core portion of [the adopted port contract](release-port-contract.md). It must be integrated with the migration/source worker's companion changes; desktop/native acceptance remains the root task's work.
## Delivered behavior
- Schema 3 persists `PortContract::{FunctionInput, FunctionOutput, Component, Legacy}`, preserved `LegacyPortContract`, Standard/Flow component kind and separate orientation, mandatory FE port UUIDs, `BoundaryExchange.oriented`, and identified `PortAllocation` relations. Legacy association data is preserved through typed resolution and remains reference-aware without becoming an invented provided/required declaration.
- Typed function/component ownership, direct item declarations, empty-contract handling, actual work allocation, endpoint direction, same-layer references, unique PortAllocation pairs, explicit interface roles, and exchange/port allocation correspondence are validated in `ports.rs` through the normal candidate validator. Standard-to-Flow component exchanges are structural errors, reflecting the root's later native observation. Legacy compatibility remains explicitly warned.
- Public `plan_functional_exchange`, `plan_component_exchange`, `plan_legacy_port_resolution`, and `plan_legacy_work_port_split` implement the contract request types and return concrete existing commands. Public `plan_function_port_items` supplies explicit item synchronization. Planners validate complete candidate batches; intermediate generation does not modify the original project.
- FE creation reuses exactly one compatible typed endpoint or creates deterministic new endpoints. Explicit incompatible reuse fails; ambiguous Auto creates a fresh port. Component generation creates/reuses both component ports, two PortAllocations, the boundary exchange, and ExchangeAllocation together. It requires actual work allocations and never invents interfaces or provider/requirement roles. Repeated generation with an existing boundary reuses facts and identities.
- Split preserves the original legacy record, primary UUID/alias/prose, artifacts, constraints, and traces; it creates one opposite typed port and rewires source/target FE references. Resolution retains original interface metadata and legacy role identities. API/source can represent preserved legacy roles on resolved ports, with clear warnings; normal typed function ports use item contracts.
- Reidentify visits typed item declarations, required FE endpoints, legacy interface associations, and allocation relations. Deleting a function port cascades incident FEs; deleting an FE retains independent ports. Deleting a work Allocation cascades dependent PortAllocations, and deleting a required PortAllocation cascades its dependent ExchangeAllocation. Interface deletion clears the preserved optional association and removes role relations while retaining items/ports.
- OA-to-SA Interaction creation creates typed SA endpoints through the FE builder. SA-to-OA FE mapping remains portless, and Required scope does not copy even Legacy SA endpoint ports solely because an FE used them. Typed SA ports, boundary implementation, and dependent role/allocation records have explicit omission warnings. Original SA objects remain intact. Existing counterpart updates retain their endpoint identities.
- The demo now uses typed ports, explicit function/component port allocations, and port-level interface roles. Projection anchors derive from visible owners without requiring port placements; orientation is independent of anchor side and actual exchange endpoint IDs are retained.
## Integration APIs and changed rendering fields
All port helpers and request types are re-exported through `oa_core::*`; the bounded implementation module is `oa_core::ports`.
- `make_port(project, id, owner, name, contract) -> Result<Element, ModelError>` builds an explicit port element for a caller's transaction. It does not adopt a model or independently validate unrelated candidate state.
- `ElementBody::new(Kind::Port, ..)` now returns None: contextual authoring must choose a typed contract and owner. Do not restore a generic InOut default to make old forms compile.
- `RelationBody::new(FunctionalExchange, ..)` is an explicitly unbound draft with nil endpoint IDs; it cannot pass candidate validation. Normal GUI/API authoring should use the public planner or a concrete complete Batch. `Command::Connect(FunctionalExchange, ..)` and `Project::relate(FunctionalExchange, ..)` now create/reuse real endpoint ports. The latter remains the existing unchecked Project builder; Session validates its resulting transaction.
- `ProjectedPort.direction` is replaced by `side: PortAnchorSide::{Left, Right, Bottom}`. `projection::port_anchor_side(&Element)` is public. Glyph semantics must inspect the element's actual PortContract; Standard/Unset and InOut Flow are not interchangeable.
- `ProjectedEdge` adds `oriented: bool`; BoundaryExchange uses its persisted value and other edges use true. The root's newer main-only `summary: bool`, routing, and layout changes must be merged alongside this field, not overwritten by this older checkpoint's projection body.
- `item_satisfies` is identity equality until R6 specialization lands. `items_satisfy`, `actual_performer`, `component_allows`, `has_port_allocation`, and `port_contract` provide the small shared validation/authoring seams.
Preview binding to the current revision/fingerprint and UI display of non-FE references during split belong to the existing desktop preview/usage surface. Port planners return concrete commands; they do not introduce a second preview transaction engine. The UI must present preserved legacy associations distinctly and expose source/form/connection paths using these helpers.
## Verification
Build output is isolated at `D:/CodexBuilds/Capella2-typed-ports-core`.
- `cargo test -p oa-core`: all 124 tests passed after core/source/migration integration, including unchanged legacy admission/location tests. One additional directed-legacy-resolution regression was then added to the focused port suite.
- `cargo test -p oa-core --test release_ports`: all 9 focused tests passed, covering atomic creation/reuse/collisions, actual allocation/generation, provider/requirement independence, Standard/Flow rejection, Legacy split and resolution, preserved attachments and references, deletion/remap/undo, portless transitions, and contract-aware geometry.
- `cargo clippy -p oa-core --all-targets -- -D warnings`: passed after the final core/test changes.
- Existing scenario14, diagram3, and workflow28 suites pass, including remapping/deleting every demo identity and checking the remaining candidate. Source/migration tests and server checks are owned/reported by the companion worker.
No CUA, desktop/native acceptance, deployment, future R6 item specialization, or R3 semantic baseline engine is claimed. Current R3 name/description-only drift remains the separately documented release seam; this milestone does not disguise it as completed semantic review.
## Owned commit scope
Core: `model.rs`, `ports.rs`, `lib.rs`, `validation.rs`, `references.rs`, `session.rs`, `transitions.rs`, `demo.rs`, `projection.rs`.
Tests: new `release_ports.rs`; typed fixture adaptations in `workflows.rs` and `release_scenarios.rs`.
Handoff: this file only. Migration/source/server/documentation files and their tests belong to the companion worker and are intentionally excluded from the core commit.
+37
View File
@@ -0,0 +1,37 @@
# Typed port migration, source and API handoff
Implemented in `codex/typed-port-contracts`, isolated worktree
`D:/CodexWorktrees/Capella2-typed-ports`, against the frozen port contract.
- Private V2 DTO, source reader and validation snapshot captured from `c20a207`.
V1 normalizes to that DTO, then follows the same V2-to-3 adapter.
- Existing port identity/prose/ownership/placement and interface associations
survive as Legacy. Missing FE endpoints receive deterministic item-bearing
typed ports; old boundary exchanges explicitly retain `oriented = true`.
- Generated identities are collision checked across all object categories;
aliases use the existing deterministic convention. Original admission limits
remain intact, with generated records and references charged before creation.
- `persistence::{decode_with_report, decode_with_report_limited, load_with_report}`
and `language::{parse_with_report, parse_with_report_limited}` return
`DecodeResult { project, migration }`. Existing Project-only wrappers remain.
Desktop integration should use the report-returning calls to display the
migration findings. Merely loading never writes or advances a revision.
- Source 3 requires a port contract and both explicit FE port aliases. Item
declarations and nested `legacy.interface` use normal source references.
Canonical output is schema 3 / `arcadia 3`; source 1/2 remain importable.
- REST import, replacement, candidate validation and project snapshots surface
reports. Reopened V2 envelopes retain resource/revision/bytes until a real edit.
Reports are transient response metadata, excluded from persisted envelopes.
Raw model JSON is preserved through request/envelope parsing so duplicate-map
detection cannot be bypassed. Invalid typed transactions retain disk and ETag.
- Language/OpenAPI documents include typed contracts, PortAllocation, explicit
endpoint IDs, independent orientation, Legacy and strict Standard/Flow kind
mismatch rejection. Original V2 goldens and worked-example files are untouched.
Validation: 16 language, 8 migration, 3 typed source and 4 existing admission
tests pass. The HTTP suite has 19 passing tests, including migration/restart and
rejected typed transaction coverage. Core+server Clippy with all targets and
`-D warnings` passes. Server unit/doc checks are included in final verification.
Native GUI acceptance and report presentation remain root integration work;
this handoff makes no native-UI or broad R3 completion claim.