Migrate schema 2 ports through frozen readers and expose schema 3 source API
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-09-05 18:23:33 +03:00
parent 0d14438b7b
commit b9f88ce1f3
21 changed files with 7513 additions and 152 deletions
+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,
})
+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
+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> {
+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"))
);
}
+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."
}
}
}
+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.