Compare commits

..
90 changed files with 8097 additions and 4099 deletions
@@ -1,27 +0,0 @@
//! Read and validate a saved acceptance fixture without rewriting its source.
//! cargo run --locked -p oa-core --example attachment_snapshot -- input.arcadia output.json
use oa_core::{persistence, validate_candidate};
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let bytes = serde_json::to_vec_pretty(&project)?;
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.write_all(b"\n")?;
output.sync_all()?;
println!(
"Validated {} objects and {} views; saved a complete Project snapshot",
project.identities().len(),
project.diagrams.len()
);
Ok(())
}
-34
View File
@@ -1502,45 +1502,11 @@ pub struct Diagram {
#[serde(deny_unknown_fields)]
pub struct EdgeRoute {
pub edge: Id,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_attachment: Option<EdgeAttachment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_attachment: Option<EdgeAttachment>,
#[serde(default)]
pub waypoints: Vec<Position>,
#[serde(default)]
pub label: Option<EdgeLabel>,
}
/// Presentation preference on one end of a connection. Real ports take precedence.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeAttachment {
pub side: PortAnchorSide,
pub offset: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeEnd {
Source,
Target,
}
impl EdgeRoute {
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
match end {
EdgeEnd::Source => self.source_attachment,
EdgeEnd::Target => self.target_attachment,
}
}
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
match end {
EdgeEnd::Source => self.source_attachment = attachment,
EdgeEnd::Target => self.target_attachment = attachment,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeLabel {
+38 -132
View File
@@ -1,12 +1,7 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{
Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation,
RelationBody, RelationKind, Size,
};
mod attachments;
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -46,7 +41,6 @@ pub struct RoutedEdge {
// Derived solver state, never persisted. A preview must inherit the same
// remaining work budget as this edge had in stable-ID settled traversal.
search_budget_before: usize,
attachments: [Option<EdgeAttachment>; 2],
}
#[derive(Debug, Clone, PartialEq)]
@@ -179,7 +173,6 @@ pub fn association_end_geometry(
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
CoincidentAttachments,
PinInsideObstacle { pin: usize, obstacle: Id },
NoClearPath,
SearchBudgetExhausted,
@@ -188,9 +181,6 @@ pub enum RouteWarning {
impl fmt::Display for RouteWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CoincidentAttachments => f.write_str(
"The two attachments coincide; move either end to separate the loop tips.",
),
Self::PinInsideObstacle { pin, .. } => {
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
}
@@ -384,14 +374,7 @@ struct Endpoint {
point: Position,
stub: Position,
}
fn endpoints(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
port: Option<Id>,
view: &ProjectedView,
attachment: Option<EdgeAttachment>,
) -> Vec<Endpoint> {
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
if let Some(port) = port.and_then(|id| {
view.ports
.iter()
@@ -406,18 +389,26 @@ fn endpoints(
),
}];
}
if let Some(attachment) = attachment {
return vec![attachments::endpoint(project, diagram, node, attachment)];
}
[
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
let r = Rect::node(node);
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
vec![
Endpoint {
point: pos(r.left, mid.y),
stub: pos(r.left - PORT_STUB, mid.y),
},
Endpoint {
point: pos(r.right, mid.y),
stub: pos(r.right + PORT_STUB, mid.y),
},
Endpoint {
point: pos(mid.x, r.top),
stub: pos(mid.x, r.top - PORT_STUB),
},
Endpoint {
point: pos(mid.x, r.bottom),
stub: pos(mid.x, r.bottom + PORT_STUB),
},
]
.into_iter()
.map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 }))
.collect()
}
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
@@ -516,12 +507,6 @@ fn route_diagram_impl(
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
let custom_suspended = promoted;
let route = if promoted { None } else { stored };
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
semantic_port_endpoint(project, edge.id, end)
.is_none()
.then(|| route.and_then(|r| r.attachment(end)))
.flatten()
});
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
@@ -532,10 +517,7 @@ fn route_diagram_impl(
});
if let Some(cached) = cached {
budget = cached.search_budget_before;
if cached.pins == pins
&& cached.attachments == attachments
&& cached.custom_suspended == custom_suspended
{
if cached.pins == pins && cached.custom_suspended == custom_suspended {
// Label-only editing cannot change the connection beneath the
// pointer, including a settled fallback with routing warnings.
for &point in &cached.points {
@@ -561,22 +543,9 @@ fn route_diagram_impl(
}
let (points, pin_lengths, failure) = route_edge(
edge,
&endpoints(
project,
diagram,
source,
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
view,
attachments[0],
),
&endpoints(
project,
diagram,
target,
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
view,
attachments[1],
),
source,
target,
view,
&pins,
&obstacles,
&previous,
@@ -605,7 +574,6 @@ fn route_diagram_impl(
warnings,
custom_suspended,
search_budget_before,
attachments,
},
);
}
@@ -711,46 +679,6 @@ struct RoutingSpace<'a> {
xs: Vec<f32>,
ys: Vec<f32>,
}
fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] {
let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() };
let normal = pos(
direction(a.stub.x - a.point.x),
direction(a.stub.y - a.point.y),
);
let tangent = pos(-normal.y, normal.x);
let candidates: Vec<_> = [36., 72., 144., 288.]
.into_iter()
.flat_map(|extent| {
[-1., 1.].into_iter().map(move |side| {
[
pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent),
pos(
a.stub.x + (normal.x + tangent.x * side) * extent,
a.stub.y + (normal.y + tangent.y * side) * extent,
),
pos(
a.stub.x + tangent.x * side * extent,
a.stub.y + tangent.y * side * extent,
),
]
})
})
.collect();
// These are computed loop guards, not authored hard pins. Prefer a clear
// orientation/size before asking the bounded router to connect them.
candidates
.iter()
.find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles))
.or_else(|| {
candidates.iter().find(|p| {
p.iter()
.all(|point| !obstacles.iter().any(|r| r.contains(*point)))
})
})
.copied()
.unwrap_or(candidates[0])
}
impl<'a> RoutingSpace<'a> {
fn new(obstacles: &'a [Rect]) -> Self {
Self {
@@ -764,41 +692,24 @@ impl<'a> RoutingSpace<'a> {
#[allow(clippy::too_many_arguments)]
fn route_edge(
edge: &ProjectedEdge,
source_ends: &[Endpoint],
target_ends: &[Endpoint],
source: &ProjectedNode,
target: &ProjectedNode,
view: &ProjectedView,
pins: &[Position],
obstacles: &[Rect],
previous: &[Segment],
budget: &mut usize,
) -> EdgePath {
// Coincident authored tips are valid presentation, including on self loops.
// Preserve them and add an external detour; never index an empty candidate
// list or turn the connection into an out-and-back degenerate stub.
let coincident = edge.source == edge.target
&& source_ends.len() == 1
&& target_ends.len() == 1
&& source_ends[0].point == target_ends[0].point
&& source_ends[0].stub == target_ends[0].stub;
let authored_pin_count = pins.len();
let mut loop_pins = Vec::new();
if coincident {
let a = source_ends[0];
loop_pins.extend_from_slice(pins);
loop_pins.extend(coincident_detour(a, obstacles));
}
let pins = if coincident { &loop_pins[..] } else { pins };
let space = RoutingSpace::new(obstacles);
let mut candidates = Vec::new();
for &a in source_ends {
for &b in target_ends {
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
// A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
&& edge.target_port.is_none()
&& a.point == b.point
&& a.stub == b.stub
&& !coincident
{
continue;
}
@@ -818,9 +729,10 @@ fn route_edge(
// Try all side combinations cheaply before spending the bounded grid budget.
for search in [false, true] {
for &(estimate, a, b) in &candidates {
if best.as_ref().is_some_and(|(cost, _, _)| {
estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost
}) {
if best
.as_ref()
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
{
continue;
}
if obstacles
@@ -836,7 +748,7 @@ fn route_edge(
let mut occupied = previous.to_vec();
record_segments(&mut occupied, &points);
let mut pin_lengths = Vec::new();
let mut distance = manhattan(a.point, a.stub);
let mut distance = PORT_STUB;
let mut start = a.stub;
let mut failed = false;
for (index, end) in pins
@@ -880,20 +792,15 @@ fn route_edge(
break;
}
}
if let Some((_, points, mut pin_lengths)) = best {
pin_lengths.truncate(authored_pin_count);
return (
points,
pin_lengths,
coincident.then_some(RouteWarning::CoincidentAttachments),
);
if let Some((_, points, pin_lengths)) = best {
return (points, pin_lengths, None);
}
// Recoverable fallback retains every hard pin, in order, and the actual port
// leads. Its warning is deliberately visible to consumers.
let (_, a, b) = candidates[0];
let mut points = vec![a.point, a.stub];
let mut pin_lengths = Vec::new();
let mut distance = manhattan(a.point, a.stub);
let mut distance = PORT_STUB;
let mut start = a.stub;
for (index, end) in pins
.iter()
@@ -926,7 +833,6 @@ fn route_edge(
start = end;
}
points.push(b.point);
pin_lengths.truncate(authored_pin_count);
(
points,
pin_lengths,
-160
View File
@@ -1,160 +0,0 @@
//! One boundary definition shared by persisted routing and attachment gestures.
use super::*;
use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide};
/// Semantic authority, independent of whether projection currently exposes a port.
pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option<Id> {
let endpoint = if let Some(relation) = project.relations.get(&edge) {
match (&relation.body, end) {
(RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port,
(RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port,
(_, EdgeEnd::Source) => relation.source(),
(_, EdgeEnd::Target) => relation.target(),
}
} else {
let trace = project.traces.get(&edge)?;
match end {
EdgeEnd::Source => trace.oa,
EdgeEnd::Target => trace.sa,
}
};
project
.elements
.get(&endpoint)
.filter(|e| e.kind() == Kind::Port)
.map(|_| endpoint)
}
/// Intersect the chosen cardinal scan line with the painted shape. Offset is
/// measured over the complete projected bounding side, including its corners.
/// Terminate's X uses its outermost stroke intersection rather than a contour.
pub fn attachment_point(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: &EdgeAttachment,
) -> Position {
let vertical = matches!(
attachment.side,
PortAnchorSide::Left | PortAnchorSide::Right
);
let (extent, depth) = if vertical {
(node.size.height, node.size.width)
} else {
(node.size.width, node.size.height)
};
let along = attachment.offset.clamp(0., 1.) * extent;
let t = 2. * attachment.offset.clamp(0., 1.) - 1.;
let kind = diagram
.projection
.state_machine()
.and_then(|_| project.elements.get(&node.element))
.map(|e| e.kind());
let inset = match kind {
Some(Kind::Choice) => depth * 0.5 * t.abs(),
Some(Kind::Terminate) => {
depth
* attachment
.offset
.clamp(0., 1.)
.min(1. - attachment.offset.clamp(0., 1.))
}
Some(
Kind::Initial
| Kind::Junction
| Kind::Final
| Kind::ShallowHistory
| Kind::DeepHistory
| Kind::EntryPoint
| Kind::ExitPoint,
) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()),
Some(Kind::Region | Kind::Fork | Kind::Join) => 0.,
_ => {
let radius = 10_f32.min(extent / 2.).min(depth / 2.);
let corner = (radius - along.min(extent - along)).max(0.);
radius - (radius * radius - corner * corner).max(0.).sqrt()
}
};
match attachment.side {
PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along),
PortAnchorSide::Right => pos(
node.position.x + node.size.width - inset,
node.position.y + along,
),
PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset),
PortAnchorSide::Bottom => pos(
node.position.x + along,
node.position.y + node.size.height - inset,
),
}
}
/// Choose a cardinal bounding side, then map its fraction onto the painted
/// contour. Contour distances cannot distinguish the sides of a circle at its
/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions.
/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping
/// rounds to hundredths: source/API values retain their complete precision.
pub fn attachment_from_point(
node: &ProjectedNode,
cursor: Position,
previous: Option<PortAnchorSide>,
hysteresis: f32,
) -> EdgeAttachment {
let candidate = |side| EdgeAttachment {
side,
offset: projection::port_anchor_offset(node, side, cursor),
};
// Measure side intent at the box, not from a distant overshoot: Euclidean
// distance differences converge outside a small symbol and would otherwise
// let hysteresis retain a perpendicular side indefinitely.
let boundary_cursor = pos(
cursor
.x
.clamp(node.position.x, node.position.x + node.size.width),
cursor
.y
.clamp(node.position.y, node.position.y + node.size.height),
);
let distance = |side| {
length(
projection::port_anchor_position(node, side, candidate(side).offset),
boundary_cursor,
)
};
let mut side = [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
]
.into_iter()
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
.unwrap();
let hysteresis = hysteresis
.max(0.)
.min(node.size.width.min(node.size.height) * 0.2);
if let Some(old) = previous
&& distance(old) <= distance(side) + hysteresis
{
side = old;
}
let mut attachment = candidate(side);
attachment.offset = (attachment.offset * 100.).round() / 100.;
attachment
}
pub(super) fn endpoint(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: EdgeAttachment,
) -> Endpoint {
let point = attachment_point(project, diagram, node, &attachment);
let stub = match attachment.side {
PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y),
PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y),
PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB),
PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB),
};
Endpoint { point, stub }
}
+1 -18
View File
@@ -93,18 +93,6 @@ fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>
.or_insert(Value::Null);
}
}
Context::Diagram => {
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
route
.entry("source_attachment".to_owned())
.or_insert(Value::Null);
route
.entry("target_attachment".to_owned())
.or_insert(Value::Null);
}
}
}
Context::Node => {
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
}
@@ -256,12 +244,7 @@ fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes {
if let Some(route) = route.as_object_mut() {
omit_defaults(
route,
&object(
json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}),
),
);
omit_defaults(route, &object(json!({"waypoints": [], "label": null})));
}
}
}
+12 -2
View File
@@ -750,7 +750,7 @@ pub fn plan_transition(
);
continue;
}
if !compatible_existing(project, *source, target, request.direction) {
if !counterpart_compatible(project, *source, target, request.direction) {
conflict(
&mut plan,
Some(*source),
@@ -1317,12 +1317,22 @@ fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection)
}
inferred_kind(source, direction) == Some(target)
}
fn compatible_existing(
/// Whether existing records have compatible layers, categories and kinds for
/// an ordinary transition. This is candidate eligibility, not approval of a
/// complete plan: properties, dependencies and structural conflicts still need
/// the normal preview and apply validation. Manual traces are not restricted
/// by this predicate.
pub fn counterpart_compatible(
p: &Project,
source: Id,
target: Id,
direction: TransitionDirection,
) -> bool {
if p.layer_of(source) != Some(direction.source_layer())
|| p.layer_of(target) != Some(direction.target_layer())
{
return false;
}
match (p.elements.get(&source), p.elements.get(&target)) {
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
_ => match (p.relations.get(&source), p.relations.get(&target)) {
-14
View File
@@ -643,20 +643,6 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
let mut routed_edges = BTreeSet::new();
for (index, route) in d.routes.iter().enumerate() {
for attachment in [route.source_attachment, route.target_attachment]
.into_iter()
.flatten()
{
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
error(
&mut out,
d.id,
"invalid_attachment_offset",
"routes",
"Connection attachment offset must be finite and between zero and one.",
);
}
}
if !routed_edges.insert(route.edge) {
error(
&mut out,
-385
View File
@@ -1,385 +0,0 @@
use oa_core::{projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment {
EdgeAttachment { side, offset }
}
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let a = p
.add_element(Kind::Activity, Layer::Oa, "Report", None)
.unwrap();
let b = p
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::Interaction, "Report status", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.place(d, a, pos(50., 50.));
p.place(d, b, pos(750., 200.));
(p, d, a, b, edge)
}
fn stored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
}
}
fn routed(p: &Project, d: Id) -> RoutedView {
let diagram = &p.diagrams[&d];
route_diagram(p, diagram, &project_diagram(p, diagram))
}
fn near(a: f32, b: f32) {
assert!((a - b).abs() < 0.002, "{a} != {b}");
}
fn orthogonal(points: &[Position]) {
assert!(points.len() >= 2);
for span in points.windows(2) {
assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}");
}
}
fn length(points: &[Position]) -> f32 {
points
.windows(2)
.map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs())
.sum()
}
#[test]
fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() {
let (mut p, d, a, _, edge) = fixture();
let initial = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75));
route.waypoints = vec![pos(460., 350.)];
p.diagrams.get_mut(&d).unwrap().routes.push(route.clone());
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge);
let full = routed(&p, d);
assert_eq!(
preview, full,
"attachment-only changes must invalidate the label-only path cache"
);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
assert_eq!(
full.edges[&edge].points[0],
pos(
n.position.x + n.size.width * 0.25,
n.position.y + n.size.height
)
);
assert_eq!(full.edges[&edge].pins, route.waypoints);
orthogonal(&full.edges[&edge].points);
let node = p
.diagrams
.get_mut(&d)
.unwrap()
.nodes
.values_mut()
.find(|n| n.element == a)
.unwrap();
node.position.x += 75.;
node.size.width += 100.;
node.size.height += 50.;
let moved = routed(&p, d);
near(
moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x,
100.,
);
near(
moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y,
50.,
);
assert_eq!(p.diagrams[&d].routes[0], route);
}
#[test]
fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() {
let (mut p, d, _, _, edge) = fixture();
let baseline = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge);
assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points);
assert_eq!(preview, routed(&p, d));
}
#[test]
fn ports_remain_authoritative_even_when_projection_filters_the_port() {
let mut p = Project::empty();
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, a, pos(0., 0.));
p.place(d, b, pos(600., 0.));
let diagram = p.diagrams[&d].clone();
let view = project_diagram(&p, &diagram);
let expected = route_diagram(&p, &diagram, &view);
assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some());
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
assert_eq!(routed(&p, d), expected);
let mut hidden = view.clone();
hidden.ports.clear();
for e in &mut hidden.edges {
e.source_port = None;
e.target_port = None;
}
let absent = route_diagram(&p, &diagram, &hidden);
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
absent,
"hidden ports must not activate a dormant node preference"
);
}
fn state(kind: Kind) -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap();
let b = p
.add_element(Kind::State, Layer::Oa, "B", Some(region))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Next", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
p.place(d, a, pos(100., 100.));
p.place(d, b, pos(600., 100.));
(p, d, a, edge)
}
#[test]
fn nonrectangular_state_attachments_intersect_the_painted_shape() {
for kind in [
Kind::Initial,
Kind::Junction,
Kind::Final,
Kind::Choice,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
Kind::Fork,
Kind::Join,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let diagram = &p.diagrams[&d];
let view = project_diagram(&p, diagram);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
] {
for offset in [0., 0.2, 0.5, 0.8, 1.] {
let at = attachment_point(&p, diagram, n, &anchor(side, offset));
let x = (at.x - n.position.x) / n.size.width;
let y = (at.y - n.position.y) / n.size.height;
match kind {
Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.),
Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.),
Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.),
Kind::State => {
assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y));
if offset == 0. || offset == 1. {
assert!(
x != 0. && x != 1. || y != 0. && y != 1.,
"rounded corner cannot use bounding-box corner"
);
}
}
_ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.),
}
}
}
}
}
#[test]
fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() {
let (mut p, d, _, edge) = state(Kind::Choice);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.));
route.waypoints = vec![pos(380., 40.)];
route.label = Some(EdgeLabel {
fraction: 0.3,
offset: pos(0., 0.),
});
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let pin = r.pins[0];
let pin_index = r.points.iter().position(|p| *p == pin).unwrap();
near(
r.pin_fractions[0],
length(&r.points[..=pin_index]) / length(&r.points),
);
assert_eq!(
r.label.anchor,
point_along_polyline(&r.points, 0.3).unwrap()
);
}
#[test]
fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() {
for kind in [
Kind::Initial,
Kind::Choice,
Kind::Terminate,
Kind::Fork,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for zoom in [0.5, 1., 2.] {
for overshoot in [0., 20.] {
let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot);
let chosen =
attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
chosen,
anchor(PortAnchorSide::Top, 0.5),
"{kind:?}, zoom {zoom}, overshoot {overshoot}"
);
let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen);
near(painted.x, n.position.x + n.size.width / 2.);
near(
painted.y,
n.position.y
+ if kind == Kind::Terminate {
n.size.height / 2.
} else {
0.
},
);
}
let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.);
let stable =
attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
stable.side,
PortAnchorSide::Right,
"corner hysteresis stays stable"
);
}
}
}
#[test]
fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
for pins in [vec![], vec![pos(400., 30.)]] {
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
route.waypoints = pins.clone();
p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()];
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert_eq!(r.points.first(), r.points.last());
assert!(length(&r.points) > 2. * PORT_STUB + 70.);
assert_eq!(r.pins, pins);
assert_eq!(r.pin_fractions.len(), pins.len());
assert!(
r.warnings.contains(&RouteWarning::CoincidentAttachments),
"{:?}",
r.warnings
);
assert_eq!(routed(&p, d), result, "deterministic fallback");
assert_eq!(p.diagrams[&d].routes[0], route);
}
}
#[test]
fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() {
for blocker_side in [-1., 1.] {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
let tip = attachment_point(
&p,
&p.diagrams[&d],
source,
&anchor(PortAnchorSide::Top, 0.5),
);
let region = p.elements[&a].parent;
let blocker = p
.add_element(Kind::State, Layer::Oa, "Detour obstruction", region)
.unwrap();
let left = if blocker_side > 0. {
tip.x + 30.
} else {
tip.x - 30. - 224.
};
p.place(d, blocker, pos(left, tip.y - 144.));
let result = routed(&p, d);
let r = &result.edges[&edge];
assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]);
orthogonal(&r.points);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == blocker).unwrap();
for segment in r.points.windows(2) {
for step in 0..=100 {
let t = step as f32 / 100.;
let q = pos(
segment[0].x + (segment[1].x - segment[0].x) * t,
segment[0].y + (segment[1].y - segment[0].y) * t,
);
assert!(
!(q.x > n.position.x - CLEARANCE
&& q.x < n.position.x + n.size.width + CLEARANCE
&& q.y > n.position.y - CLEARANCE
&& q.y < n.position.y + n.size.height + CLEARANCE),
"route crosses blocker: {q:?}"
);
}
}
assert!(
r.pins.is_empty(),
"generated detours must never become authored pins"
);
}
}
@@ -1,272 +0,0 @@
use oa_core::{language, projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn authored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![pos(520., 420.)],
label: Some(EdgeLabel {
fraction: 0.35,
offset: pos(17., -23.),
}),
}
}
fn routed(project: &Project, diagram: Id) -> RoutedView {
let d = &project.diagrams[&diagram];
route_diagram(project, d, &project_diagram(project, d))
}
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
let mut p = language::parse(
r#"arcadia 7;
project lifecycle "Attachment lifecycle" {
oa_activity group "Group" { oa_activity first "First" {} }
oa_activity second "Second" {}
oa_activity replacement "Replacement" {}
interaction signal "Signal" { source @first; target @second; }
diagram view "View" {}
diagram other "Other" {}
}"#,
)
.unwrap();
let (d, other, group, first, second, replacement, edge) = (
id(&p, "view"),
id(&p, "other"),
id(&p, "group"),
id(&p, "first"),
id(&p, "second"),
id(&p, "replacement"),
id(&p, "signal"),
);
for diagram in [d, other] {
p.place(diagram, group, pos(20., 20.));
p.place(diagram, first, pos(100., 130.));
p.place(diagram, second, pos(750., 150.));
p.place(diagram, replacement, pos(750., 570.));
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(authored(edge));
}
(p, d, other, group, first, replacement, edge)
}
#[test]
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
let (mut p, d, other, group, _, _, edge) = fixture();
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
let before = p.clone();
let expanded = routed(&p, d);
let other_geometry = routed(&p, other);
let parent = p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == group)
.unwrap()
.id;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
let folded = routed(&p, d);
assert!(folded.edges[&edge].custom_suspended);
assert_eq!(folded.edges[&edge].pins, []);
assert!(
folded.edges[&edge]
.warnings
.contains(&RouteWarning::PromotedEndpoints)
);
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
let mut automatic = p.clone();
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
assert_eq!(
folded,
routed(&automatic, d),
"attachments, pins and label must all be dormant on promotion"
);
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
assert_eq!(routed(&p, other), other_geometry);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = false;
assert_eq!(p, before);
assert_eq!(routed(&p, d), expanded);
}
#[test]
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
let (p, d, other, _, _, replacement, edge) = fixture();
let before = routed(&p, d);
let mut relation = p.relations[&edge].clone();
let RelationBody::Interaction { target, .. } = &mut relation.body else {
unreachable!()
};
*target = replacement;
let mut expected = p.clone();
expected.relations.insert(edge, relation.clone());
let mut session = Session::new(p.clone());
session
.execute(Command::EditRelation(edge, relation))
.unwrap();
assert_eq!(
session.project(),
&expected,
"reconnect changes only the semantic endpoint; all per-view records remain exact"
);
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
for diagram in [d, other] {
let current = session.project();
let view = project_diagram(current, &current.diagrams[&diagram]);
let node = view
.nodes
.iter()
.find(|n| n.element == replacement)
.unwrap();
let preference = current.diagrams[&diagram].routes[0]
.target_attachment
.unwrap();
let expected_tip =
attachment_point(current, &current.diagrams[&diagram], node, &preference);
let result = routed(current, diagram);
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
assert_eq!(
result.edges[&edge].pins,
p.diagrams[&diagram].routes[0].waypoints
);
}
assert_ne!(
routed(session.project(), d).edges[&edge].points.last(),
before.edges[&edge].points.last()
);
session.undo();
assert_eq!(session.project(), &p);
assert_eq!(routed(session.project(), d), before);
session.redo();
assert_eq!(session.project(), &expected);
}
#[test]
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let mut ports = Vec::new();
for (owner, name, contract) in [
(
system,
"Output",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
),
(
actor,
"Input",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
},
),
(
function,
"Function output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
),
] {
let port = Id::new_v4();
p.elements
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
ports.push(port);
}
p.relate(
RelationKind::Allocation,
"Function allocation",
system,
function,
);
let boundary = p.relate(
RelationKind::BoundaryExchange,
"Boundary",
ports[0],
ports[1],
);
let allocation = p.relate(
RelationKind::PortAllocation,
"Port allocation",
ports[0],
ports[2],
);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, system, pos(0., 0.));
p.place(d, actor, pos(700., 0.));
p.place(d, function, pos(0., 450.));
let original = p.clone();
let baseline = routed(&p, d);
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
assert!(baseline.edges.contains_key(&edge));
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
Some(ports[0])
);
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
Some(target)
);
let mut route = authored(edge);
route.waypoints.clear();
route.label = None;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
}
assert_eq!(routed(&p, d), baseline);
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
hidden.ports.clear();
for edge in &mut hidden.edges {
edge.source_port = None;
edge.target_port = None;
}
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
route_diagram(&original, &original.diagrams[&d], &hidden),
"filtered direct Port endpoints must not activate their stored preferences"
);
}
@@ -1,136 +0,0 @@
use oa_core::{projection::*, routing::*, *};
#[test]
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
let mut project = Project::empty();
let oa = project
.add_element(Kind::Activity, Layer::Oa, "Send", None)
.unwrap();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
let id = Id::new_v4();
project.elements.insert(
id,
Element {
id,
alias: format!("port_{}", id.simple()),
name: "Status port".into(),
description: "Existing legacy port retained by Item trace".into(),
parent: Some(owner),
body: ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
id
});
let trace = Trace {
id: Id::new_v4(),
alias: "status_correspondence".into(),
name: "Status correspondence".into(),
description: "Preserve this semantic evidence".into(),
oa: ports[0],
sa: ports[1],
kind: TraceKind::Item,
baseline: None,
};
project.traces.insert(trace.id, trace.clone());
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let owners = [
project.place(diagram, oa, Position { x: 100., y: 100. }),
project.place(diagram, sa, Position { x: 800., y: 300. }),
];
validate_candidate(&project).unwrap();
let semantic_elements = project.elements.clone();
let semantic_relations = project.relations.clone();
let semantic_traces = project.traces.clone();
let view = project_diagram(&project, &project.diagrams[&diagram]);
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
assert!(edge.trace);
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
assert_eq!((edge.source_port, edge.target_port), (None, None));
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
.into_iter()
.enumerate()
{
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owners[index])
.unwrap()
.port_anchors
.push(PortAnchor {
port: ports[index],
side,
offset,
});
}
validate_candidate(&project).unwrap();
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
for index in 0..2 {
let marker = moved_view
.ports
.iter()
.find(|port| port.element == ports[index] && port.owner == owners[index])
.unwrap();
let points = &moved.edges[&trace.id].points;
let tip = if index == 0 {
points[0]
} else {
*points.last().unwrap()
};
assert_eq!(tip, marker.position);
let old = &initial.edges[&trace.id].points;
assert_ne!(
tip,
if index == 0 {
old[0]
} else {
*old.last().unwrap()
}
);
}
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: trace.id,
waypoints: vec![],
label: None,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.9,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.1,
}),
});
validate_candidate(&project).unwrap();
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
assert_eq!(dormant_view, moved_view);
assert_eq!(
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
moved
);
assert_eq!(project.elements, semantic_elements);
assert_eq!(project.relations, semantic_relations);
assert_eq!(project.traces, semantic_traces);
assert_eq!(project.traces[&trace.id], trace);
}
-154
View File
@@ -1,154 +0,0 @@
//! Route the exact native acceptance fixture and intended saved checkpoints.
use oa_core::{projection::project_diagram, routing::route_diagram, *};
const FIXTURE: &str =
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn set(
project: &mut Project,
view: &str,
edge: &str,
end: EdgeEnd,
attachment: Option<(PortAnchorSide, f32)>,
) {
let view = id(project, view);
let edge = id(project, edge);
let route = project
.diagrams
.get_mut(&view)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == edge)
.unwrap();
route.set_attachment(
end,
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
);
}
fn clear(project: &Project, checkpoint: &str) {
validate_candidate(project).unwrap();
for (view, edges) in [
("dispatch_view", &["dispatch_request"][..]),
("readiness_view", &["activate", "initialize", "finish"][..]),
] {
let diagram = &project.diagrams[&id(project, view)];
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
for alias in edges {
let edge = &routed.edges[&id(project, alias)];
assert!(
edge.warnings.is_empty(),
"{checkpoint}/{view}/{alias}: {:?}",
edge.warnings
);
let stored = diagram
.routes
.iter()
.find(|r| r.edge == id(project, alias))
.unwrap();
assert_eq!(
edge.pins, stored.waypoints,
"{checkpoint}/{alias}: manual pins retained"
);
}
}
}
#[test]
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
use EdgeEnd::{Source, Target};
use PortAnchorSide::{Bottom, Top};
let mut project = language::parse(FIXTURE).unwrap();
let baseline = project.clone();
clear(&project, "B");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
Some((Top, 0.25)),
);
clear(&project, "O1");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "O2");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
None,
);
clear(&project, "O3");
set(
&mut project,
"readiness_view",
"activate",
Source,
Some((Top, 0.25)),
);
clear(&project, "S1");
set(
&mut project,
"readiness_view",
"activate",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "S2");
set(&mut project, "readiness_view", "activate", Target, None);
clear(&project, "S3");
set(
&mut project,
"readiness_view",
"initialize",
Source,
Some((Top, 0.5)),
);
clear(&project, "S4");
set(
&mut project,
"readiness_view",
"finish",
Source,
Some((Top, 0.5)),
);
clear(&project, "S5");
assert_eq!(project.elements, baseline.elements);
assert_eq!(project.relations, baseline.relations);
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
let view = id(&project, alias);
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
}
for alias in ["dispatch_view", "readiness_view"] {
let view = id(&project, alias);
assert_eq!(
project.diagrams[&view].nodes,
baseline.diagrams[&view].nodes
);
for route in &project.diagrams[&view].routes {
let original = baseline.diagrams[&view]
.routes
.iter()
.find(|r| r.edge == route.edge)
.unwrap();
assert_eq!(route.waypoints, original.waypoints);
assert_eq!(route.label, original.label);
}
}
}
-290
View File
@@ -1,290 +0,0 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::{Value, json};
use std::collections::BTreeMap;
const SOURCE: &str = r#"arcadia 7;
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
label { fraction 0.5; offset { x 3; y -7; }; };
source_attachment { side Left; offset 0.25; };
target_attachment { side Bottom; offset 1; };
}];
}
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
}
"#;
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn original() -> Project {
language::parse(SOURCE).unwrap()
}
fn set(diagram: Id, route: EdgeRoute) -> Command {
Command::SetEdgeRoute { diagram, route }
}
#[test]
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
let project = original();
let view = id(&project, "view");
let route = &project.diagrams[&view].routes[0];
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25
})
);
assert_eq!(
route.target_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0
})
);
for text in [SOURCE.to_owned(), language::format(&project)] {
assert_eq!(language::parse(&text).unwrap(), project);
}
assert_eq!(
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
project
);
assert_eq!(
compile_bundle(&format_bundle(&project), ProjectLimits::default())
.unwrap()
.decoded
.project,
project
);
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
assert_eq!(automatic.source_attachment, None);
assert_eq!(automatic.target_attachment, None);
let encoded = serde_json::to_value(automatic).unwrap();
assert!(encoded.get("source_attachment").is_none());
assert!(encoded.get("target_attachment").is_none());
let mut with_null = encoded.clone();
with_null["source_attachment"] = Value::Null;
with_null["target_attachment"] = Value::Null;
assert_eq!(
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
*automatic
);
assert_eq!(
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
*automatic
);
// Old source without either new field still means automatic presentation.
let old = SOURCE
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
let old = language::parse(&old).unwrap();
assert!(
old.diagrams
.values()
.flat_map(|d| &d.routes)
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
);
assert!(!language::format(&old).contains("_attachment"));
assert_eq!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
old
);
}
#[test]
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
let project = original();
let edge = id(&project, "signal");
for bad in [
json!({}),
json!({"side":"Top"}),
json!({"offset":0.5}),
json!({"side":"Diagonal","offset":0.5}),
json!({"side":"Top","offset":null}),
json!({"side":"Top","offset":0.5,"unknown":true}),
json!({"side":"Top","offset":"NaN"}),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route = json!({"edge":edge});
route[field] = bad.clone();
assert!(
serde_json::from_value::<EdgeRoute>(route).is_err(),
"{field}: {bad}"
);
}
}
for bad in [
"{}",
"{ side Top; }",
"{ offset 0.5; }",
"{ side Diagonal; offset 0.5; }",
"{ side Top; offset null; }",
"{ side Top; offset 0.5; unknown true; }",
"{ side Top; offset -0.01; }",
"{ side Top; offset 1.01; }",
"{ side Top; offset NaN; }",
"{ side Top; offset 1e999; }",
] {
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
assert!(language::parse(&source).is_err(), "{bad}");
let bundle = SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
"{bad}"
);
}
for version in [1, 2] {
let legacy =
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
assert!(
language::parse(&legacy).is_err(),
"legacy route extension must not be silently admitted"
);
}
}
#[test]
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
let project = original();
let view = id(&project, "view");
let first = id(&project, "first");
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
let mut route = project.diagrams[&view].routes[0].clone();
route.set_attachment(
end,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset,
}),
);
let mut session = Session::new(project.clone());
assert!(
session
.execute(Command::Batch(vec![
Command::Rename(first, "Must roll back".into()),
set(view, route)
]))
.is_err()
);
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && !session.can_redo());
}
}
for offset in [-0.01, 1.01] {
let mut value = serde_json::to_value(&project).unwrap();
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
json!(offset);
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
}
}
#[test]
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
let project = original();
let view = id(&project, "view");
let edge = id(&project, "signal");
let mut session = Session::new(project.clone());
let mut route = project.diagrams[&view].routes[0].clone();
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.75,
});
let command = set(view, route.clone());
assert_eq!(
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
command
);
session.execute(command).unwrap();
let mut expected = project.clone();
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
assert_eq!(
session.project(),
&expected,
"only this view/end changes; semantic fields and other view stay exact"
);
session.undo();
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && session.can_redo());
session.redo();
assert_eq!(session.project(), &expected);
route.source_attachment = None;
session.execute(set(view, route.clone())).unwrap();
let mut reset = expected.clone();
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
assert_eq!(
session.project(),
&reset,
"opposite attachment, pins and label survive endpoint reset"
);
session.undo();
assert_eq!(session.project(), &expected);
session
.execute(Command::RemoveEdgeRoute {
diagram: view,
edge,
})
.unwrap();
let mut removed = expected.clone();
removed.diagrams.get_mut(&view).unwrap().routes.clear();
assert_eq!(session.project(), &removed);
session.undo();
assert_eq!(session.project(), &expected);
}
#[test]
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
let current = compile_bundle(&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
]),
}, ProjectLimits::default()).unwrap();
let mut project = current.decoded.project.clone();
let view = id(&project, "view");
project.diagrams.get_mut(&view).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let changed = update_bundle(&current, &project, ProjectLimits::default()).unwrap();
assert_eq!(changed.decoded.project, project);
for path in ["p.arcadia", "model.arcadia"] {
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
}
assert_eq!(
changed.bundle.files["views.arcadia"],
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
);
assert_eq!(
compile_bundle(&changed.bundle, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
assert_eq!(
update_bundle(&changed, &project, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
}
-2
View File
@@ -441,8 +441,6 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr
diagram: d,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pos(500., 420.)],
label: None,
},
-4
View File
@@ -70,8 +70,6 @@ fn fixture() -> Fixture {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -691,8 +689,6 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
EdgeRoute {
edge: f.edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None
}
-2
View File
@@ -27,8 +27,6 @@ fn route(p: &Project, d: Id) -> RoutedView {
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: pins,
label,
});
-2
View File
@@ -117,8 +117,6 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
.routes
.push(EdgeRoute {
edge: fe,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(authored.clone()),
});
-16
View File
@@ -301,8 +301,6 @@ fn folding_retains_authored_routes_without_false_self_transitions() {
let pins = vec![point(690., 480.), point(690., 230.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: out,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -364,15 +362,11 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
let pins = vec![point(570., 460.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(label.clone()),
});
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.external,
source_attachment: None,
target_attachment: None,
waypoints: vec![point(690., 230.)],
label: None,
});
@@ -428,8 +422,6 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
let placement = f.p.place(f.d, orphan, point(30., 750.));
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
@@ -464,8 +456,6 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
diagram: other,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
},
@@ -593,8 +583,6 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
let pins = vec![point(730., 130.), point(1060., 130.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(EdgeLabel {
fraction: 0.25,
@@ -664,8 +652,6 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda
let pins = vec![point(280., 390.), point(280., 470.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -744,8 +730,6 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g
);
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(EdgeLabel {
fraction: 0.3,
@@ -0,0 +1,353 @@
//! Existing counterpart eligibility is a layer/category/kind decision, not
//! validation or acceptance of an entire transition plan. The catalog fixtures
//! below intentionally contain partial records (unowned states, etc.). The
//! final regression separately starts from a structurally valid real project.
use oa_core::{transitions::*, *};
use std::collections::{BTreeMap, BTreeSet};
fn element(p: &mut Project, layer: Layer, kind: Kind) -> Id {
let id = Id::new_v4();
let body = if kind == Kind::Port {
// Legacy ports can exist in preserved OA models. This does not offer
// new typed SA implementation ports for creation in portless OA.
ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: None,
},
)
} else {
ElementBody::new(kind, layer).unwrap()
};
p.elements.insert(
id,
Element {
id,
alias: format!("candidate_{}", id.simple()),
name: "Identical candidate label".into(),
description: String::new(),
parent: None,
body,
},
);
id
}
#[test]
fn existing_element_candidates_obey_explicit_engineering_families_in_both_directions() {
// Contract: language.rst §5 trace families and the explicit capability /
// mission alternative. State constructors remain eligible for explicit
// review across the state family; selecting a candidate does not accept a
// constructor change or make invalid ownership legal.
let mut allowed: BTreeSet<(Kind, Kind)> = [
(Kind::Activity, Kind::Function),
(Kind::Entity, Kind::System),
(Kind::Entity, Kind::SystemActor),
(Kind::Actor, Kind::System),
(Kind::Actor, Kind::SystemActor),
(Kind::Capability, Kind::SystemCapability),
(Kind::OperationalMission, Kind::Mission),
(Kind::Capability, Kind::Mission),
(Kind::Process, Kind::FunctionalChain),
]
.into_iter()
.collect();
for kind in [
Kind::ExchangeItem,
Kind::ExchangeCategory,
Kind::DataType,
Kind::DataField,
Kind::Interface,
Kind::Port,
Kind::Scenario,
Kind::Lifeline,
Kind::Occurrence,
Kind::StateMachine,
Kind::Region,
Kind::Event,
Kind::Constraint,
] {
allowed.insert((kind, kind));
}
let vertices = [
Kind::State,
Kind::Mode,
Kind::Initial,
Kind::Final,
Kind::Choice,
Kind::Junction,
Kind::Fork,
Kind::Join,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
];
for oa in vertices {
for sa in vertices {
allowed.insert((oa, sa));
}
}
let mut p = Project::empty();
let oa: BTreeMap<_, _> = Kind::ALL
.into_iter()
.filter(|kind| kind.supports(Layer::Oa))
.map(|kind| (kind, element(&mut p, Layer::Oa, kind)))
.collect();
let sa: BTreeMap<_, _> = Kind::ALL
.into_iter()
.filter(|kind| kind.supports(Layer::Sa))
.map(|kind| (kind, element(&mut p, Layer::Sa, kind)))
.collect();
let original = p.clone();
for (&oa_kind, &oa_id) in &oa {
for (&sa_kind, &sa_id) in &sa {
let expected = allowed.contains(&(oa_kind, sa_kind));
assert_eq!(
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
expected,
"OA {oa_kind:?} → SA {sa_kind:?}"
);
assert_eq!(
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
expected,
"SA {sa_kind:?} → OA {oa_kind:?}"
);
}
}
assert_eq!(
p, original,
"Eligibility must not establish correspondence or edit records"
);
}
fn relation(p: &mut Project, kind: RelationKind, anchor: Id) -> Id {
let id = Id::new_v4();
// Only the semantic layer and relation category participate in candidate
// eligibility. Endpoint compatibility belongs to full plan validation.
p.relations.insert(
id,
Relation {
id,
alias: format!("relation_{}", id.simple()),
name: "Identical relation label".into(),
description: String::new(),
body: RelationBody::new(kind, anchor, anchor),
},
);
id
}
#[test]
fn existing_relation_candidates_distinguish_work_boundary_and_shared_relations() {
// Work and boundary exchanges are different families. The boundary /
// boundary pair permits preserved legacy OA boundary records; ordinary
// portless OA communication means map to SA boundary exchanges.
let mut allowed = vec![
(RelationKind::Interaction, RelationKind::FunctionalExchange),
(
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange,
),
(
RelationKind::BoundaryExchange,
RelationKind::BoundaryExchange,
),
];
let shared = [
RelationKind::Allocation,
RelationKind::InterfaceItemAllocation,
RelationKind::DataAssociation,
RelationKind::ExchangeAllocation,
RelationKind::CapabilityInvolvement,
RelationKind::CapabilityInclude,
RelationKind::CapabilityExtend,
RelationKind::CapabilityGeneralization,
RelationKind::MissionInvolvement,
RelationKind::MissionCapability,
RelationKind::SequenceLink,
RelationKind::ContextualLink,
RelationKind::ChainInvolvement,
RelationKind::InterfaceProvision,
RelationKind::InterfaceRequirement,
RelationKind::StateTransition,
RelationKind::Availability,
RelationKind::StateAction,
];
for kind in shared {
allowed.push((kind, kind));
}
let mut p = Project::empty();
let oa_anchor = element(&mut p, Layer::Oa, Kind::Activity);
let sa_anchor = element(&mut p, Layer::Sa, Kind::Function);
let oa: Vec<_> = shared
.into_iter()
.chain([
RelationKind::Interaction,
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange,
RelationKind::RoleRealization,
RelationKind::RoleAssignment,
])
.map(|kind| (kind, relation(&mut p, kind, oa_anchor)))
.collect();
let sa: Vec<_> = shared
.into_iter()
.chain([
RelationKind::FunctionalExchange,
RelationKind::BoundaryExchange,
RelationKind::PortAllocation,
])
.map(|kind| (kind, relation(&mut p, kind, sa_anchor)))
.collect();
for &(oa_kind, oa_id) in &oa {
for &(sa_kind, sa_id) in &sa {
let expected = allowed.contains(&(oa_kind, sa_kind));
assert_eq!(
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
expected,
"OA {oa_kind:?} → SA {sa_kind:?}"
);
assert_eq!(
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
expected,
"SA {sa_kind:?} → OA {oa_kind:?}"
);
}
}
}
#[test]
fn candidate_eligibility_rejects_wrong_layers_categories_and_missing_identities() {
let mut p = Project::empty();
let oa = element(&mut p, Layer::Oa, Kind::Activity);
let sa = element(&mut p, Layer::Sa, Kind::Function);
let oa_exchange = relation(&mut p, RelationKind::Interaction, oa);
let sa_exchange = relation(&mut p, RelationKind::FunctionalExchange, sa);
let diagram = p.ensure_diagram(Layer::Sa);
let node = p.place(diagram, sa, Position { x: 1., y: 2. });
for (direction, source, target, source_relation, target_relation) in [
(
TransitionDirection::OaToSa,
oa,
sa,
oa_exchange,
sa_exchange,
),
(
TransitionDirection::SaToOa,
sa,
oa,
sa_exchange,
oa_exchange,
),
] {
assert!(counterpart_compatible(&p, source, target, direction));
assert!(counterpart_compatible(
&p,
source_relation,
target_relation,
direction
));
for (a, b) in [
(target, source),
(source, source),
(target, target),
(target_relation, source_relation),
(source_relation, source_relation),
(source, target_relation),
(source_relation, target),
] {
assert!(!counterpart_compatible(&p, a, b, direction));
}
for absent_or_nonsemantic in [Id::nil(), Id::new_v4(), p.id, diagram, node] {
assert!(!counterpart_compatible(
&p,
source,
absent_or_nonsemantic,
direction
));
assert!(!counterpart_compatible(
&p,
absent_or_nonsemantic,
target,
direction
));
}
}
p.elements.remove(&sa);
assert!(!counterpart_compatible(
&p,
oa,
sa,
TransitionDirection::OaToSa
));
assert!(!counterpart_compatible(
&p,
sa,
oa,
TransitionDirection::SaToOa
));
// The retained relation header cannot borrow a layer from a deleted owner.
assert!(!counterpart_compatible(
&p,
oa_exchange,
sa_exchange,
TransitionDirection::OaToSa
));
}
#[test]
fn activity_picker_rejects_real_sa_port_and_agrees_with_explicit_plan_refusal() {
let p = Project::demo();
let original = p.clone();
assert!(validation::validate(&p).is_empty());
let source = p
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let port = p
.elements
.values()
.find(|e| e.layer() == Layer::Sa && e.kind() == Kind::Port)
.unwrap()
.id;
let function = p
.elements
.values()
.find(|e| e.kind() == Kind::Function)
.unwrap()
.id;
assert!(counterpart_compatible(
&p,
source,
function,
TransitionDirection::OaToSa
));
assert!(!counterpart_compatible(
&p,
source,
port,
TransitionDirection::OaToSa
));
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
request.mappings.push(TransitionMapping {
source,
target: Some(port),
target_kind: None,
});
let plan = plan_transition(&p, &request).unwrap();
assert!(
plan.conflicts
.iter()
.any(|conflict| conflict.source == Some(source)
&& conflict.target == Some(port)
&& conflict.field.is_none())
);
assert!(apply_transition(&p, &plan).is_err());
assert_eq!(p, original);
}
@@ -0,0 +1,628 @@
//! Endpoint lifecycle acceptance for the ordinary, prose-reviewed transition API.
//! OA is portless; the SA half contains typed ports. Deletion expectations below
//! are fixture-owned identity lists, never results of the deletion implementation.
use oa_core::{
language, persistence,
source_project::{ProjectLimits, SourceBundle, compile_bundle},
transitions::*,
validation::{self, Severity},
*,
};
use std::collections::BTreeMap;
struct Side {
root: Id,
elements: Vec<Id>,
exchange: Id,
diagram: Id,
nodes: Vec<Id>,
artifacts: Vec<Id>,
}
struct Fixture {
project: Project,
source: Side,
target: Side,
trace: Id,
trace_artifact: Id,
}
fn artifact(p: &mut Project, owner: Id, label: &str) -> Id {
let id = Id::new_v4();
p.artifact_links.insert(
id,
ArtifactLink {
id,
alias: p.fresh_alias(label, "evidence", id),
name: label.into(),
description: "Independently authored rationale".into(),
owner,
relation: ArtifactRelation::References,
uri: "git:engineering/design.md".into(),
revision: Some("review-17".into()),
fragment: Some("retained-decision".into()),
},
);
id
}
fn side(p: &mut Project, layer: Layer) -> Side {
let (kind, prefix, relation) = match layer {
Layer::Oa => (Kind::Activity, "Operational", RelationKind::Interaction),
Layer::Sa => (
Kind::Function,
"Technical",
RelationKind::FunctionalExchange,
),
};
let root = p
.add_element(kind, layer, &format!("{prefix} design"), None)
.unwrap();
p.elements.get_mut(&root).unwrap().description =
format!("{prefix} independently reviewed intent");
let send = p
.add_element(kind, layer, &format!("{prefix} send"), Some(root))
.unwrap();
let receive = p
.add_element(kind, layer, &format!("{prefix} receive"), Some(root))
.unwrap();
p.elements.get_mut(&send).unwrap().description =
"Preserve the detailed sending responsibility".into();
p.elements.get_mut(&receive).unwrap().description =
"Preserve the detailed receiving responsibility".into();
// A shared, separately owned definition survives deletion of its users.
let item = p
.add_element(
Kind::ExchangeItem,
layer,
&format!("{prefix} information"),
None,
)
.unwrap();
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
p.add_element(
Kind::DataField,
layer,
&format!("{prefix} payload"),
Some(item),
)
.unwrap();
let exchange = p.relate(relation, &format!("{prefix} delivery"), send, receive);
let mut elements = vec![root, send, receive];
match &mut p.relations.get_mut(&exchange).unwrap().body {
RelationBody::Interaction { items, .. } => *items = vec![item],
RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} => {
*items = vec![item];
let (output, input) = (*source_port, *target_port);
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::FunctionOutput {
outgoing_items: vec![item],
},
legacy: None,
};
*p.elements.get_mut(&input).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::FunctionInput {
incoming_items: vec![item],
},
legacy: None,
};
elements.extend([output, input]);
}
_ => unreachable!(),
}
p.relations.get_mut(&exchange).unwrap().description =
"Retain direction, item contract and relation identity".into();
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition;
let nodes = [root, send, receive]
.into_iter()
.enumerate()
.map(|(index, element)| {
p.place(
diagram,
element,
Position {
x: 43.25 + index as f32 * 271.,
y: 97.5 + index as f32 * 83.,
},
)
})
.collect::<Vec<_>>();
let root_node = p
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&nodes[0])
.unwrap();
root_node.size = Size {
width: 611.,
height: 319.,
};
root_node.collapsed = true;
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: exchange,
waypoints: vec![Position {
x: 337.5,
y: 423.25,
}],
label: Some(EdgeLabel {
fraction: 0.7,
offset: Position { x: 11., y: -17. },
}),
});
let artifacts = [root, send, exchange, nodes[0]]
.into_iter()
.enumerate()
.map(|(index, owner)| artifact(p, owner, &format!("{prefix} evidence {index}")))
.collect();
artifact(p, item, &format!("{prefix} shared item evidence"));
Side {
root,
elements,
exchange,
diagram,
nodes,
artifacts,
}
}
fn accepted(p: &Project) {
let issues = validation::validate(p);
// This fixture deliberately isolates decomposition and correspondence from
// performer allocation and process/chain/scenario inclusion. Each surviving
// leaf (including a newly transitioned root) must have exactly those two
// incomplete-work warnings; each surviving local exchange lacks a behavior
// context. Check exact record IDs and fields, including duplicate warnings.
// The system of interest and item contracts are complete fixture facts.
assert!(
issues
.iter()
.all(|issue| issue.severity == Severity::Warning),
"{issues:?}"
);
let mut expected = Vec::new();
for element in p.elements.values().filter(|element| {
matches!(element.kind(), Kind::Activity | Kind::Function)
&& !p
.elements
.values()
.any(|child| child.parent == Some(element.id) && child.kind() == element.kind())
}) {
expected.push(("unallocated_leaf", Some(element.id), Some("allocation")));
expected.push(("work_without_behavior_context", Some(element.id), None));
}
for relation in p.relations.values().filter(|relation| {
matches!(
relation.kind(),
RelationKind::Interaction | RelationKind::FunctionalExchange
)
}) {
expected.push(("exchange_without_behavior_context", Some(relation.id), None));
}
let mut actual = issues
.iter()
.map(|issue| (issue.code.as_str(), issue.element, issue.field.as_deref()))
.collect::<Vec<_>>();
expected.sort();
actual.sort();
assert_eq!(
actual, expected,
"Unexpected fixture diagnostics: {issues:?}"
);
}
fn round_trip(p: &Project) -> String {
accepted(p);
let json = serde_json::to_vec_pretty(p).unwrap();
assert_eq!(persistence::decode(&json).unwrap(), *p);
let source = language::format(p);
let compiled = compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source.clone())]),
},
ProjectLimits::default(),
)
.unwrap();
assert_eq!(compiled.decoded.project, *p);
assert_eq!(language::format(&compiled.decoded.project), source);
source
}
fn explicit(direction: TransitionDirection, source: Id, target: Id) -> TransitionRequest {
let mut request = TransitionRequest::new(direction, vec![source]);
request.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
request
}
fn fixture(direction: TransitionDirection) -> Fixture {
let mut project = Project::empty();
project.name = "Endpoint lifecycle conservation".into();
project
.add_element(Kind::System, Layer::Sa, "System of interest", None)
.unwrap();
let oa = side(&mut project, Layer::Oa);
let sa = side(&mut project, Layer::Sa);
let (source, target) = match direction {
TransitionDirection::OaToSa => (oa, sa),
TransitionDirection::SaToOa => (sa, oa),
};
accepted(&project);
let before = project.clone();
let mut request = explicit(direction, source.root, target.root);
for field in [TransitionField::Name, TransitionField::Description] {
request.resolutions.push(FieldResolution {
source: source.root,
field,
decision: FieldDecision::KeepTarget,
});
}
let plan = plan_transition(&project, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
project = apply_transition(&project, &plan).unwrap();
let trace = assert_only_new_trace(&before, &project, direction, source.root, target.root);
let mut without_trace = project.clone();
without_trace.traces.remove(&trace);
assert_eq!(
without_trace, before,
"Initial review must only add correspondence"
);
let trace_artifact = artifact(&mut project, trace, "Correspondence rationale");
round_trip(&project);
Fixture {
project,
source,
target,
trace,
trace_artifact,
}
}
fn assert_only_new_trace(
before: &Project,
after: &Project,
direction: TransitionDirection,
source: Id,
target: Id,
) -> Id {
let added = after
.traces
.values()
.filter(|trace| !before.traces.contains_key(&trace.id))
.collect::<Vec<_>>();
assert_eq!(added.len(), 1);
assert_eq!(after.traces.len(), before.traces.len() + 1);
let trace = added[0];
let (oa, sa) = match direction {
TransitionDirection::OaToSa => (source, target),
TransitionDirection::SaToOa => (target, source),
};
assert_eq!(
(trace.oa, trace.sa, trace.kind),
(oa, sa, TraceKind::Refinement)
);
assert_eq!(
trace.baseline,
Some(TraceBaseline {
oa: after.properties(oa).unwrap(),
sa: after.properties(sa).unwrap(),
semantic: None,
})
);
assert_eq!(trace_drift(after, trace), Drift::Unchanged);
trace.id
}
fn expected_deletion(fixture: &Fixture, side: &Side) -> Project {
let mut expected = fixture.project.clone();
for id in &side.elements {
assert!(expected.elements.remove(id).is_some());
}
assert!(expected.relations.remove(&side.exchange).is_some());
assert!(expected.traces.remove(&fixture.trace).is_some());
for id in side.artifacts.iter().chain([&fixture.trace_artifact]) {
assert!(expected.artifact_links.remove(id).is_some());
}
let diagram = expected.diagrams.get_mut(&side.diagram).unwrap();
for id in &side.nodes {
assert!(diagram.nodes.remove(id).is_some());
}
// This fixture authored exactly one route, for its removed local exchange.
assert_eq!(diagram.routes.len(), 1);
assert_eq!(diagram.routes[0].edge, side.exchange);
diagram.routes.clear();
expected
}
fn unchanged_retry(p: &Project, direction: TransitionDirection, source: Id, target: Id) {
let request = TransitionRequest::new(direction, vec![source]);
let plan = plan_transition(p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
assert_eq!(
(
plan.changes[0].source,
plan.changes[0].target,
plan.changes[0].action
),
(source, target, TransitionAction::Reuse)
);
assert!(plan.changes[0].fields.is_empty());
assert!(plan.commands.is_empty(), "{:?}", plan.commands);
let mut session = Session::new(p.clone());
let revision = session.revision();
session
.execute(Command::Replace(apply_transition(p, &plan).unwrap()))
.unwrap();
assert_eq!(session.project(), p);
assert_eq!(session.revision(), revision);
assert!(!session.can_undo());
}
fn source_deletion(direction: TransitionDirection) {
let fixture = fixture(direction);
let original_source = round_trip(&fixture.project);
let expected = expected_deletion(&fixture, &fixture.source);
let mut session = Session::new(fixture.project.clone());
session
.execute(Command::Delete(fixture.source.root))
.unwrap();
// Whole-project equality covers every retained target child, typed SA port,
// exchange, placement, route, artifact, shared item and unchanged identity.
assert_eq!(session.project(), &expected);
let deleted_source = round_trip(session.project());
session.undo();
assert_eq!(session.project(), &fixture.project);
assert_eq!(round_trip(session.project()), original_source);
assert!(!session.can_undo(), "Deletion is one history transaction");
session.redo();
assert_eq!(session.project(), &expected);
assert_eq!(round_trip(session.project()), deleted_source);
}
fn implicit_creation(
p: &Project,
direction: TransitionDirection,
source: Id,
forbidden_target: Id,
) -> (Project, Id) {
let request = TransitionRequest::new(direction, vec![source]);
let plan = plan_transition(p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
let change = &plan.changes[0];
assert_eq!(change.source, source);
assert_eq!(change.action, TransitionAction::Create);
let target = change.target;
assert_ne!(target, forbidden_target);
assert!(!p.contains(target));
let after = apply_transition(p, &plan).unwrap();
let trace = assert_only_new_trace(p, &after, direction, source, target);
let created = &after.elements[&target];
assert_eq!(created.id, target, "Apply preserves the preview identity");
assert_eq!(created.name, p.elements[&source].name);
assert_eq!(created.description, p.elements[&source].description);
assert_eq!(created.parent, None);
assert_eq!(
created.body,
match direction {
TransitionDirection::OaToSa => ElementBody::Sa(SaBody::Function),
TransitionDirection::SaToOa => ElementBody::Oa(OaBody::Activity),
}
);
assert!(!created.alias.is_empty());
assert!(
!p.identities()
.iter()
.any(|(_, alias)| *alias == created.alias)
);
// Remove only the independently expected two additions. Any incidental
// mutation, clone of target children or extra correspondence fails here.
let mut retained = after.clone();
retained.elements.remove(&target);
retained.traces.remove(&trace);
assert_eq!(retained, *p);
round_trip(&after);
// Replaying a creation plan after acceptance violates its snapshot, while
// a fresh unchanged request reuses the accepted counterpart.
assert!(
apply_transition(&after, &plan)
.unwrap_err()
.0
.contains("stale")
);
unchanged_retry(&after, direction, source, target);
(after, target)
}
fn target_deletion(direction: TransitionDirection) {
let fixture = fixture(direction);
let request = TransitionRequest::new(direction, vec![fixture.source.root]);
let old_plan = plan_transition(&fixture.project, &request).unwrap();
assert!(old_plan.conflicts.is_empty());
let expected = expected_deletion(&fixture, &fixture.target);
let mut session = Session::new(fixture.project.clone());
session
.execute(Command::Delete(fixture.target.root))
.unwrap();
assert_eq!(session.project(), &expected);
round_trip(session.project());
let revision = session.revision();
assert!(
apply_transition(session.project(), &old_plan)
.unwrap_err()
.0
.contains("stale")
);
let explicit_plan = plan_transition(
session.project(),
&explicit(direction, fixture.source.root, fixture.target.root),
)
.unwrap();
assert!(
explicit_plan
.conflicts
.iter()
.any(|conflict| conflict.source == Some(fixture.source.root)
&& conflict.target == Some(fixture.target.root)
&& conflict.field.is_none())
);
assert!(apply_transition(session.project(), &explicit_plan).is_err());
assert_eq!(session.project(), &expected);
assert_eq!(session.revision(), revision);
let (created, _) = implicit_creation(
session.project(),
direction,
fixture.source.root,
fixture.target.root,
);
session.execute(Command::Replace(created.clone())).unwrap();
session.undo();
assert_eq!(session.project(), &expected);
session.undo();
assert_eq!(session.project(), &fixture.project);
assert!(!session.can_undo());
session.redo();
session.redo();
assert_eq!(session.project(), &created);
round_trip(session.project());
}
fn source_recreation(direction: TransitionDirection) {
let fixture = fixture(direction);
let mut replacement = fixture.project.elements[&fixture.source.root].clone();
replacement.id = Id::new_v4();
let source = replacement.id;
// Same alias, name and prose, different UUID: name matching must never
// silently relink the retained engineering design.
assert_ne!(source, fixture.source.root);
let deleted = execute_batch(&fixture.project, &[Command::Delete(fixture.source.root)]).unwrap();
assert_eq!(deleted, expected_deletion(&fixture, &fixture.source));
let recreated =
execute_batch(&deleted, &[Command::CreateElement(replacement.clone())]).unwrap();
let mut expected = deleted.clone();
expected.elements.insert(source, replacement);
assert_eq!(recreated, expected);
round_trip(&recreated);
let (implicit, new_target) =
implicit_creation(&recreated, direction, source, fixture.target.root);
assert!(implicit.elements.contains_key(&fixture.target.root));
assert_ne!(new_target, fixture.target.root);
// The explicit branch starts from exactly the same recreated project.
let mut request = explicit(direction, source, fixture.target.root);
let unreviewed = plan_transition(&recreated, &request).unwrap();
for field in [TransitionField::Name, TransitionField::Description] {
assert!(
unreviewed
.conflicts
.iter()
.any(|conflict| conflict.source == Some(source)
&& conflict.target == Some(fixture.target.root)
&& conflict.field == Some(field))
);
}
assert!(apply_transition(&recreated, &unreviewed).is_err());
request.resolutions = vec![
FieldResolution {
source,
field: TransitionField::Name,
decision: FieldDecision::TakeSource,
},
FieldResolution {
source,
field: TransitionField::Description,
decision: FieldDecision::KeepTarget,
},
];
let plan = plan_transition(&recreated, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
assert_eq!(
(plan.changes[0].target, plan.changes[0].action),
(fixture.target.root, TransitionAction::Update)
);
assert_eq!(
plan.changes[0].fields,
vec![FieldChange {
field: TransitionField::Name,
before: recreated.elements[&fixture.target.root].name.clone(),
after: recreated.elements[&source].name.clone(),
}]
);
let relinked = apply_transition(&recreated, &plan).unwrap();
let new_trace = assert_only_new_trace(
&recreated,
&relinked,
direction,
source,
fixture.target.root,
);
assert_ne!(
new_trace, fixture.trace,
"Deleted correspondence is not silently resurrected"
);
let mut expected = recreated.clone();
expected
.elements
.get_mut(&fixture.target.root)
.unwrap()
.name = recreated.elements[&source].name.clone();
let mut without_trace = relinked.clone();
without_trace.traces.remove(&new_trace);
assert_eq!(
without_trace, expected,
"Only the reviewed target name may change; rich target design survives"
);
round_trip(&relinked);
unchanged_retry(&relinked, direction, source, fixture.target.root);
let original_source = round_trip(&recreated);
let mut session = Session::new(recreated.clone());
session.execute(Command::Replace(relinked)).unwrap();
session.undo();
assert_eq!(session.project(), &recreated);
assert_eq!(round_trip(session.project()), original_source);
}
#[test]
fn oa_source_deletion_preserves_rich_sa_design_and_exact_undo() {
source_deletion(TransitionDirection::OaToSa);
}
#[test]
fn sa_source_deletion_preserves_rich_portless_oa_design_and_exact_undo() {
source_deletion(TransitionDirection::SaToOa);
}
#[test]
fn deleted_sa_target_refuses_old_plans_and_implicit_retry_creates_once() {
target_deletion(TransitionDirection::OaToSa);
}
#[test]
fn deleted_oa_target_refuses_old_plans_and_implicit_retry_creates_once() {
target_deletion(TransitionDirection::SaToOa);
}
#[test]
fn recreated_oa_identity_requires_explicit_relink_to_retained_sa_design() {
source_recreation(TransitionDirection::OaToSa);
}
#[test]
fn recreated_sa_identity_requires_explicit_relink_to_retained_oa_design() {
source_recreation(TransitionDirection::SaToOa);
}
@@ -748,8 +748,6 @@ fn allocation_artifact_and_manual_trace_cascades_are_named_and_refused() {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![xy(420., 400.), xy(420., 550.)],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -70,7 +70,6 @@ impl Root<'_> {
pan: self.app.pan,
context: self.app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: false,
placing: false,
connection: None,
+1 -84
View File
@@ -493,7 +493,6 @@ pub struct App {
pub camera_epoch: u64,
camera_boundary_epoch: u64,
pub selected_bend: Option<(Id, usize)>,
pub selected_attachment: Option<(crate::route_edit::Context, Id, EdgeEnd)>,
pub exact_strings: bool,
pub document: Document,
pub page: Page,
@@ -614,7 +613,6 @@ impl App {
camera_epoch: 0,
camera_boundary_epoch: 0,
selected_bend: None,
selected_attachment: None,
exact_strings: false,
document,
page: Page::Diagram,
@@ -1105,9 +1103,6 @@ impl App {
previous != (self.layer, self.active_diagram)
}
fn select(&mut self, id: Id) {
if id != self.selected {
self.selected_attachment = None;
}
if self.selected == id {
return;
}
@@ -2334,15 +2329,6 @@ impl App {
} else if self.behavior.creation.has_pending() {
self.behavior.creation.acknowledge_commit();
self.status = "Behavior creation cancelled".into();
} else if self.page == Page::Behavior
&& matches!(
self.behavior.diagram.tool,
behavior::state_edit::Tool::AdjustAttachment { .. }
)
{
// Menu arming invalidates canvas keyboard ownership. Escape
// must also cancel before the next canvas click grants it.
self.behavior.diagram.tool = behavior::state_edit::Tool::Select;
}
if self.connection.take().is_some() {
self.status = "Drawing cancelled · Select / move is active".into();
@@ -4537,7 +4523,7 @@ impl App {
}
fn edit_route(&mut self, event: crate::route_edit::Event) {
use crate::route_edit::{Event, insert_pin, stored_route};
if self.connection.is_some() || self.page != Page::Diagram {
if self.connection.is_some() {
return;
}
let Some(context) = self.route_context() else {
@@ -4565,75 +4551,6 @@ impl App {
.filter(|(id, _)| *id == self.selected)
.map(|(_, i)| i);
let command = match event {
Event::ChooseAttachment(end) => {
if crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_some()
{
self.selected_attachment = Some((context, self.selected, end));
self.selected_bend = None;
self.status = format!(
"Drag the {} attachment handle along its element boundary · Esc cancels",
if end == EdgeEnd::Source {
"source"
} else {
"target"
}
);
}
return;
}
Event::ResetAttachment(end) => {
if crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_none()
|| route.attachment(end).is_none()
{
return;
}
route.set_attachment(end, None);
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route,
}
}
Event::CommitAttachment(expected, changed, end, owner) => {
if expected != context
|| changed.edge != self.selected
|| crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_none_or(|node| node.id != owner)
{
return;
}
// A boundary gesture owns exactly one presentation field.
let mut intended = route.clone();
intended.set_attachment(end, changed.attachment(end));
if changed != intended || intended == route {
return;
}
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route: intended,
}
}
Event::SelectBend(id, index) => {
if id == self.selected && index < route.waypoints.len() {
self.selected_bend = Some((id, index));
@@ -69,7 +69,6 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
@@ -75,7 +75,6 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -230,7 +230,6 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: true,
connection: None,
@@ -307,7 +306,6 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -924,8 +924,6 @@ async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed(
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
@@ -1,158 +0,0 @@
//! Production App tree and its actual keyboard subscription, without a canvas click.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{
advanced::subscription,
futures::{StreamExt, stream, task::noop_waker_ref},
keyboard::key::Named,
mouse,
};
use std::task::{Context as PollContext, Poll};
async fn press(mounted: &mut Mounted, app: &mut App, named: Named) -> Vec<Message> {
let input = key(named);
let (mut messages, statuses) = mounted.input_with_status(
app,
std::slice::from_ref(&input),
mouse::Cursor::Unavailable,
);
let interaction = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event: input,
status: statuses[0],
};
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
let mut cx = PollContext::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
messages.push(message);
}
}
for message in messages.iter().cloned() {
let task = app.update(message);
mounted.perform(app, task).await;
}
mounted.settle(app).await;
messages
}
async fn armed() -> (App, Mounted, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
let mut app = App::with_project(project);
app.window_size = iced::Size::new(1800., 1100.);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let mut mounted = Mounted::new(&mut app).await;
app.behavior.diagram.fit = false;
let context = app.state_context().unwrap();
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
anchor: iced::Point::new(450., 400.),
},
);
mounted.settle(&mut app).await;
let token = app.model_menu.as_ref().unwrap().token;
send(
&mut app,
Message::Menu(crate::model_menu::Event::Invoke(
token,
Box::new(Message::StateDiagram(Event::Choose(
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source,
},
))),
)),
);
mounted.settle(&mut app).await;
assert!(app.model_menu.is_none());
assert_eq!(
app.behavior.diagram.tool,
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source
}
);
(app, mounted, relation)
}
fn canvas_focus(mounted: &mut Mounted, app: &App) -> (iced::Rectangle, bool) {
let id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, bounds, focused) = mounted
.geometry(app)
.focused
.into_iter()
.find(|(candidate, _, _)| candidate.as_ref() == Some(&id))
.unwrap();
(bounds, focused)
}
#[tokio::test]
async fn mounted_menu_armed_attachment_escape_without_canvas_click_preserves_document_and_focus() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
let history = (app.document.can_undo(), app.document.can_redo());
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
assert!(!canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(messages.iter().any(|m| matches!(m, Message::Escape)));
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(!canvas_focus(&mut mounted, &app).1);
press(&mut mounted, &mut app, Named::Delete).await;
assert!(
!app.delete_pending,
"Escape must not grant canvas Delete ownership"
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
}
#[tokio::test]
async fn mounted_attachment_escape_preserves_modal_priority_and_focused_canvas_control() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
// Existing modal Escape consumes the first key without canceling the tool underneath.
send(&mut app, Message::Delete);
assert!(app.delete_pending && app.delete_target.is_some());
mounted.settle(&mut app).await;
press(&mut mounted, &mut app, Named::Escape).await;
assert!(!app.delete_pending);
assert!(matches!(
app.behavior.diagram.tool,
Tool::AdjustAttachment { .. }
));
// A real canvas click grants ownership; inner-canvas Escape remains live.
let bounds = canvas_focus(&mut mounted, &app).0;
mounted
.click(&mut app, iced::Point::new(bounds.x + 10., bounds.y + 10.))
.await;
assert!(canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::StateDiagram(Event::Choose(Tool::Select))))
);
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
@@ -172,24 +172,6 @@ impl App {
)));
}
Event::Choose(tool) => {
if let Tool::AdjustAttachment { relation, end } = tool {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before adjusting an attachment.".into();
return Task::none();
}
let Some(context) = self.state_context() else {
return Task::none();
};
if state_edit::attachment_node(self.project(), context.diagram, relation, end)
.is_none()
{
return Task::none();
}
self.selected = relation;
self.selected_node = None;
self.refresh_draft();
}
if !self.apply() {
return Task::none();
}
@@ -228,22 +210,6 @@ impl App {
let Some(context) = self.state_context() else {
return Task::none();
};
if let Event::SetAttachment {
relation,
end,
attachment,
..
} = &mutation
{
let existing = self.project().diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == *relation)
.and_then(|route| route.attachment(*end));
if existing == *attachment {
return Task::none();
}
}
match state_edit::plan(self.project(), context, &mutation) {
Ok(plan) => {
if self.execute(plan.command) {
@@ -1,7 +1,5 @@
//! App transaction and draft boundaries for captured state-canvas messages.
use super::*;
#[path = "state_attachment_escape_tests.rs"]
mod attachment_escape;
use crate::behavior::{
self,
state_edit::{self, Context, Endpoint, Event, Tool},
@@ -158,14 +158,6 @@ impl Mounted {
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
self.input_with_status(app, events, cursor).0
}
pub fn input_with_status(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> (Vec<Message>, Vec<iced::event::Status>) {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
@@ -173,7 +165,7 @@ impl Mounted {
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let (_, statuses) = ui.update(
let _ = ui.update(
events,
cursor,
&mut self.renderer,
@@ -181,7 +173,7 @@ impl Mounted {
&mut messages,
);
self.cache = ui.into_cache();
(messages, statuses)
messages
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
@@ -43,10 +43,8 @@ pub struct State {
press: Option<Press>,
source: Option<Id>,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
pan_from: Option<(mouse::Button, Point)>,
viewport: Option<Size>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
scene: RefCell<Option<CachedScene>>,
geometry: canvas::Cache,
paint_key: RefCell<Option<PaintKey>>,
@@ -78,20 +76,6 @@ enum Press {
/// Handles require a drag; an explicitly chosen reconnect tool accepts a click.
start: Option<Point>,
},
Attachment {
relation: Id,
end: EdgeEnd,
placement: Id,
start: Point,
},
}
#[derive(Clone, Copy, PartialEq)]
struct AttachmentPreview {
relation: Id,
end: EdgeEnd,
placement: Id,
attachment: EdgeAttachment,
}
struct Scene {
@@ -102,14 +86,12 @@ struct Scene {
struct CachedScene {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
scene: Arc<Scene>,
}
#[derive(PartialEq)]
struct PaintKey {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
selected: Id,
zoom: f32,
pan: Vector,
@@ -125,8 +107,6 @@ impl State {
fn cancel_gesture(&mut self) {
self.press = None;
self.preview = None;
self.attachment_preview = None;
self.attachment_viewport = None;
self.pan_from = None;
}
fn reset(&mut self) {
@@ -676,23 +656,13 @@ impl StateCanvas<'_> {
.gesture_active(state)
.then_some(state.preview)
.flatten();
let attachment_preview = self
.gesture_active(state)
.then_some(state.attachment_preview)
.flatten();
if let Some(cached) = state.scene.borrow().as_ref()
&& cached.context == self.context
&& cached.preview == preview
&& cached.attachment_preview == attachment_preview
{
return Some(cached.scene.clone());
}
let mut diagram = self.project.diagrams.get(&self.diagram)?.clone();
if let Some(preview) = attachment_preview {
let mut route = crate::route_edit::stored_route(&diagram, preview.relation);
route.set_attachment(preview.end, Some(preview.attachment));
crate::route_edit::with_route(&mut diagram, route);
}
if let Some((id, at)) = preview
&& let Some(node) = diagram.nodes.get(&id)
{
@@ -727,7 +697,6 @@ impl StateCanvas<'_> {
*state.scene.borrow_mut() = Some(CachedScene {
context: self.context,
preview,
attachment_preview,
scene: scene.clone(),
});
Some(scene)
@@ -889,64 +858,10 @@ impl StateCanvas<'_> {
fn gesture_active(&self, state: &State) -> bool {
self.editable && state.context == Some(self.context) && state.tool == Some(self.tool)
}
fn move_attachment_pointer(&self, state: &mut State, window: Point, local: Point) {
let Some(Press::Attachment {
relation,
end,
placement,
start,
}) = state.press
else {
return;
};
if window.distance(start) < 3. {
// Returning to the press restores the exact persisted optional value,
// without quantizing an existing attachment or pinning an automatic one.
state.attachment_preview = None;
return;
}
let Some(owner) = state_edit::attachment_node(self.project, self.diagram, relation, end)
.filter(|owner| owner.id == placement)
else {
state.cancel_gesture();
return;
};
let diagram = &self.project.diagrams[&self.diagram];
let previous = state
.attachment_preview
.map(|preview| preview.attachment.side)
.or_else(|| {
diagram
.routes
.iter()
.find(|route| route.edge == relation)
.and_then(|route| route.attachment(end))
.map(|attachment| attachment.side)
});
let world = self.world(local);
if !world.x.is_finite() || !world.y.is_finite() {
state.cancel_gesture();
return;
}
let attachment =
routing::attachment_from_point(&owner, position(world), previous, 6. / self.scale());
state.attachment_preview = Some(AttachmentPreview {
relation,
end,
placement,
attachment,
});
}
fn guidance(&self, state: &State, scene: &Scene, world: Option<Point>) -> String {
if !self.editable {
return "Pending edits · apply or cancel them before editing the diagram".into();
}
if let Tool::AdjustAttachment { relation, end } = self.tool {
return format!(
"Adjust {end:?} attachment of {} · drag that tip around its existing vertex boundary · Esc cancels · relation unchanged",
self.project.named(relation)
);
}
let hovered = world.and_then(|p| self.vertex_at(scene, p));
let press = self.gesture_active(state).then_some(state.press).flatten();
let source = self.gesture_active(state).then_some(state.source).flatten();
@@ -1037,15 +952,8 @@ impl canvas::Program<Event> for StateCanvas<'_> {
if !self.editable {
state.press = None;
state.preview = None;
state.attachment_preview = None;
state.source = None;
}
if state
.attachment_viewport
.is_some_and(|view| view != (self.zoom, self.pan, bounds))
{
state.cancel_gesture();
}
// Winit positions are ordered events. The cursor supplied by Iced is the
// final cursor for an entire batch and cannot reinterpret earlier clicks.
let cursor = match event {
@@ -1073,11 +981,6 @@ impl canvas::Program<Event> for StateCanvas<'_> {
state.reset();
return None;
}
canvas::Event::Window(iced::window::Event::Resized(_)) => {
state.cancel_gesture();
state.viewport = None;
return None;
}
_ => cursor,
};
let window = cursor.position();
@@ -1239,26 +1142,6 @@ impl canvas::Program<Event> for StateCanvas<'_> {
});
Some(Action::capture())
}
Tool::AdjustAttachment { relation, end } => {
let owner =
state_edit::attachment_node(self.project, self.diagram, relation, end)?;
let route = scene.routed.edges.get(&relation)?;
let tip = match end {
EdgeEnd::Source => route.points.first(),
EdgeEnd::Target => route.points.last(),
}?;
if world.distance(point(*tip)) > 10. / self.scale() {
return None;
}
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
state.press = Some(Press::Attachment {
relation,
end,
placement: owner.id,
start: window,
});
Some(Action::capture())
}
}
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
@@ -1273,9 +1156,6 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
}
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
}
Some(Action::request_redraw())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
@@ -1296,14 +1176,9 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
if let Some(window) = window {
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
}
}
let press = state.press.take()?;
state.attachment_viewport = None;
let preview = state.preview.take();
let attachment_preview = state.attachment_preview.take();
let world = local.map(|p| self.world(p));
let event = match press {
Press::Move { node, .. } => preview.map(|(_, position)| Event::Move {
@@ -1311,24 +1186,6 @@ impl canvas::Program<Event> for StateCanvas<'_> {
node,
position,
}),
Press::Attachment {
relation,
end,
placement,
..
} if inside => attachment_preview
.filter(|preview| {
preview.relation == relation
&& preview.end == end
&& preview.placement == placement
})
.map(|preview| Event::SetAttachment {
context: self.context,
relation,
end,
placement,
attachment: Some(preview.attachment),
}),
Press::Create { owner, kind, start }
if inside
&& window?.distance(start) < 3.
@@ -1425,10 +1282,6 @@ impl canvas::Program<Event> for StateCanvas<'_> {
.gesture_active(state)
.then_some(state.preview)
.flatten(),
attachment_preview: self
.gesture_active(state)
.then_some(state.attachment_preview)
.flatten(),
selected: self.selected,
zoom: self.scale(),
pan: self.pan,
@@ -1538,20 +1391,6 @@ impl canvas::Program<Event> for StateCanvas<'_> {
.with_width(2.5 / self.scale()),
);
}
if let Tool::AdjustAttachment { relation, end } = self.tool
&& let Some(route) = scene.routed.edges.get(&relation)
&& let Some(tip) = match end {
EdgeEnd::Source => route.points.first(),
EdgeEnd::Target => route.points.last(),
}
{
frame.stroke(
&Path::circle(point(*tip), 9. / self.scale()),
Stroke::default()
.with_color(style::TEAL)
.with_width(2. / self.scale()),
);
}
if let Some(at) = world
&& self.editable
{
@@ -2644,242 +2483,6 @@ mod tests {
);
}
#[test]
fn explicit_attachment_drag_is_view_only_and_one_source_history_operation() {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
for zoom in [0.5, 1., 2.] {
let mut f = fixture();
let relation =
f.project
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
let second = state_edit::create_view(&f.project, f.machine).unwrap();
let other_id = second.diagram;
f.project = prepare_command(&f.project, &second.command)
.unwrap()
.project;
let other = f.project.diagrams[&other_id].clone();
let mut canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, zoom);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let route = &scene.routed.edges[&relation];
let tip = match end {
EdgeEnd::Source => route.points.first(),
EdgeEnd::Target => route.points.last(),
}
.unwrap();
let handle = screen(&canvas, point(*tip));
motion(&canvas, &mut state, handle, handle);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
handle,
);
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
handle
)
.is_none()
);
assert!(
state.attachment_preview.is_none(),
"A click must not materialize an automatic endpoint"
);
let owner =
state_edit::attachment_node(&f.project, f.diagram, relation, end).unwrap();
let destination = screen(
&canvas,
Point::new(owner.position.x + owner.size.width * 0.25, owner.position.y),
);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
handle,
);
motion(&canvas, &mut state, destination, destination);
assert!(state.attachment_preview.is_some());
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
destination,
)
.unwrap();
assert!(matches!(event, Event::SetAttachment { end: actual, .. } if actual == end));
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
let mut document =
Document::from_project(f.project.clone(), Default::default()).unwrap();
let source = document.bundle().clone();
document.execute(plan.command).unwrap();
assert_eq!(document.project().relations, f.project.relations);
assert_eq!(document.project().diagrams[&other_id], other);
let route = &document.project().diagrams[&f.diagram].routes[0];
assert_eq!(
route.attachment(end),
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
let edited = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.bundle(), &source);
assert!(!document.undo());
assert!(document.redo());
assert_eq!(document.bundle(), &edited);
}
}
}
#[test]
fn attachment_return_to_press_restores_exact_automatic_or_fractional_original() {
for original in [
None,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.234567,
}),
] {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
let mut f = fixture();
let relation =
f.project
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
if original.is_some() {
let mut route =
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation);
route.set_attachment(end, original);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(route);
}
let canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, 1.);
let mut state = State::default();
let before = canvas.scene(&state).unwrap();
let edge = &before.routed.edges[&relation];
let tip = match end {
EdgeEnd::Source => edge.points.first(),
EdgeEnd::Target => edge.points.last(),
}
.unwrap();
let start = screen(&canvas, point(*tip));
let away = start + Vector::new(80., 40.);
motion(&canvas, &mut state, start, start);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
start,
);
motion(&canvas, &mut state, away, away);
assert!(state.attachment_preview.is_some());
motion(&canvas, &mut state, start, start);
assert!(state.attachment_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
edge.points
);
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
start
)
.is_none()
);
assert_eq!(
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation)
.attachment(end),
original
);
}
}
}
#[test]
fn attachment_drag_cancels_on_camera_context_tool_pending_and_focus_changes() {
let mut f = fixture();
let relation = f
.project
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
for cancel in 0..9 {
let mut canvas = canvas(
&f,
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source,
},
1.,
);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let handle = screen(&canvas, point(scene.routed.edges[&relation].points[0]));
let destination = handle + Vector::new(40., 30.);
motion(&canvas, &mut state, handle, handle);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
handle,
);
motion(&canvas, &mut state, destination, destination);
assert!(state.attachment_preview.is_some());
match cancel {
0 => canvas.zoom = 2.,
1 => canvas.pan.x += 10.,
2 => canvas.context.epoch += 1,
3 => canvas.tool = Tool::Select,
4 => canvas.editable = false,
5 | 6 => {
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(if cancel == 5 {
mouse::Button::Right
} else {
mouse::Button::Middle
}),
destination,
);
}
7 => {
canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds(),
mouse::Cursor::Available(destination),
);
}
_ => {
motion(&canvas, &mut state, Point::new(-100., -100.), destination);
}
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
destination
)
.is_none()
);
assert!(state.attachment_preview.is_none());
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
}
}
#[test]
fn selected_endpoint_reconnect_preserves_identity_and_requires_handle_motion() {
let mut f = fixture();
+1 -137
View File
@@ -20,7 +20,6 @@ pub enum Tool {
Region,
Transition,
Reconnect { relation: Id, endpoint: Endpoint },
AdjustAttachment { relation: Id, end: EdgeEnd },
}
#[derive(Debug, Clone)]
@@ -67,13 +66,6 @@ pub enum Event {
endpoint: Endpoint,
vertex: Id,
},
SetAttachment {
context: Context,
relation: Id,
end: EdgeEnd,
placement: Id,
attachment: Option<EdgeAttachment>,
},
}
impl Event {
@@ -92,8 +84,7 @@ impl Event {
| Self::Place { context: c, .. }
| Self::Move { context: c, .. }
| Self::Connect { context: c, .. }
| Self::Reconnect { context: c, .. }
| Self::SetAttachment { context: c, .. } => Some(*c),
| Self::Reconnect { context: c, .. } => Some(*c),
}
}
}
@@ -258,36 +249,6 @@ pub fn can_reconnect(
.is_ok()
}
/// Only the actual visible transition owner can receive a visual attachment.
/// Folded promotion, semantic ports and internal-transition rows are excluded.
pub fn attachment_node(
project: &Project,
diagram: Id,
relation: Id,
end: EdgeEnd,
) -> Option<projection::ProjectedNode> {
machine(project, diagram).ok()?;
let relation_record = project.relations.get(&relation)?;
if relation_record.kind() != RelationKind::StateTransition
|| oa_core::routing::semantic_port_endpoint(project, relation, end).is_some()
{
return None;
}
let view = projection::project_view(project, diagram);
let edge = view.edges.iter().find(|edge| edge.id == relation)?;
let stored = project.diagrams.get(&diagram)?;
if projection::edge_endpoint_placements(project, stored, relation)?
!= (edge.source, edge.target)
{
return None;
}
let owner = match end {
EdgeEnd::Source => edge.source,
EdgeEnd::Target => edge.target,
};
view.nodes.iter().find(|node| node.id == owner).cloned()
}
fn checked(project: &Project, plan: Plan) -> Result<Plan, ModelError> {
prepare_command(project, &plan.command)?;
Ok(plan)
@@ -531,22 +492,6 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan,
relation,
None,
),
Event::SetAttachment {
relation,
end,
placement,
attachment,
..
} => {
attachment_node(project, diagram, relation, end)
.filter(|owner| owner.id == placement)
.ok_or_else(|| {
error("Choose the actual visible transition endpoint in this view.")
})?;
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
route.set_attachment(end, attachment);
(Command::SetEdgeRoute { diagram, route }, relation, None)
}
_ => {
return Err(error(
"Handle this navigation or tool request without a model command.",
@@ -635,85 +580,6 @@ mod tests {
plan.diagram
}
#[test]
fn attachment_reset_preserves_other_end_pins_label_and_rejects_ineligible_owners() {
let mut f = fixture();
let diagram = create(&mut f);
let c = context(diagram);
let edge = f.project.relate(
RelationKind::StateTransition,
"Leave work",
f.leaf,
f.target,
);
let attachment = EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.3,
};
let mut route = crate::route_edit::stored_route(&f.project.diagrams[&diagram], edge);
route.source_attachment = Some(attachment);
route.target_attachment = Some(attachment);
route.waypoints.push(Position { x: 700., y: 700. });
// Retain the exact label value alongside the other route components.
route.label = Some(EdgeLabel {
fraction: 0.5,
offset: Position { x: 10., y: 20. },
});
f.project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(route.clone());
let owner = attachment_node(&f.project, diagram, edge, EdgeEnd::Source).unwrap();
let event = Event::SetAttachment {
context: c,
relation: edge,
end: EdgeEnd::Source,
placement: owner.id,
attachment: None,
};
let accepted = prepare_command(&f.project, &plan(&f.project, c, &event).unwrap().command)
.unwrap()
.project;
route.source_attachment = None;
assert_eq!(accepted.diagrams[&diagram].routes, vec![route]);
assert_eq!(accepted.relations, f.project.relations);
let wrong = Event::SetAttachment {
context: c,
relation: edge,
end: EdgeEnd::Source,
placement: node(&f.project, diagram, f.target),
attachment: Some(attachment),
};
assert!(plan(&f.project, c, &wrong).is_err());
let composite = node(&f.project, diagram, f.composite);
f.project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&composite)
.unwrap()
.collapsed = true;
assert!(attachment_node(&f.project, diagram, edge, EdgeEnd::Source).is_none());
assert!(plan(&f.project, c, &event).is_err());
let internal = f.project.relate(
RelationKind::StateTransition,
"Internal",
f.target,
f.target,
);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&internal).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Source).is_none());
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Target).is_none());
}
#[test]
fn view_initializes_only_its_machine_in_one_source_and_model_history_entry() {
let f = fixture();
@@ -897,8 +763,6 @@ mod tests {
.routes
.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pin],
label: None,
});
@@ -178,44 +178,6 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
visible,
));
}
for (caption, reset, end) in [
(
"Adjust source attachment",
"Reset source attachment",
EdgeEnd::Source,
),
(
"Adjust target attachment",
"Reset target attachment",
EdgeEnd::Target,
),
] {
let owner = state_edit::attachment_node(project, context.diagram, id, end);
entries.push(item(
caption,
diagram_event(Event::Choose(Tool::AdjustAttachment { relation: id, end })),
owner.is_some(),
));
if let Some(owner) = owner {
let authored = project.diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == id)
.and_then(|route| route.attachment(end))
.is_some();
entries.push(item(
reset,
diagram_event(Event::SetAttachment {
context,
relation: id,
end,
placement: owner.id,
attachment: None,
}),
authored,
));
}
}
if !visible {
entries.push(Entry::title(
"Use transition details for hidden or internal endpoints",
+2 -160
View File
@@ -22,9 +22,6 @@ use std::{
sync::Arc,
};
#[cfg(test)]
#[path = "diagram/attachment_tests.rs"]
mod attachment_tests;
#[cfg(test)]
#[path = "diagram/lifecycle_tests.rs"]
mod lifecycle_tests;
#[cfg(test)]
@@ -56,7 +53,6 @@ pub struct DiagramCanvas<'a> {
pub pan: Vector,
pub context: Context,
pub selected_bend: Option<usize>,
pub selected_attachment: Option<EdgeEnd>,
pub editable: bool,
pub placing: bool,
pub connection: Option<&'a crate::connection_edit::Session>,
@@ -71,7 +67,6 @@ pub struct State {
pan_start: Option<(mouse::Button, Point)>,
context: Option<Context>,
route_drag: Option<Gesture>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
connection_press: Option<(Id, Point)>,
connection_hover: Option<Point>,
port_drag: Option<crate::port_edit::Gesture>,
@@ -299,11 +294,6 @@ impl DiagramCanvas<'_> {
);
request.canvas = Some(self.context);
request.creation_point = Some(position(world));
if self.attachment_at(&scene, world).is_some() {
request.object = self.selected;
request.route_point = Some(position(world));
return request;
}
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
&& let Some(index) = edge
@@ -381,39 +371,6 @@ impl DiagramCanvas<'_> {
})
.map(|(id, segment, _)| (id, segment))
}
fn attachment_at<'b>(
&self,
scene: &'b Scene,
world: Point,
) -> Option<(EdgeEnd, &'b ProjectedNode)> {
let edge = scene.routed.edges.get(&self.selected)?;
if edge.custom_suspended {
return None;
}
[
(EdgeEnd::Source, edge.points.first()),
(EdgeEnd::Target, edge.points.last()),
]
.into_iter()
.filter_map(|(end, at)| {
let distance = world.distance(point(*at?));
let owner = route_edit::attachment_node(
self.project,
&self.project.diagrams[&self.diagram],
&scene.view,
self.selected,
end,
)?;
(distance <= 8. / self.zoom).then_some((end, owner, distance))
})
.min_by(|a, b| {
a.2.total_cmp(&b.2).then_with(|| {
(Some(a.0) != self.selected_attachment)
.cmp(&(Some(b.0) != self.selected_attachment))
})
})
.map(|(end, owner, _)| (end, owner))
}
fn hit_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
let p = self.world(p);
routed
@@ -604,24 +561,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
// An attachment belongs to the boundary geometry captured at press.
// Camera/layout changes must not reinterpret its pointer in another space.
if matches!(
state.route_drag.as_ref().map(|gesture| &gesture.handle),
Some(Handle::Attachment { .. })
) && (state.attachment_viewport != Some((self.zoom, self.pan, bounds))
|| matches!(
event,
canvas::Event::Window(
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
) | canvas::Event::Mouse(mouse::Event::CursorLeft)
))
{
state.route_drag = None;
state.attachment_viewport = None;
state.pointer = None;
return Some(Action::request_redraw());
}
// A completed work click retires App's allocation epoch before the
// retained canvas sees a redraw. Its already-idle press must not eat
// the first Delete. A live or not-yet-dispatched press stays guarded.
@@ -901,21 +840,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
&self.project.diagrams[&self.diagram],
self.selected,
);
if let Some((end, owner)) = self.attachment_at(&scene, world) {
let original = route.attachment(end);
self.begin_route(
state,
world,
Handle::Attachment {
end,
owner: owner.clone(),
original,
},
route,
);
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
return Some(Action::capture());
}
if let Some(index) = edge
.pins
.iter()
@@ -1039,7 +963,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::request_redraw());
}
if let Some(gesture) = &mut state.route_drag {
gesture.move_attachment_to(self.world(p), self.zoom);
gesture.move_to(self.world(p), self.zoom);
return Some(Action::request_redraw().and_capture());
}
if let Some((_, start, original)) = state.drag {
@@ -1116,43 +1040,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::capture());
}
if let Some(mut gesture) = state.route_drag.take() {
if matches!(gesture.handle, Handle::Attachment { .. }) {
state.attachment_viewport = None;
if !cursor.is_over(bounds) {
return Some(Action::request_redraw().and_capture());
}
}
// `local` uses the ordered pointer position at release,
// before any later motion in the same native event batch.
if let Some(p) = local {
gesture.move_attachment_to(self.world(p), self.zoom);
gesture.move_to(self.world(p), self.zoom);
}
if !gesture.changed {
return Some(Action::request_redraw().and_capture());
}
if let Handle::Attachment { end, owner, .. } = &gesture.handle {
if gesture.route
== route_edit::stored_route(
&self.project.diagrams[&self.diagram],
self.selected,
)
{
return Some(Action::request_redraw().and_capture());
}
return Some(
Action::publish(Message::Route(RouteEvent::CommitAttachment(
gesture.context,
gesture.route,
*end,
owner.id,
)))
.and_capture(),
);
}
let bend = match gesture.handle {
Handle::Bend(index) => Some(index),
Handle::Label { .. } => None,
Handle::Attachment { .. } => unreachable!(),
};
return Some(
Action::publish(Message::Route(RouteEvent::Commit(
@@ -1196,15 +1094,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta })
if cursor.is_over(bounds) =>
{
// Cancel before publishing Zoom: a release may arrive in this
// same event batch, before App applies the new camera.
if matches!(
state.route_drag.as_ref().map(|gesture| &gesture.handle),
Some(Handle::Attachment { .. })
) {
state.route_drag = None;
state.attachment_viewport = None;
}
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
*y
@@ -1447,41 +1336,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
{
for (end, at) in [
(EdgeEnd::Source, edge.points.first()),
(EdgeEnd::Target, edge.points.last()),
] {
if let Some(at) = at
&& route_edit::attachment_node(
project,
&project.diagrams[&self.diagram],
view,
self.selected,
end,
)
.is_some()
{
let at = point(*at);
let handle = Path::rectangle(
at - Vector::new(5.5 / self.zoom, 5.5 / self.zoom),
Size::new(11. / self.zoom, 11. / self.zoom),
);
f.fill(
&handle,
if self.selected_attachment == Some(end) {
style::TEAL
} else {
Color::WHITE
},
);
f.stroke(
&handle,
Stroke::default()
.with_color(style::TEAL)
.with_width(1.7 / self.zoom),
);
}
}
for (index, pin) in edge.pins.iter().enumerate() {
let handle = Path::circle(point(*pin), 5.5 / self.zoom);
f.fill(
@@ -2188,7 +2042,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2265,7 +2118,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2391,7 +2243,6 @@ mod tests {
selected: p.id,
navigation_focus: None,
selected_bend: None,
selected_attachment: None,
zoom,
pan: Vector::new(20., 30.),
editable: true,
@@ -2529,8 +2380,6 @@ mod tests {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
@@ -2573,7 +2422,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2651,7 +2499,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2750,7 +2597,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2880,7 +2726,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2936,7 +2781,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -3012,7 +2856,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -3047,7 +2890,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -1,446 +0,0 @@
use super::*;
use crate::app::{App, Page};
use iced::widget::canvas::Program;
fn fixture() -> App {
let mut p = Project::empty();
let a = p.add(Kind::Activity, "Send", 100., 150.);
let b = p.add(Kind::Activity, "Receive", 650., 150.);
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![Position { x: 450., y: 350. }],
label: Some(EdgeLabel {
fraction: 0.4,
offset: Position { x: 5., y: 10. },
}),
source_attachment: None,
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.6,
}),
});
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.active_diagram = Some(diagram);
let _ = app.update(Message::Select(edge));
app
}
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
allocation: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom,
pan: Vector::new(40., 50.),
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: app
.selected_attachment
.filter(|(c, e, _)| Some(*c) == app.route_context() && *e == app.selected)
.map(|(_, _, end)| end),
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
}
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
}
fn screen(c: &DiagramCanvas<'_>, p: Position) -> Point {
Point::new(
bounds().x + p.x * c.zoom + c.pan.x,
bounds().y + p.y * c.zoom + c.pan.y,
)
}
fn send(
c: &DiagramCanvas<'_>,
state: &mut State,
event: mouse::Event,
final_cursor: Point,
) -> Option<Message> {
c.update(
state,
&canvas::Event::Mouse(event),
bounds(),
mouse::Cursor::Available(final_cursor),
)
.and_then(|a| a.into_inner().0)
}
fn positions(c: &DiagramCanvas<'_>) -> (Point, Point) {
let scene = c.scene(&State::default());
let start = *scene.routed.edges[&c.selected].points.first().unwrap();
let node = route_edit::attachment_node(
c.project,
&c.project.diagrams[&c.diagram],
&scene.view,
c.selected,
EdgeEnd::Source,
)
.unwrap();
let end = oa_core::routing::attachment_point(
c.project,
&c.project.diagrams[&c.diagram],
node,
&EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
},
);
(screen(c, start), screen(c, end))
}
fn drag(app: &App, zoom: f32) -> Message {
let c = canvas(app, zoom);
let (start, end) = positions(&c);
let mut state = State::default();
let messages = [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Left),
]
.into_iter()
.filter_map(|e| send(&c, &mut state, e, end))
.collect::<Vec<_>>();
assert_eq!(messages.len(), 1);
messages.into_iter().next().unwrap()
}
#[test]
fn attachment_event_batches_commit_one_source_history_change_at_three_zooms() {
for zoom in [0.4, 1., 2.] {
let mut app = fixture();
let before = app.document.bundle().clone();
let project = app.project().clone();
let revision = app.document.revision();
let message = drag(&app, zoom);
let Message::Route(RouteEvent::CommitAttachment(_, ref route, EdgeEnd::Source, _)) =
message
else {
panic!("attachment commit required")
};
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
let intended = route.clone();
let _ = app.update(message);
assert_eq!(app.document.revision(), revision + 1);
let mut expected = project;
route_edit::with_route(
expected
.diagrams
.get_mut(&app.active_diagram.unwrap())
.unwrap(),
intended,
);
assert_eq!(
*app.project(),
expected,
"only the intended view attachment changes"
);
let after = app.document.bundle().clone();
assert_eq!(
oa_core::source_project::compile_bundle(&after, Default::default())
.unwrap()
.decoded
.project,
expected
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
}
}
#[test]
fn attachment_stationary_return_and_escape_leave_source_unchanged() {
let mut app = fixture();
let before = app.document.bundle().clone();
let c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
assert!(send(&c, &mut state, event, start).is_none());
}
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
let _ = app.update(Message::Escape);
assert!(
send(
&canvas(&app, 1.),
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn attachment_camera_layout_focus_and_outside_release_cancel_after_motion() {
for cancel in 0..8 {
let app = fixture();
let before = app.document.bundle().clone();
let mut c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
assert!(state.route_drag.as_ref().is_some_and(|g| g.changed));
let mut current_bounds = bounds();
let mut release = end;
match cancel {
0 => c.zoom = 2.,
1 => c.pan.x += 20.,
2 => current_bounds.x += 20.,
3 => current_bounds.height -= 100.,
4..=6 => {
let event = match cancel {
4 => canvas::Event::Window(iced::window::Event::Unfocused),
5 => canvas::Event::Window(iced::window::Event::Resized(Size::new(800., 600.))),
_ => canvas::Event::Mouse(mouse::Event::CursorLeft),
};
let message = c
.update(
&mut state,
&event,
current_bounds,
mouse::Cursor::Available(end),
)
.and_then(|a| a.into_inner().0);
assert!(message.is_none());
}
_ => {
release = Point::new(-100., -100.);
assert!(
send(
&c,
&mut state,
mouse::Event::CursorMoved { position: release },
release
)
.is_none()
);
}
}
let message = c
.update(
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
current_bounds,
mouse::Cursor::Available(release),
)
.and_then(|a| a.into_inner().0);
assert!(message.is_none(), "cancellation case {cancel}");
assert!(state.route_drag.is_none(), "cancellation case {cancel}");
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn attachment_wheel_and_middle_pan_cancel_before_same_batch_left_release() {
for wheel in [true, false] {
let mut app = fixture();
let before = app.document.bundle().clone();
let revision = app.document.revision();
let c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
let camera_message = send(
&c,
&mut state,
if wheel {
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
}
} else {
mouse::Event::ButtonPressed(mouse::Button::Middle)
},
end,
);
assert_eq!(camera_message.is_some(), wheel);
assert!(state.route_drag.is_none());
// Deliberately retain the same canvas and defer App camera dispatch.
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
if !wheel {
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Middle),
end
)
.is_none()
);
}
if let Some(message) = camera_message {
assert!(matches!(message, Message::Zoom(_)));
let _ = app.update(message);
}
assert_eq!(app.document.revision(), revision);
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn attachment_fast_release_uses_release_position_and_stays_in_one_view() {
let mut app = fixture();
let original = app.active_diagram.unwrap();
let _ = app.update(Message::DuplicateDiagram(original));
let other = app.active_diagram.unwrap();
assert_ne!(original, other);
app.active_diagram = Some(original);
let edge = app.project().diagrams[&original].routes[0].edge;
let _ = app.update(Message::Select(edge));
let untouched = app.project().diagrams[&other].clone();
let c = canvas(&app, 0.7);
let (start, end) = positions(&c);
let mut state = State::default();
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
start
)
.is_none()
);
let message = send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
)
.expect("release completes drag without final move");
let _ = app.update(message);
assert_eq!(
app.project().diagrams[&original].routes[0].source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
assert_eq!(app.project().diagrams[&other], untouched);
}
#[test]
fn attachment_stale_context_wrong_placement_and_unowned_route_fields_are_rejected() {
let mut app = fixture();
let before = app.document.bundle().clone();
let message = drag(&app, 1.);
let Message::Route(RouteEvent::CommitAttachment(context, route, end, node)) = message else {
panic!()
};
let mut changed = route.clone();
changed.waypoints.clear();
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context, changed, end, node,
)));
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context,
route.clone(),
end,
Id::new_v4(),
)));
let _ = app.update(Message::Escape);
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context, route, end, node,
)));
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn attachment_reset_preserves_opposite_end_pins_label_and_semantics() {
let mut app = fixture();
let before = app.project().clone();
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
let mut expected = before;
expected
.diagrams
.get_mut(&app.active_diagram.unwrap())
.unwrap()
.routes[0]
.target_attachment = None;
assert_eq!(*app.project(), expected);
let revision = app.document.revision();
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
assert_eq!(app.document.revision(), revision);
}
#[test]
fn coincident_attachment_handles_honor_explicit_end_choice() {
let mut app = fixture();
let _ = app.update(Message::Route(RouteEvent::ChooseAttachment(
EdgeEnd::Target,
)));
let c = canvas(&app, 1.);
let mut scene = Scene {
work: None,
view: projection::project_view(c.project, c.diagram),
routed: Arc::new(route_diagram(
c.project,
&c.project.diagrams[&c.diagram],
&projection::project_view(c.project, c.diagram),
)),
};
let routed = Arc::make_mut(&mut scene.routed);
let edge = routed.edges.get_mut(&c.selected).unwrap();
let start = *edge.points.first().unwrap();
*edge.points.last_mut().unwrap() = start;
assert_eq!(
c.attachment_at(&scene, point(start)).unwrap().0,
EdgeEnd::Target
);
let mut source = c;
source.selected_attachment = Some(EdgeEnd::Source);
assert_eq!(
source.attachment_at(&scene, point(start)).unwrap().0,
EdgeEnd::Source
);
}
@@ -62,7 +62,6 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: app.connection.as_ref(),
@@ -70,7 +70,6 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: app.connection.as_ref(),
@@ -16,7 +16,6 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -186,7 +186,6 @@ mod tests {
pan: Vector::ZERO,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
+2 -2
View File
@@ -344,7 +344,7 @@ fn article(topic: Topic) -> Article {
&[
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
"Select a connection and drag a square endpoint handle along its element boundary to move its attachment in this view without changing what it connects. If handles overlap, right-click the selected connection or use its Actions menu and choose Move source attachment or Move target attachment, then drag the handle. Connections to semantic ports retain their port attachment; move the port instead. Reset source attachment or Reset target attachment restores that end's automatic placement while preserving the other end, pins and label. Reset routing clears all these route overrides in this view. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
],
),
explain(
@@ -564,7 +564,7 @@ fn article(topic: Topic) -> Article {
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex. To change only where a line touches its existing vertex, choose Adjust source attachment or Adjust target attachment, then drag the highlighted tip along the boundary. Reset source attachment or Reset target attachment restores automatic placement for that end without changing the transition's meaning or other route settings. Escape cancels an attachment drag.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
],
),
-57
View File
@@ -240,33 +240,6 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
.is_none_or(|(s, t)| s == e.source && t == e.target)
});
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
if let Some((diagram, view)) = diagram.zip(view.as_ref()) {
for (end, choose, reset) in [
(
EdgeEnd::Source,
"Move source attachment",
"Reset source attachment",
),
(
EdgeEnd::Target,
"Move target attachment",
"Reset target attachment",
),
] {
let eligible = editable
&& route_edit::attachment_node(p, diagram, view, id, end).is_some();
entries.push(item(
choose,
Message::Route(route_edit::Event::ChooseAttachment(end)),
eligible,
));
entries.push(item(
reset,
Message::Route(route_edit::Event::ResetAttachment(end)),
eligible && route.is_some_and(|route| route.attachment(end).is_some()),
));
}
}
if let Some(point) = request.route_point {
entries.push(
item(
@@ -927,36 +900,6 @@ mod tests {
assert_eq!(app.document.bundle(), &changed);
}
#[test]
fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() {
let mut project = Project::empty();
let source = project.add(Kind::Activity, "Send", 0., 0.);
let target = project.add(Kind::Activity, "Receive", 650., 0.);
let edge = project.relate(RelationKind::Interaction, "Report", source, target);
let mut app = App::with_project(project);
let before = app.document.bundle().clone();
let mut request = Request::object(edge, Point::ORIGIN);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request.clone())));
let action = choose(&app, "Move target attachment");
send(&mut app, action);
assert_eq!(
app.selected_attachment,
Some((app.route_context().unwrap(), edge, EdgeEnd::Target))
);
assert_eq!(app.document.bundle(), &before);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request)));
let stale = choose(&app, "Move source attachment");
send(&mut app, Message::Escape);
send(&mut app, stale);
assert!(
app.selected_attachment
.is_none_or(|(context, _, _)| Some(context) != app.route_context())
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
let mut p = Project::empty();
+1 -62
View File
@@ -14,9 +14,6 @@ pub struct Context {
pub enum Event {
SelectBend(Id, usize),
Commit(Context, EdgeRoute, Option<usize>),
CommitAttachment(Context, EdgeRoute, EdgeEnd, Id),
ChooseAttachment(EdgeEnd),
ResetAttachment(EdgeEnd),
AddBend,
AddBendAt(oa_core::Position),
RemoveBend,
@@ -26,14 +23,7 @@ pub enum Event {
#[derive(Debug, Clone)]
pub enum Handle {
Bend(usize),
Label {
original: Position,
},
Attachment {
end: EdgeEnd,
owner: projection::ProjectedNode,
original: Option<EdgeAttachment>,
},
Label { original: Position },
}
#[derive(Debug, Clone)]
@@ -68,57 +58,8 @@ impl Gesture {
};
}
}
Handle::Attachment { .. } => {}
}
}
pub fn move_attachment_to(&mut self, point: Point, zoom: f32) {
let Handle::Attachment {
end,
ref owner,
original,
} = self.handle
else {
self.move_to(point, zoom);
return;
};
if point.distance(self.start) * zoom < 3. {
self.route.set_attachment(end, original);
self.changed = false;
return;
}
self.changed = true;
let attachment = attachment_from_point(
owner,
position(point),
self.route.attachment(end).map(|a| a.side),
6. / zoom,
);
self.route.set_attachment(end, Some(attachment));
}
}
/// The painted endpoint must still be the actual unported placement, not a folded proxy.
pub fn attachment_node<'a>(
project: &Project,
diagram: &Diagram,
view: &'a projection::ProjectedView,
edge: Id,
end: EdgeEnd,
) -> Option<&'a projection::ProjectedNode> {
if semantic_port_endpoint(project, edge, end).is_some() {
return None;
}
let projected = view.edges.iter().find(|e| e.id == edge)?;
let actual = projection::edge_endpoint_placements(project, diagram, edge)?;
if actual != (projected.source, projected.target) {
return None;
}
let placement = match end {
EdgeEnd::Source => projected.source,
EdgeEnd::Target => projected.target,
};
view.nodes.iter().find(|node| node.id == placement)
}
pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
@@ -131,8 +72,6 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
edge,
waypoints: Vec::new(),
label: None,
source_attachment: None,
target_attachment: None,
})
}
-2
View File
@@ -329,8 +329,6 @@ fn reopening_canvas_for_a_modal_preserves_viewport_after_route_edits() {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: 400. }],
label: Some(EdgeLabel {
fraction: 0.5,
-2
View File
@@ -112,8 +112,6 @@ mod tests {
});
view.routes.push(EdgeRoute {
edge: exchange,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 420., y: 440. }],
label: Some(EdgeLabel {
fraction: 0.4,
-6
View File
@@ -466,12 +466,6 @@ fn diagram(app: &App) -> View<'_, Message> {
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_attachment: app
.selected_attachment
.filter(|(context, edge, _)| {
Some(*context) == app.route_context() && *edge == app.selected
})
.map(|(_, _, end)| end),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
+88 -58
View File
@@ -13,6 +13,7 @@ use iced::{
use oa_core::{transitions::*, *};
use std::collections::BTreeSet;
mod communication;
mod counterparts;
pub(crate) mod recovery;
#[cfg(test)]
mod transition_tests;
@@ -34,6 +35,7 @@ pub enum Event {
Scope(String),
Destination(Choice),
MappingTarget(Id, Choice),
MappingQuery(Id, String),
MappingKind(Id, Kind),
CommunicationPort(Id, bool, communication::PortMenuChoice),
OperationOmission(Id, bool),
@@ -274,6 +276,7 @@ pub struct Transition {
pub request: TransitionRequest,
pub plan: TransitionPlan,
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
counterpart_queries: std::collections::BTreeMap<Id, String>,
}
impl Transition {
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
@@ -515,6 +518,7 @@ impl Workflow {
request,
plan,
communication_ports: Default::default(),
counterpart_queries: Default::default(),
})))
}
/// Return a complete accepted candidate only on explicit Apply. Intermediate
@@ -619,6 +623,12 @@ impl Workflow {
_ => {}
},
Self::Transition(d) => {
if let Event::MappingQuery(source, query) = event {
d.counterpart_queries.insert(source, query);
// Searching is presentation only: preserve reviewed decisions,
// preview identities and the exact recovery snapshot.
return Ok(None);
}
match &event {
Event::OperationOmission(source, enabled) => {
d.request
@@ -1148,48 +1158,17 @@ impl Workflow {
content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11));
}
let mapping = d.request.mappings.iter().find(|m| m.source == source);
let existing = p
.traces
.values()
.filter_map(|t| {
if d.request.direction == TransitionDirection::OaToSa && t.oa == source
{
Some(t.sa)
} else if d.request.direction == TransitionDirection::SaToOa
&& t.sa == source
{
Some(t.oa)
} else {
None
}
})
.collect::<Vec<_>>();
let target = mapping.map(|m| m.target).unwrap_or_else(|| {
if existing.len() == 1 {
Some(existing[0])
} else {
None
}
let target_choice = counterparts::target(p, source, &d.request);
let target = target_choice.flatten();
let query = d
.counterpart_queries
.get(&source)
.map_or("", String::as_str);
let targets = counterparts::choices(p, source, d.request.direction, query);
let no_matches = targets.len() == 1;
let selected = target_choice.map(|target| {
counterparts::selected(p, source, d.request.direction, target)
});
let mut targets = vec![Choice {
id: Id::nil(),
label: "Create new counterpart".into(),
}];
targets.extend(
p.elements
.keys()
.chain(p.relations.keys())
.filter(|id| {
p.layer_of(**id) == Some(d.request.direction.target_layer())
&& p.elements.contains_key(&source)
== p.elements.contains_key(id)
})
.map(|id| choice(p, *id)),
);
let selected = targets
.iter()
.find(|c| Some(c.id) == target.or(Some(Id::nil())))
.cloned();
let mut item = column![
text(format!(
"{} · {}",
@@ -1200,14 +1179,29 @@ impl Workflow {
.unwrap_or("Relationship")
))
.size(14),
text_input("Find counterpart by name or @alias", query)
.on_input(move |query| Message::Workflow(Event::MappingQuery(
source, query
)))
.size(12)
.padding(8),
pick_list(targets, selected, move |v| Message::Workflow(
Event::MappingTarget(source, v)
))
.placeholder("Choose a counterpart")
.text_size(12)
.padding(8)
.width(Fill)
]
.spacing(7);
if no_matches {
let message = if query.trim().is_empty() {
"No compatible existing counterparts. Create a new one."
} else {
"No matching existing counterparts. Clear the search or create a new one."
};
item = item.push(text(message).size(11));
}
if target.is_none()
&& let Some(e) = p.elements.get(&source)
{
@@ -1413,16 +1407,6 @@ impl Workflow {
.color(style::MUTED),
);
}
content = content.push(
text(format!(
"{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings",
d.plan.changes.len(),
d.plan.conflicts.iter().filter(|c| d.boundary_choice(p, c)).count(),
d.plan.conflicts.iter().filter(|c| !d.boundary_choice(p, c)).count(),
d.plan.warnings.len()
))
.size(12),
);
}
}
let enabled = match self {
@@ -1439,12 +1423,50 @@ impl Workflow {
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
column![
scrollable(content.padding(iced::Padding {
right: 14.,
..Default::default()
}))
.height(480),
let mut footer = column![].spacing(8);
if let Self::Transition(d) = self {
let boundary_choices = d
.plan
.conflicts
.iter()
.filter(|c| d.boundary_choice(p, c))
.count();
footer = footer.push(
text(format!(
"{} reviewed items · {} boundary choices · {} conflicts · {} warnings",
d.plan.changes.len(),
boundary_choices,
d.plan.conflicts.len() - boundary_choices,
d.plan.warnings.len()
))
.size(12),
);
let guidance = if d.destination_error(p).is_some() {
Some("Choose a compatible diagram in Show result in, above the mappings.".into())
} else if boundary_choices > 0 {
Some("Choose the counterpart kind in each mapping that needs a system boundary decision.".into())
} else if let Some(field) = d.plan.conflicts.iter().find_map(|c| c.field) {
let field = match field {
TransitionField::Name => "Name",
TransitionField::Description => "Description",
};
Some(format!(
"Review {field} below the mappings: choose Keep target, Take source, or merged text."
))
} else if !d.plan.conflicts.is_empty() {
Some("Review the conflict messages below the mappings and complete the required decisions.".into())
} else {
None
};
if let Some(guidance) = guidance {
footer = footer.push(
text(guidance)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
}
footer = footer.push(
row![
Space::new().width(Fill),
button(text("Cancel").size(12))
@@ -1464,7 +1486,15 @@ impl Workflow {
.padding(10)
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
]
.spacing(10)
.spacing(10),
);
column![
scrollable(content.padding(iced::Padding {
right: 14.,
..Default::default()
}))
.height(480),
footer
]
.spacing(18)
.into()
@@ -0,0 +1,315 @@
//! Search is separate from the committed counterpart mapping.
use super::*;
/// Outer None requires a choice between distinct traced targets; inner None
/// means creation (explicit, or no existing correspondence).
pub(super) fn target(p: &Project, source: Id, request: &TransitionRequest) -> Option<Option<Id>> {
if let Some(mapping) = request.mappings.iter().find(|m| m.source == source) {
return Some(mapping.target);
}
let existing = p
.traces
.values()
.filter_map(|trace| match request.direction {
TransitionDirection::OaToSa if trace.oa == source => Some(trace.sa),
TransitionDirection::SaToOa if trace.sa == source => Some(trace.oa),
_ => None,
})
.collect::<BTreeSet<_>>();
match existing.len() {
0 => Some(None),
1 => Some(existing.first().copied()),
_ => None,
}
}
fn new_choice() -> Choice {
Choice {
id: Id::nil(),
label: "Create new counterpart".into(),
}
}
fn identity(p: &Project, id: Id) -> Option<(&str, &str, String)> {
if let Some(e) = p.elements.get(&id) {
Some((&e.name, &e.alias, e.kind().label().into()))
} else {
p.relations
.get(&id)
.map(|r| (r.name.as_str(), r.alias.as_str(), r.kind().to_string()))
}
}
fn label(p: &Project, id: Id) -> Choice {
let label = match identity(p, id) {
Some((name, alias, kind)) => format!("{} · {kind} · @{alias}", short(name, 48)),
None => format!("Missing counterpart · {id}"),
};
Choice { id, label }
}
pub(super) fn selected(
p: &Project,
source: Id,
direction: TransitionDirection,
target: Option<Id>,
) -> Choice {
let Some(target) = target else {
return new_choice();
};
let mut choice = label(p, target);
if p.contains(target) && !counterpart_compatible(p, source, target, direction) {
choice.label.push_str(" (incompatible)");
}
choice
}
pub(super) fn choices(
p: &Project,
source: Id,
direction: TransitionDirection,
query: &str,
) -> Vec<Choice> {
let query = query.trim().to_lowercase();
let mut candidates = p
.elements
.keys()
.chain(p.relations.keys())
.copied()
.filter(|id| counterpart_compatible(p, source, *id, direction))
.filter_map(|id| {
let (name, alias, _) = identity(p, id)?;
let name = name.to_lowercase();
let alias = alias.to_lowercase();
let matches = if let Some(query) = query.strip_prefix('@') {
alias.contains(query)
} else {
name.contains(&query) || alias.contains(&query)
};
matches.then_some((name, alias, id))
})
.collect::<Vec<_>>();
candidates.sort();
std::iter::once(new_choice())
.chain(candidates.into_iter().map(|(_, _, id)| label(p, id)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let source = p
.add_element(Kind::Activity, Layer::Oa, "Assess pressure", None)
.unwrap();
let first = p
.add_element(Kind::Function, Layer::Sa, "Assess pressure", None)
.unwrap();
let second = p
.add_element(Kind::Function, Layer::Sa, "Assess pressure", None)
.unwrap();
let incompatible = p
.add_element(Kind::SystemActor, Layer::Sa, "Assess pressure", None)
.unwrap();
p.elements.get_mut(&first).unwrap().alias = "alpha_assess".into();
p.elements.get_mut(&second).unwrap().alias = "beta_assess".into();
(p, source, first, second, incompatible)
}
fn ids(choices: Vec<Choice>) -> Vec<Id> {
choices.into_iter().map(|c| c.id).collect()
}
#[test]
fn search_disambiguates_names_with_aliases_and_retains_creation() {
let (p, source, first, second, _) = fixture();
let all = choices(&p, source, TransitionDirection::OaToSa, "");
assert_eq!(
all.iter().map(|c| c.id).collect::<Vec<_>>(),
vec![Id::nil(), first, second]
);
assert!(all[1].label.contains("@alpha_assess"));
assert!(all[2].label.contains("@beta_assess"));
assert_ne!(all[1].label, all[2].label);
for (query, expected) in [
(" ASSESS PRESSURE ", vec![Id::nil(), first, second]),
(" @BETA_ ", vec![Id::nil(), second]),
("ALPHA", vec![Id::nil(), first]),
("@pressure", vec![Id::nil()]),
("no match", vec![Id::nil()]),
] {
assert_eq!(
ids(choices(&p, source, TransitionDirection::OaToSa, query)),
expected,
"{query}"
);
}
assert_eq!(
ids(choices(&p, first, TransitionDirection::SaToOa, "PRESSURE")),
vec![Id::nil(), source]
);
}
#[test]
fn query_preserves_reviewed_request_plan_ids_and_recovery_exactly() {
let (mut p, source, first, _, _) = fixture();
let second = p
.add_element(Kind::Activity, Layer::Oa, "Second source card", None)
.unwrap();
let before_project = p.clone();
// Exercise both an existing reviewed target and generated preview IDs.
for chosen in [Some(first), None] {
let mut w = Workflow::transition(&p, source).unwrap();
let Workflow::Transition(t) = &mut w else {
panic!()
};
t.request.sources.push(second);
t.plan = plan_transition(&p, &t.request).unwrap();
w.update(
&p,
Event::MappingTarget(
source,
selected(&p, source, TransitionDirection::OaToSa, chosen),
),
)
.unwrap();
w.update(
&p,
Event::Resolve(
source,
TransitionField::Name,
FieldDecision::Value("Reviewed name".into()),
),
)
.unwrap();
let saved = w.recovery_snapshot().unwrap();
let Workflow::Transition(t) = &w else {
panic!()
};
let plan = t.plan.clone();
let request = t.request.clone();
for query in ["assess", "@beta", "no match", ""] {
assert!(
w.update(&p, Event::MappingQuery(source, query.into()))
.unwrap()
.is_none()
);
assert_eq!(w.recovery_snapshot().unwrap(), saved);
let Workflow::Transition(t) = &w else {
panic!()
};
assert_eq!(t.plan, plan);
assert_eq!(t.request, request);
assert_eq!(t.counterpart_queries[&source], query);
assert_eq!(target(&p, source, &t.request), Some(chosen));
assert_eq!(
selected(&p, source, t.request.direction, chosen).id,
chosen.unwrap_or(Id::nil())
);
}
w.update(&p, Event::MappingQuery(second, "other card".into()))
.unwrap();
let Workflow::Transition(t) = &w else {
panic!()
};
assert_eq!(t.counterpart_queries[&source], "");
assert_eq!(t.counterpart_queries[&second], "other card");
assert_eq!(t.plan, plan);
let restored = Workflow::from_recovery(&p, saved.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
let Workflow::Transition(t) = restored else {
panic!()
};
assert!(t.counterpart_queries.is_empty());
assert_eq!(t.request, request);
}
assert_eq!(p, before_project);
}
#[test]
fn recovered_invalid_or_missing_mapping_stays_visible_and_refuses_apply() {
let (p, source, _, _, incompatible) = fixture();
for chosen in [incompatible, Id::from_u128(987654321)] {
let mut w = Workflow::transition(&p, source).unwrap();
w.update(
&p,
Event::MappingTarget(
source,
Choice {
id: chosen,
label: "Recovered choice".into(),
},
),
)
.unwrap();
let saved = w.recovery_snapshot().unwrap();
let mut restored = Workflow::from_recovery(&p, saved.clone()).unwrap();
restored
.update(&p, Event::MappingQuery(source, "no match".into()))
.unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
let Workflow::Transition(t) = &restored else {
panic!()
};
assert_eq!(target(&p, source, &t.request), Some(Some(chosen)));
let display = selected(&p, source, t.request.direction, Some(chosen));
assert_eq!(display.id, chosen);
assert!(display.label.contains(if chosen == incompatible {
"incompatible"
} else {
"Missing counterpart"
}));
assert!(
!choices(&p, source, t.request.direction, "")
.iter()
.any(|c| c.id == chosen)
);
assert!(restored.update(&p, Event::Apply).is_err());
}
}
#[test]
fn trace_inference_counts_distinct_targets_and_explicit_mapping_wins() {
let (mut p, source, first, second, _) = fixture();
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
assert_eq!(target(&p, source, &request), Some(None));
for (i, counterpart) in [first, first, second].into_iter().enumerate() {
let id = Id::from_u128(100 + i as u128);
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{i}"),
name: format!("Trace {i}"),
description: String::new(),
oa: source,
sa: counterpart,
kind: TraceKind::Manual,
baseline: None,
},
);
assert_eq!(
target(&p, source, &request),
if i < 2 { Some(Some(first)) } else { None }
);
}
assert_eq!(
target(
&p,
first,
&TransitionRequest::new(TransitionDirection::SaToOa, vec![first])
),
Some(Some(source))
);
for chosen in [Some(second), None] {
request.mappings = vec![TransitionMapping {
source,
target: chosen,
target_kind: None,
}];
assert_eq!(target(&p, source, &request), Some(chosen));
}
}
}
@@ -202,6 +202,7 @@ impl Workflow {
request,
plan,
communication_ports,
counterpart_queries: Default::default(),
};
transition.sync_communication_ports();
transition.plan =
@@ -234,8 +234,6 @@ fn basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_e
.routes
.push(EdgeRoute {
edge: trace,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 301., y: 507. }],
label: Some(EdgeLabel {
fraction: 0.35,
-2
View File
@@ -12,8 +12,6 @@ use tokio::{sync::oneshot, task::JoinHandle};
mod communication_limits;
#[path = "http/data_contracts.rs"]
mod data_contracts;
#[path = "http/edge_attachments.rs"]
mod edge_attachments;
#[path = "http/port_anchors.rs"]
mod port_anchors;
#[path = "http/semantic_transitions.rs"]
@@ -1,344 +0,0 @@
use super::*;
use oa_core::{
EdgeAttachment, PortAnchorSide,
source_project::{ProjectLimits, SourceBundle, compile_bundle},
};
use std::collections::BTreeMap;
fn source_bundle() -> SourceBundle {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "// project rationale\narcadia 7; project attachments \"Attachments\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// semantic ownership\noa_activity first \"First\" {}\noa_activity second \"Second\" {}\ninteraction signal \"Signal\" { source @first; target @second; }\n".into()),
("views.arcadia".into(), "// view rationale\ndiagram view \"View\" { routes [{ edge @signal; waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; }]; }\ndiagram other \"Other\" { routes [{ edge @signal; }]; }\n".into()),
]),
}, ProjectLimits::default()).unwrap().bundle
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
async fn import(server: &TestServer) -> StoredProject {
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(&source_bundle())
.send()
.await
.unwrap();
let status = response.status();
let value: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::CREATED, "{value}");
server
.get(value["resource_id"].as_str().unwrap().parse().unwrap())
.await
}
async fn transact(
server: &TestServer,
current: &StoredProject,
commands: Value,
dry_run: bool,
) -> reqwest::Response {
server
.client
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
.header("if-match", current.etag())
.json(&json!({"commands":commands,"dry_run":dry_run}))
.send()
.await
.unwrap()
}
fn assert_pair(current: &StoredProject) {
let compiled = compile_bundle(&current.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, current.model);
assert_eq!(compiled.bundle, current.source_project);
}
async fn assert_unchanged(server: &TestServer, expected: &StoredProject) {
let actual = server.get(expected.resource_id).await;
assert_eq!(actual.etag(), expected.etag());
assert_eq!(
serde_json::to_value(actual).unwrap(),
serde_json::to_value(expected).unwrap()
);
}
#[tokio::test]
async fn edge_attachments_http_dry_run_etags_source_parity_and_restart() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server).await;
let diagram = id(&original.model, "view");
let mut expected = original.model.clone();
let route = &mut expected.diagrams.get_mut(&diagram).unwrap().routes[0];
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25,
});
route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0,
});
let commands = json!([Command::SetEdgeRoute {
diagram,
route: route.clone()
}]);
let preview = transact(&server, &original, commands.clone(), true).await;
assert_eq!(preview.status(), StatusCode::OK);
let preview: Value = preview.json().await.unwrap();
assert_eq!(
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
expected
);
let proposed: SourceBundle = serde_json::from_value(preview["source_project"].clone()).unwrap();
assert_eq!(
compile_bundle(&proposed, ProjectLimits::default())
.unwrap()
.decoded
.project,
expected
);
assert_unchanged(&server, &original).await;
let applied = transact(&server, &original, commands.clone(), false).await;
assert_eq!(applied.status(), StatusCode::OK);
let mut current = server.get(original.resource_id).await;
assert_eq!(applied.headers()["etag"], current.etag());
assert_eq!(current.revision, original.revision + 1);
assert_eq!(
current.model, expected,
"only the two attachment fields in this view change"
);
assert_eq!(current.source_project, proposed);
assert_pair(&current);
for path in ["project.arcadia", "model.arcadia"] {
assert_eq!(
current.source_project.files[path],
original.source_project.files[path]
);
}
assert!(current.source_project.files["views.arcadia"].contains("// view rationale"));
let noop = transact(&server, &current, commands.clone(), false).await;
assert_eq!(noop.status(), StatusCode::OK);
assert_eq!(noop.headers()["etag"], current.etag());
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
let stale = json!([
Command::Rename(id(&current.model, "first"), "Must not persist".into()),
commands[0].clone()
]);
assert_eq!(
transact(&server, &original, stale, false).await.status(),
StatusCode::PRECONDITION_FAILED
);
assert_unchanged(&server, &current).await;
let mut edited = current.source_project.clone();
let text = edited.files.get_mut("views.arcadia").unwrap();
assert_eq!(text.matches("offset 0.25;").count(), 1);
*text = text.replace("offset 0.25;", "offset 0.375;");
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let response = server
.client
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.header("if-match", current.etag())
.json(&edited)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
current = server.get(current.resource_id).await;
assert_eq!(current.model, expected);
assert_eq!(current.source_project, edited);
assert_pair(&current);
let source = server
.client
.get(server.url(&format!("/projects/{}/source", current.resource_id)))
.send()
.await
.unwrap();
assert_eq!(source.status(), StatusCode::OK);
assert_eq!(source.headers()["etag"], current.etag());
assert_eq!(
oa_core::language::parse(&source.text().await.unwrap()).unwrap(),
expected
);
let bundle = server
.client
.get(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.send()
.await
.unwrap();
assert_eq!(bundle.status(), StatusCode::OK);
assert_eq!(bundle.headers()["etag"], current.etag());
assert_eq!(
bundle.json::<SourceBundle>().await.unwrap(),
current.source_project
);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_unchanged(&server, &current).await;
let mut route = expected.diagrams[&diagram].routes[0].clone();
route.source_attachment = None;
let reset = json!([Command::SetEdgeRoute {
diagram,
route: route.clone()
}]);
assert_eq!(
transact(&server, &current, reset.clone(), false)
.await
.status(),
StatusCode::OK
);
expected.diagrams.get_mut(&diagram).unwrap().routes[0] = route;
let reset_state = server.get(current.resource_id).await;
assert_eq!(
reset_state.model, expected,
"reset preserves pins, label, opposite end, other view and semantics"
);
assert_pair(&reset_state);
let noop = transact(&server, &reset_state, reset, false).await;
assert_eq!(noop.status(), StatusCode::OK);
assert_eq!(noop.headers()["etag"], reset_state.etag());
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
server.stop().await;
}
#[tokio::test]
async fn edge_attachments_http_malformed_and_invalid_offsets_reject_atomically() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = import(&server).await;
let diagram = id(&current.model, "view");
let first = id(&current.model, "first");
for (bad, status) in [
(json!({}), StatusCode::BAD_REQUEST),
(json!({"side":"Top"}), StatusCode::BAD_REQUEST),
(json!({"offset":0.5}), StatusCode::BAD_REQUEST),
(
json!({"side":"Diagonal","offset":0.5}),
StatusCode::BAD_REQUEST,
),
(json!({"side":"Top","offset":null}), StatusCode::BAD_REQUEST),
(
json!({"side":"Top","offset":"NaN"}),
StatusCode::BAD_REQUEST,
),
(
json!({"side":"Top","offset":0.5,"unknown":true}),
StatusCode::BAD_REQUEST,
),
(
json!({"side":"Top","offset":-0.01}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"side":"Top","offset":1.01}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route =
serde_json::to_value(&current.model.diagrams[&diagram].routes[0]).unwrap();
route[field] = bad.clone();
let commands = json!([Command::Rename(first, "Must roll back".into()), {"SetEdgeRoute":{"diagram":diagram,"route":route}}]);
let response = transact(&server, &current, commands, false).await;
assert_eq!(
response.status(),
status,
"{field} {bad}: {}",
response.text().await.unwrap()
);
assert_unchanged(&server, &current).await;
}
}
let mut invalid = current.model.clone();
invalid.diagrams.get_mut(&diagram).unwrap().routes[0].target_attachment =
Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 2.0,
});
let response = server
.client
.put(server.url(&format!("/projects/{}/model", current.resource_id)))
.header("if-match", current.etag())
.json(&invalid)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_unchanged(&server, &current).await;
server.stop().await;
}
#[tokio::test]
async fn edge_attachments_served_openapi_matches_required_nested_records_and_optional_ends() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let response = server
.client
.get(server.url("/openapi.json"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let schema: Value = response.json().await.unwrap();
let schemas = &schema["components"]["schemas"];
let attachment = &schemas["EdgeAttachment"];
assert_eq!(attachment["required"], json!(["side", "offset"]));
assert_eq!(attachment["additionalProperties"], false);
assert_eq!(
attachment["properties"]["side"]["$ref"],
"#/components/schemas/PortAnchorSide"
);
assert_eq!(attachment["properties"]["offset"]["minimum"], 0);
assert_eq!(attachment["properties"]["offset"]["maximum"], 1);
for field in ["source_attachment", "target_attachment"] {
assert!(
!schemas["EdgeRoute"]["required"]
.as_array()
.unwrap()
.contains(&json!(field))
);
let optional = &schemas["EdgeRoute"]["properties"][field];
assert_eq!(optional["default"], Value::Null);
assert_eq!(
optional["oneOf"],
json!([{"$ref":"#/components/schemas/EdgeAttachment"},{"type":"null"}])
);
}
let compiled = compile_bundle(&source_bundle(), ProjectLimits::default()).unwrap();
let diagram = id(&compiled.decoded.project, "view");
let mut route = compiled.decoded.project.diagrams[&diagram].routes[0].clone();
let omitted = serde_json::to_value(&route).unwrap();
assert!(omitted.get("source_attachment").is_none());
assert!(omitted.get("target_attachment").is_none());
route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.0,
});
let encoded = serde_json::to_value(Command::SetEdgeRoute { diagram, route }).unwrap();
assert_eq!(
encoded["SetEdgeRoute"]["route"]["target_attachment"],
json!({"side":"Right","offset":0.0})
);
server.stop().await;
}
+1 -28
View File
@@ -2,7 +2,7 @@
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="utf-8" />
<meta name="generator" content="Docutils 0.23: https://docutils.sourceforge.io/" />
<meta name="generator" content="Docutils 0.22.2: https://docutils.sourceforge.io/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcadia Studio model REST API</title>
<style type="text/css">
@@ -947,33 +947,6 @@ authored files and revision. JSON/source imports may omit <span class="docutils
it defaults to <span class="docutils literal">[]</span> and empty lists are omitted on export. The normative
diagram semantics and source spelling are in <span class="docutils literal">language.rst</span> section 5.</p>
</section>
<section id="diagram-connection-attachments">
<h2>Diagram connection attachments</h2>
<p><span class="docutils literal">EdgeRoute.source_attachment</span> and <span class="docutils literal">target_attachment</span> are optional
<span class="docutils literal"><span class="pre">{&quot;side&quot;:&quot;Bottom&quot;,&quot;offset&quot;:0.25}</span></span> records. Side and a finite offset in
<span class="docutils literal">[0,1]</span> are required within a supplied record; unknown fields are rejected.
Absence/null means automatic placement. Omitted fields in older schema-3
models retain that meaning. The normal transaction endpoint accepts:</p>
<pre class="literal-block">{&quot;commands&quot;:[{&quot;SetEdgeRoute&quot;:{
&quot;diagram&quot;:&quot;&lt;diagram UUID&gt;&quot;,
&quot;route&quot;:{&quot;edge&quot;:&quot;&lt;relation or trace UUID&gt;&quot;,
&quot;source_attachment&quot;:{&quot;side&quot;:&quot;Bottom&quot;,&quot;offset&quot;:0.25},
&quot;waypoints&quot;:[],&quot;label&quot;:null}
}}]}</pre>
<p><span class="docutils literal">SetEdgeRoute</span> replaces the complete route entry. Clients changing one end
must retain its other attachment, bends and label; omit/null only the intended
attachment to restore that end's automatic position. <span class="docutils literal">RemoveEdgeRoute</span> resets
all presentation for that edge. Both support ETag checking, dry runs, atomic
validation and source-project writeback. Invalid offsets reject the whole
transaction without changing files or revision.</p>
<p>These are per-view presentation preferences, not a semantic reconnect. Fractions
follow the projected shape, including moves/resizes; semantic reconnect retains
them as preferences of the same edge-end slots. Real semantic ports take
precedence, and promoted folded endpoints suspend authored routing. The complete
boundary mapping, lifecycle and generated-view limitations are normative in
<span class="docutils literal">language.rst</span> section 5. GET model/source, PUT source, desktop and transactions
share this representation; no separate attachment endpoint exists.</p>
</section>
<section id="contextual-process-and-chain-topology">
<h2>Contextual process and chain topology</h2>
<p>Operational processes and system functional chains can combine work
-31
View File
@@ -329,37 +329,6 @@ authored files and revision. JSON/source imports may omit ``port_anchors``;
it defaults to ``[]`` and empty lists are omitted on export. The normative
diagram semantics and source spelling are in ``language.rst`` section 5.
Diagram connection attachments
-------------------------------
``EdgeRoute.source_attachment`` and ``target_attachment`` are optional
``{"side":"Bottom","offset":0.25}`` records. Side and a finite offset in
``[0,1]`` are required within a supplied record; unknown fields are rejected.
Absence/null means automatic placement. Omitted fields in older schema-3
models retain that meaning. The normal transaction endpoint accepts::
{"commands":[{"SetEdgeRoute":{
"diagram":"<diagram UUID>",
"route":{"edge":"<relation or trace UUID>",
"source_attachment":{"side":"Bottom","offset":0.25},
"waypoints":[],"label":null}
}}]}
``SetEdgeRoute`` replaces the complete route entry. Clients changing one end
must retain its other attachment, bends and label; omit/null only the intended
attachment to restore that end's automatic position. ``RemoveEdgeRoute`` resets
all presentation for that edge. Both support ETag checking, dry runs, atomic
validation and source-project writeback. Invalid offsets reject the whole
transaction without changing files or revision.
These are per-view presentation preferences, not a semantic reconnect. Fractions
follow the projected shape, including moves/resizes; semantic reconnect retains
them as preferences of the same edge-end slots. Real semantic ports take
precedence, and promoted folded endpoints suspend authored routing. The complete
boundary mapping, lifecycle and generated-view limitations are normative in
``language.rst`` section 5. GET model/source, PUT source, desktop and transactions
share this representation; no separate attachment endpoint exists.
Contextual process and chain topology
-------------------------------------
+2 -45
View File
@@ -1785,16 +1785,14 @@ the Reparent command. Moving, resizing, arranging, or folding an owner retains
its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
<span class="docutils literal">port_anchors</span>; their explicit migration supplies an empty list.</p>
<p>Each <span class="docutils literal">routes</span> entry is an EdgeRoute record with a required <span class="docutils literal">edge</span> reference
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), <span class="docutils literal">label</span>
(default <span class="docutils literal">none</span>), and <span class="docutils literal">source_attachment</span> / <span class="docutils literal">target_attachment</span> (each
default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), and <span class="docutils literal">label</span>
(default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
at most once in a diagram's route list; different diagrams MAY store independent
routes for the same edge. List order and explicit empty entries are retained by
source and JSON round trips, although list order has no routing meaning. Edges
need not currently be visible in their diagram.</p>
<pre class="code text literal-block"><code>routes [
{ edge &#64;report_exchange;
source_attachment { side Bottom; offset 0.25; };
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
label { fraction 0.55; offset { x 0; y -16; }; };
}
@@ -1811,47 +1809,6 @@ are structural errors. A pinned point that obstructs routing remains stored;
geometric routing difficulty is reported separately from structural validity.
Each EdgeRoute counts as one record against <span class="docutils literal">ParseLimits.max_records</span>; pinned
points are bounded by the per-route limit and aggregate input byte limits.</p>
<p>An attachment is an EdgeAttachment record with required <span class="docutils literal">side</span> (Left, Right,
Top, Bottom) and required finite binary32 <span class="docutils literal">offset</span> from zero through one.
Unknown fields, omitted nested fields and out-of-range offsets are structural
errors (<span class="docutils literal">invalid_attachment_offset</span> for the latter). Attachments are bounded
inline values, at most two per route, not additional graph objects or records.
JSON uses the same field names, enum strings and number values. Omitted or null
attachments mean automatic positioning; canonical compact source and JSON omit
absent attachments. Earlier schema-3 input hydrates both fields as absent.</p>
<p>The end names designate stored source/target slots, even for undirected edges.
These values MUST NOT change semantic endpoints, ports, ownership, type,
orientation or identity. Each preference belongs to that edge end in that view:
another view remains independent. It follows the current endpoint's projected
boundary under movement, resize or semantic reconnect; reconnect preserves the
preference, as it preserves authored bends. The fraction does not identify the
previous semantic endpoint. Deletion/reidentification of the edge follows the
existing whole-route rules; moving, hiding or replacing its placement retains
the preference. Removing one attachment restores that end's automatic position
without changing bends, labels or the opposite attachment.</p>
<p>For Left/Right, offset gives the Y fraction of the complete projected bounding
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
their width/height; Regions and fork/join bars are rectangles. State circles use
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
<span class="docutils literal">t = 2 * offset - 1</span> and <span class="docutils literal">D</span> be width for Left/Right or height for Top/Bottom.
The inset from the selected bounding side is <span class="docutils literal">D/2 * (1 - <span class="pre">sqrt(1-t*t))</span></span> for an
ellipse and <span class="docutils literal">D/2 * abs(t)</span> for a diamond. A Terminate X uses the outermost
painted-stroke intersection, inset <span class="docutils literal">D * min(offset, <span class="pre">1-offset)</span></span>. Zero and one
are valid; different side/fraction pairs can describe the same painted point.
Routing leads extend in the selected cardinal direction beyond the bounding box
before turning. Source/API fractions retain their precision; pointer editing
snaps fractions to hundredths without changing the stored semantic record.</p>
<p>An actual semantic Port remains authoritative, including when projection hides
its port marker. An edge attachment on that end is retained but dormant; use
the port's own placement anchor to move a port. When folding promotes an endpoint
to an ancestor, all authored route data is suspended and resumes on expansion.
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
tips retain their positions with an external loop and a routing warning; the
editor must offer an explicit way to choose either end even when handles overlap.
Internal state transitions have no external connection. Process's generated
topology and Scenario's lifeline/activation/time-derived tips are not consumers
of these diagram route fields; this feature does not redefine their semantics.</p>
<p><span class="docutils literal">SetEdgeRoute { diagram, route }</span> upserts an entry by edge identity, retaining
the order and contents of unrelated entries. <span class="docutils literal">RemoveEdgeRoute { diagram, edge }</span>
removes it; removing an absent entry is a no-op for an existing diagram and edge.
+2 -49
View File
@@ -1167,9 +1167,8 @@ its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
``port_anchors``; their explicit migration supplies an empty list.
Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference
to an existing Relation or Trace, ``waypoints`` (default ``[]``), ``label``
(default ``none``), and ``source_attachment`` / ``target_attachment`` (each
default ``none``). Route records have no separate UUID or alias. An edge occurs
to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label``
(default ``none``). Route records have no separate UUID or alias. An edge occurs
at most once in a diagram's route list; different diagrams MAY store independent
routes for the same edge. List order and explicit empty entries are retained by
source and JSON round trips, although list order has no routing meaning. Edges
@@ -1179,7 +1178,6 @@ need not currently be visible in their diagram.
routes [
{ edge @report_exchange;
source_attachment { side Bottom; offset 0.25; };
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
label { fraction 0.55; offset { x 0; y -16; }; };
}
@@ -1198,51 +1196,6 @@ geometric routing difficulty is reported separately from structural validity.
Each EdgeRoute counts as one record against ``ParseLimits.max_records``; pinned
points are bounded by the per-route limit and aggregate input byte limits.
An attachment is an EdgeAttachment record with required ``side`` (Left, Right,
Top, Bottom) and required finite binary32 ``offset`` from zero through one.
Unknown fields, omitted nested fields and out-of-range offsets are structural
errors (``invalid_attachment_offset`` for the latter). Attachments are bounded
inline values, at most two per route, not additional graph objects or records.
JSON uses the same field names, enum strings and number values. Omitted or null
attachments mean automatic positioning; canonical compact source and JSON omit
absent attachments. Earlier schema-3 input hydrates both fields as absent.
The end names designate stored source/target slots, even for undirected edges.
These values MUST NOT change semantic endpoints, ports, ownership, type,
orientation or identity. Each preference belongs to that edge end in that view:
another view remains independent. It follows the current endpoint's projected
boundary under movement, resize or semantic reconnect; reconnect preserves the
preference, as it preserves authored bends. The fraction does not identify the
previous semantic endpoint. Deletion/reidentification of the edge follows the
existing whole-route rules; moving, hiding or replacing its placement retains
the preference. Removing one attachment restores that end's automatic position
without changing bends, labels or the opposite attachment.
For Left/Right, offset gives the Y fraction of the complete projected bounding
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
their width/height; Regions and fork/join bars are rectangles. State circles use
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
``t = 2 * offset - 1`` and ``D`` be width for Left/Right or height for Top/Bottom.
The inset from the selected bounding side is ``D/2 * (1 - sqrt(1-t*t))`` for an
ellipse and ``D/2 * abs(t)`` for a diamond. A Terminate X uses the outermost
painted-stroke intersection, inset ``D * min(offset, 1-offset)``. Zero and one
are valid; different side/fraction pairs can describe the same painted point.
Routing leads extend in the selected cardinal direction beyond the bounding box
before turning. Source/API fractions retain their precision; pointer editing
snaps fractions to hundredths without changing the stored semantic record.
An actual semantic Port remains authoritative, including when projection hides
its port marker. An edge attachment on that end is retained but dormant; use
the port's own placement anchor to move a port. When folding promotes an endpoint
to an ancestor, all authored route data is suspended and resumes on expansion.
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
tips retain their positions with an external loop and a routing warning; the
editor must offer an explicit way to choose either end even when handles overlap.
Internal state transitions have no external connection. Process's generated
topology and Scenario's lifeline/activation/time-derived tips are not consumers
of these diagram route fields; this feature does not redefine their semantics.
``SetEdgeRoute { diagram, route }`` upserts an entry by edge identity, retaining
the order and contents of unrelated entries. ``RemoveEdgeRoute { diagram, edge }``
removes it; removing an absent entry is a no-op for an existing diagram and edge.
+1 -45
View File
@@ -2137,59 +2137,15 @@
],
"additionalProperties": false
},
"EdgeAttachment": {
"type": "object",
"description": "Per-view preference on one stored connection end, not a semantic endpoint. Offset is finite and measured along the complete projected bounding-box side. Real semantic ports take precedence; hidden/promoted geometry retains the preference. Reconnect preserves the preference on its source/target slot.",
"properties": {
"side": {
"$ref": "#/components/schemas/PortAnchorSide"
},
"offset": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Left/Right: top-to-bottom fraction. Top/Bottom: left-to-right fraction. Shape-aware geometry derives the actual contour point."
}
},
"required": [
"side",
"offset"
],
"additionalProperties": false
},
"EdgeRoute": {
"type": "object",
"description": "One diagram's authored presentation for an existing Relation or Trace, including currently hidden edges. No separate identity. Stores optional endpoint preferences, pinned points and label placement; explicit empty routes are retained. Removing the route resets all authored presentation.",
"description": "One diagram's authored route for an existing Relation or Trace, including currently hidden edges. No separate identity. Only pinned points are stored; explicit empty routes are retained.",
"properties": {
"edge": {
"type": "string",
"format": "uuid",
"description": "Existing Relation or Trace identity, never a node placement."
},
"source_attachment": {
"oneOf": [
{
"$ref": "#/components/schemas/EdgeAttachment"
},
{
"type": "null"
}
],
"default": null,
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
},
"target_attachment": {
"oneOf": [
{
"$ref": "#/components/schemas/EdgeAttachment"
},
{
"type": "null"
}
],
"default": null,
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
},
"waypoints": {
"type": "array",
"items": {
+2
View File
@@ -49,6 +49,8 @@ P1 extension to frozen relation interface: FunctionalExchange now has `source_po
`transitions::counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Kind>` is the shared choice policy for UI and other clients. It returns supported explicit element counterpart kinds in stable `Kind::ALL` order using the same compatibility predicate as transition validation; an empty list means no automatic element mapping.
`transitions::counterpart_compatible(&Project, source: Id, target: Id, direction: TransitionDirection) -> bool` checks existing-record candidate eligibility: both IDs must exist in the direction's respective layers, belong to the same element/relation category, and have compatible transition kinds. It does not approve properties, dependencies or structural changes; clients still preview and apply a complete plan. Manual trace creation retains its separate policy. The desktop counterpart search uses this predicate, filters by name or `@alias`, and sorts by name then alias and ID. Search is presentation state: it does not replan, change mappings or reviewed field decisions, or enter workflow recovery data. A selected target remains visible when filtered out; invalid or missing recovered targets remain visible with their error and require correction. Multiple traces to the same target count as one inferred counterpart; distinct traced targets require an explicit choice.
`TransitionRequest {direction:OaToSa|SaToOa, sources:Vec<Id>, scope:SelectedOnly|Required|Subtree, mappings:Vec<TransitionMapping>, fields:Vec<TransitionField>, resolutions:Vec<FieldResolution>, destination_diagram:Option<Id>}`. `TransitionRequest::new(direction,sources)` defaults to selected-only and name/description fields. `TransitionMapping {source,target:Option<Id>,target_kind:Option<Kind>}` selects reuse or explicit creation kind. An explicit mapping with target=None means create a new counterpart even if another trace exists. Without mapping, one existing trace is reused; multiple traces require selection.
`FieldResolution {source,field:Name|Description,decision:TakeSource|KeepTarget|Value(String)}`. `TransitionPlan {base_fingerprint,request,changes,conflicts,warnings,commands}`. Changes carry source/target IDs, Create/Update/Reuse and before/after field changes. Conflicts have optional source/target/field and message. Conflicts prevent apply. The deterministic fingerprint guards stale plans, is not a security hash, and does not introduce persistent project revision fields. Planned IDs and aliases remain unchanged during apply. Server owns request idempotency/revision storage. Apply is one candidate transaction; Session adoption can use Command::Replace(candidate) for one Undo.
-9
View File
@@ -456,12 +456,3 @@ Missing global and scoped parameter IDs remain explicit stabilization metadata,
not new random identities assigned on each parse. Public source8 integration must
thread the same accounting through the outer project and included files; this
private checkpoint does not itself admit a complete version8 source bundle.
## Connection-end presentation preferences — 2026-09-22
Issue #33 uses optional side/fraction attachments on each view's `EdgeRoute`,
with semantic Port precedence and shared shape-aware routing. Visual attachment
editing remains separate from semantic reconnect. The [decision and evidence
map](edge-attachments-20260922.md) records alternatives, lifecycle rules,
independent falsifiers, evaluators and the Process/Scenario applicability limits.
This is branch-specific work; it does not establish an integrated release.
-103
View File
@@ -1,103 +0,0 @@
# Connection attachment editing — issue 33
Branch-specific work from `3f9e6a18af96b5cd10940da4093be8da28b9c835` on
`codex/edge-anchors-ytr83r-20260922`, owned by Codex-ytr83r/root.
[Live scope and handoff](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/33)
remain in Gitea. This is not an integrated release checkpoint.
The user needs to move where a connection touches an element without changing
the relationship. The existing ordinary route editor offered bends and labels;
unported ends were selected from four side midpoints. SA semantic ports already
had per-placement anchors. State endpoint handles instead performed semantic
reconnect. These are different operations and remain distinct.
## Decision and alternatives
Store optional `source_attachment` and `target_attachment` values on `EdgeRoute`.
Each contains a side and normalized fraction, interpreted against the actual
projected shape. No additional semantic object, ID or reference is introduced.
The precise format, geometry, Port precedence, folding, reconnect and reset rules
are in [the normative language contract](../docs/language.rst); REST uses the
same data and existing `SetEdgeRoute` transaction.
Compared alternatives:
- Absolute endpoint coordinates are easy to store but become detached from the
owner after movement/resizing and duplicate derived geometry.
- Reusing semantic Port anchors would invent ports for OA/state relationships
and conflate each connection's presentation with a shared port's identity.
- Binding an attachment to an additional semantic endpoint/placement reference
would preserve the old owner's identity after reconnect, but needs stale
reference cleanup and a dormant preference lifecycle without a current user
need. The selected rule retains the edge-end preference across reconnect,
just as authored bends are retained; it is documented, not inferred silently.
Fractions remain precise in source/API; pointer edits snap to hundredths.
Automatic absence and authored values are distinct. Press/release without a
drag, Escape, stale context, focus/camera/layout change and outside release do
not author a preference. Returning within three screen pixels of the press
restores the exact initial optional value, including its original precision.
Successful editing changes one field in one route in one view in one history
operation. Per-end reset preserves the other end, bends and label.
Ordinary diagrams expose eligible selected-end handles and explicit source/target
menu choice for overlapping handles. State menus expose separately labelled
Adjust attachment and Reconnect actions; selecting a transition still retains
the existing reconnect gesture. Ports keep their existing movement policy.
This work does not adopt or alter the separate #16 resize/port-capacity policy.
## Review-driven corrections and evaluators
Independent contract review identified projection-filtered Port markers as an
unsafe authority: eligibility and routing now consult semantic endpoint identity.
Attachment metadata participates in the route-preview cache key. Inset contours
use actual lead lengths for path costs and pin fractions; shape scan-line
intersections are shared by routing and pointer gestures. Side choice uses the
nearest cardinal bounding segment, avoiding coincident circle scan-line points
that otherwise retain the wrong exit direction. Hysteresis is capped by symbol
size so small circles can change sides at low zoom; an explicit regression
covers midpoint, overshoot and near-corner intent.
The first frozen gate exposed a remaining overshoot case: distances measured
from far outside a small symbol converged, keeping the previous perpendicular
side. Measuring side intent at the cursor clamped to the bounding box preserves
the intended side under overshoot. The failed gate is retained separately.
Valid Port-to-Port Item traces also omit projected port IDs; routing now resolves
the semantic port before looking up its visible, owner-qualified marker. An
actual trace regression checks moved anchors and unchanged trace metadata.
Coincident authored self ends previously threatened an empty candidate list.
They retain their positions and receive an explicit loop warning. A critic then
showed a fixed invented detour point could collide with a neighboring card even
though the opposite side was clear. Computed detours now consider both directions
and multiple bounded extents, preferring clear candidates. These points never
become persisted hard pins. Geometry tests exercise blockers on either side.
Cross-review found ordinary attachment drags survived camera/focus/layout
changes and outside release. They now carry a viewport stamp and cancel on those
changes, with an actual pointer-event cancellation matrix. The same review
reconciled return-to-press behavior across ordinary and State canvases.
Meaningful evaluators are `oa-core` integration tests `attachment_geometry`,
`attachment_lifecycle`, `attachment_trace_ports`, `attachment_workshop`,
`edge_attachments` and the existing routing/port/state suites; desktop ordinary
attachment and State pointer/planner tests; server `http::edge_attachments`
conditional transactions, exact source exports and restart. Tests compare complete
projects/source where applicable, not just attachment fields. Native acceptance
uses the [fixture and workflow plan](experiments/edge-attachments-20260922/native-plan.md).
Final test/native results and exact tested commit belong in the publication
handoff, not an unverified claim in this decision record.
## Applicability and integration limits
Process topology uses generated cards and qualified contextual addresses.
Scenario endpoints derive from lifelines, activations, lifecycle and time/order
notation. Neither renderer consumes ordinary `Diagram.routes`; their visual
editing requires separate notation-aware contracts. This change does not claim
to implement arbitrary attachment dragging there, or to resolve automatic
arrangement (#32) or State bend editing (#23).
The base includes ordinary Text work, not the separately published Scenario
spacing/inspector branches or the latest transition lifecycle branch. Integration
owner #27 must compose actual pushed commits and verify the combined result.
Linux/WSL verification remains paused by the user.
@@ -1,53 +0,0 @@
// Immutable acceptance baseline: copy before editing. Stable IDs are deliberate.
arcadia 7;
project endpoint_acceptance "Harbor dispatch — attachment acceptance" id "ea202922-0033-4000-8000-000000000001" {
description "Small native fixture for presentation-only endpoint editing. Names describe stored source-to-target direction.";
oa_activity receive_report "Receive distress report" id "ea202922-0033-4000-8000-000000000010" { }
oa_activity dispatch_crew "Dispatch rescue crew" id "ea202922-0033-4000-8000-000000000011" { }
interaction dispatch_request "Dispatch request: Receive → Dispatch" id "ea202922-0033-4000-8000-000000000020" { source @receive_report; target @dispatch_crew; description "Semantic sentinel: retain this description and direction."; }
sa_system controller "Rescue readiness controller" id "ea202922-0033-4000-8000-000000000030" { }
sa_state_machine readiness "Rescue readiness" id "ea202922-0033-4000-8000-000000000031" { parent @controller; }
sa_region main_region "Readiness region" id "ea202922-0033-4000-8000-000000000032" { parent @readiness; }
sa_initial start "Startup" id "ea202922-0033-4000-8000-000000000033" { parent @main_region; }
sa_state ready "Ready" id "ea202922-0033-4000-8000-000000000034" { parent @main_region; }
sa_state active "Active" id "ea202922-0033-4000-8000-000000000035" { parent @main_region; }
sa_choice decision "Dispatch decision" id "ea202922-0033-4000-8000-000000000036" { parent @main_region; }
sa_final finished "Shutdown complete" id "ea202922-0033-4000-8000-000000000037" { parent @main_region; }
state_transition initialize "Initialize: Startup → Ready" id "ea202922-0033-4000-8000-000000000040" { source @start; target @ready; transition_kind External; triggers []; trigger ""; guard ""; effects []; effect ""; }
state_transition activate "Activate: Ready → Active" id "ea202922-0033-4000-8000-000000000041" { source @ready; target @active; transition_kind External; triggers []; trigger ""; guard "crew_assigned"; effects []; effect ""; }
state_transition assess "Assess: Active → Decision" id "ea202922-0033-4000-8000-000000000042" { source @active; target @decision; transition_kind External; triggers []; trigger ""; guard ""; effects []; effect ""; }
state_transition retry "Retry: Decision → Ready" id "ea202922-0033-4000-8000-000000000043" { source @decision; target @ready; transition_kind External; triggers []; trigger ""; guard "retry_requested"; effects []; effect ""; }
state_transition finish "Finish: Decision → Shutdown" id "ea202922-0033-4000-8000-000000000044" { source @decision; target @finished; transition_kind External; triggers []; trigger ""; guard "else"; effects []; effect ""; }
state_transition refresh "Refresh: Active → Active" id "ea202922-0033-4000-8000-000000000045" { source @active; target @active; transition_kind External; triggers []; trigger ""; guard "refresh_requested"; effects []; effect ""; }
state_transition internal_check "Internal status check" id "ea202922-0033-4000-8000-000000000046" { source @active; target @active; transition_kind Internal; triggers []; trigger ""; guard ""; effects []; effect "Status unchanged"; }
sa_function emit_status "Emit readiness status" id "ea202922-0033-4000-8000-000000000050" { }
sa_function display_status "Display readiness status" id "ea202922-0033-4000-8000-000000000051" { }
sa_port status_out "Status output" id "ea202922-0033-4000-8000-000000000052" { parent @emit_status; contract { FunctionOutput { outgoing_items []; }; }; }
sa_port status_in "Status input" id "ea202922-0033-4000-8000-000000000053" { parent @display_status; contract { FunctionInput { incoming_items []; }; }; }
functional_exchange status_exchange "Readiness status: Emit → Display" id "ea202922-0033-4000-8000-000000000054" { source @emit_status; target @display_status; source_port @status_out; target_port @status_in; items []; }
diagram dispatch_view "01 Dispatch — edit attachments" id "ea202922-0033-4000-8000-000000000100" { layer Oa; projection FunctionalDataflow; routes [{ edge @dispatch_request; waypoints [{ x 440; y 340; }]; label { fraction 0.5; offset { x 12; y -18; }; }; source_attachment { side Right; offset 0.5; }; target_attachment { side Left; offset 0.6; }; }]; }
diagram dispatch_reference "02 Dispatch — untouched reference" id "ea202922-0033-4000-8000-000000000101" { layer Oa; projection FunctionalDataflow; routes [{ edge @dispatch_request; target_attachment { side Bottom; offset 0.25; }; }]; }
node dispatch_view_receive id "ea202922-0033-4000-8000-000000000110" { diagram @dispatch_view; element @receive_report; position { x 100; y 180; }; size { width 240; height 120; }; collapsed false; }
node dispatch_view_dispatch id "ea202922-0033-4000-8000-000000000111" { diagram @dispatch_view; element @dispatch_crew; position { x 600; y 180; }; size { width 240; height 120; }; collapsed false; }
node dispatch_reference_receive id "ea202922-0033-4000-8000-000000000120" { diagram @dispatch_reference; element @receive_report; position { x 100; y 180; }; size { width 240; height 120; }; collapsed false; }
node dispatch_reference_dispatch id "ea202922-0033-4000-8000-000000000121" { diagram @dispatch_reference; element @dispatch_crew; position { x 600; y 180; }; size { width 240; height 120; }; collapsed false; }
diagram readiness_view "03 Readiness — edit attachments" id "ea202922-0033-4000-8000-000000000200" { layer Sa; projection { StateMachine { machine @readiness; }; }; routes [{ edge @activate; waypoints [{ x 500; y 310; }]; label { fraction 0.5; offset { x 8; y -16; }; }; source_attachment { side Right; offset 0.5; }; target_attachment { side Left; offset 0.6; }; }, { edge @initialize; source_attachment { side Right; offset 0.5; }; }, { edge @finish; source_attachment { side Right; offset 0.5; }; }]; }
diagram readiness_reference "04 Readiness — untouched reference" id "ea202922-0033-4000-8000-000000000201" { layer Sa; projection { StateMachine { machine @readiness; }; }; routes []; }
node readiness_view_readiness id "ea202922-0033-4000-8000-000000000210" { diagram @readiness_view; element @readiness; position { x 40; y 40; }; size { width 980; height 590; }; collapsed false; }
node readiness_view_main_region id "ea202922-0033-4000-8000-000000000211" { diagram @readiness_view; element @main_region; position { x 64; y 104; }; size { width 932; height 500; }; collapsed false; }
node readiness_view_start id "ea202922-0033-4000-8000-000000000212" { diagram @readiness_view; element @start; position { x 110; y 252; }; size { width 20; height 20; }; collapsed false; }
node readiness_view_ready id "ea202922-0033-4000-8000-000000000213" { diagram @readiness_view; element @ready; position { x 190; y 210; }; size { width 240; height 120; }; collapsed false; }
node readiness_view_active id "ea202922-0033-4000-8000-000000000214" { diagram @readiness_view; element @active; position { x 570; y 210; }; size { width 240; height 140; }; collapsed false; }
node readiness_view_decision id "ea202922-0033-4000-8000-000000000215" { diagram @readiness_view; element @decision; position { x 670; y 430; }; size { width 40; height 40; }; collapsed false; }
node readiness_view_finished id "ea202922-0033-4000-8000-000000000216" { diagram @readiness_view; element @finished; position { x 880; y 440; }; size { width 24; height 24; }; collapsed false; }
node readiness_reference_readiness id "ea202922-0033-4000-8000-000000000230" { diagram @readiness_reference; element @readiness; position { x 40; y 40; }; size { width 980; height 590; }; collapsed false; }
node readiness_reference_main_region id "ea202922-0033-4000-8000-000000000231" { diagram @readiness_reference; element @main_region; position { x 64; y 104; }; size { width 932; height 500; }; collapsed false; }
node readiness_reference_start id "ea202922-0033-4000-8000-000000000232" { diagram @readiness_reference; element @start; position { x 110; y 212; }; size { width 20; height 20; }; collapsed false; }
node readiness_reference_ready id "ea202922-0033-4000-8000-000000000233" { diagram @readiness_reference; element @ready; position { x 190; y 170; }; size { width 240; height 120; }; collapsed false; }
node readiness_reference_active id "ea202922-0033-4000-8000-000000000234" { diagram @readiness_reference; element @active; position { x 570; y 170; }; size { width 240; height 140; }; collapsed false; }
node readiness_reference_decision id "ea202922-0033-4000-8000-000000000235" { diagram @readiness_reference; element @decision; position { x 670; y 430; }; size { width 40; height 40; }; collapsed false; }
node readiness_reference_finished id "ea202922-0033-4000-8000-000000000236" { diagram @readiness_reference; element @finished; position { x 880; y 440; }; size { width 24; height 24; }; collapsed false; }
diagram ports_view "05 Status — semantic ports regression" id "ea202922-0033-4000-8000-000000000300" { layer Sa; projection FunctionalDataflow; routes []; }
node ports_emit id "ea202922-0033-4000-8000-000000000310" { diagram @ports_view; element @emit_status; position { x 100; y 180; }; size { width 240; height 120; }; collapsed false; port_anchors [{ port @status_out; side Right; offset 0.5; }]; }
node ports_display id "ea202922-0033-4000-8000-000000000311" { diagram @ports_view; element @display_status; position { x 600; y 180; }; size { width 240; height 120; }; collapsed false; port_anchors [{ port @status_in; side Left; offset 0.5; }]; }
}
@@ -1,70 +0,0 @@
# Native endpoint attachment acceptance
This is a prepared plan, not a record of passed native checks. `native-fixture.arcadia` is a deliberately small schema-7 source fixture with 49 explicit stable UUIDs. The parent must parse and validate it with the candidate before staging a working copy. No GUI, compilation, or evaluator has been run for this preparation.
## Bind the run
Record the exact candidate commit, source/worktree cleanliness, GUI/runtime hashes, successful gate receipts, fixture hash and recipe hash. Keep an immutable baseline and a separate mutable working source; use an isolated profile. Record launch, normal close, stdout/stderr, saved checkpoints, and captures. Compile baseline and checkpoints to full typed Project values with the same pinned source evaluator. Use a baseline-derived expected Project with only the named field changes; compare the entire Project, not a JSON subset or screenshot. Source text formatting may differ after save; source must parse to the exact expected Project. Save/reopen comparisons must use the same expected Project. Preserve raw files and integer precision.
The fixture contains two OA activities and their one interaction, one small SA state machine, and a separate two-function semantic-port regression view. Its controller is semantic ownership, not a large canvas placement. The State view places the machine, region, and all five vertices. Two saved views per edited graph expose view-isolation failures. View 05 is separate to keep the main workflows uncluttered.
## Identities and geometry
Every ID has prefix `ea202922-0033-4000-8000-`; the twelve-digit suffix below is decimal-padded. The source file is authoritative for all fields.
| Object / alias | ID suffix | Purpose |
|---|---:|---|
| `dispatch_request` | 20 | Stored source `receive_report` (10), target `dispatch_crew` (11) |
| `dispatch_view` / `dispatch_reference` | 100 / 101 | Edited OA view / untouched OA view |
| `readiness` / `main_region` | 31 / 32 | Machine / region membership |
| `start`, `ready`, `active`, `decision`, `finished` | 33–37 | Initial, two stable states, choice, final |
| `activate` | 41 | Stored Ready source → Active target |
| `initialize` / `finish` | 40 / 44 | Circle-source / diamond-source contour checks |
| `refresh` / `internal_check` | 45 / 46 | External self-transition / internal row |
| `readiness_view` / `readiness_reference` | 200 / 201 | Edited State view / untouched State view |
| `status_exchange` / `ports_view` | 54 / 300 | Real semantic ports regression |
Coordinates below are **diagram coordinates**, not assumed native screen coordinates. After Fit, identify bounds and zoom/pan from actual UI; map `window = canvas origin + pan + zoom × diagram`. State canvas includes a guidance strip in its offset. Confirm painted handles before pressing; retain screenshots documenting the mapping. Source and target describe stored ends, regardless of arrow appearance.
| View / endpoint | Baseline attachment / contour point | Intended destination |
|---|---|---|
| OA `dispatch_request` source | Right 0.5 → (340,240) | Top 0.25 → (160,180) |
| OA `dispatch_request` target | Left 0.6 → (600,252) | Bottom 0.75 → (780,300) |
| State `activate` source | Right 0.5 → (430,270) | Top 0.25 → (250,210) |
| State `activate` target | Left 0.6 → (570,294) | Bottom 0.75 → (750,350) |
| State `initialize` initial source | Right 0.5 → (126,260) | Top 0.5 → (118,252) |
| State `finish` choice source | Right 0.5 → (698,444) | Top 0.5 → (684,430) |
| Real `status_out` / `status_in` | Right 0.5 → (340,240) / Left 0.5 → (600,240) | No attachment editing permitted |
For rounded corners, the quarter-width points are on the straight section. Circle and diamond checks intentionally use contour extrema with unambiguous 0.5 offsets. Native drag input may quantize to adjacent hundredths: if the intended fraction is not attained, do not change the expected oracle to match output. Repeat from the prior checkpoint using the painted geometry, or record the observed limitation and narrow the claim.
## OA workflow and exact expectations
1. Open view **01 Dispatch — edit attachments** and Fit. Select `Dispatch request` on its line. Confirm distinct endpoint handles and context actions. Save baseline checkpoint `00` before edits; full Project equals baseline B.
2. Drag source handle from (340,240) to (160,180), inspect live preview and release. Save `01`. Expected O1 = B with **only** `diagrams[100].routes[edge20].source_attachment = {Top,0.25}`. Target sentinel `{Left,0.6}`, waypoint `(440,340)`, label `{fraction:0.5,offset:(12,-18)}`, relation description/direction, nodes and all other views remain exact.
3. Undo and save `02`: B exactly. Redo and save `03`: O1 exactly. One Undo must remove the entire drag, not a partial movement.
4. Drag target to (780,300), save `04`: O2 = O1 with only target `{Bottom,0.75}`. Open source and target context actions to ensure end identity is clear. Use **Reset source attachment**, save `05`: O3 = O2 with source None; preserve target, waypoint and label. Undo returns O2, redo returns O3.
5. Start a drag, move away and return within three screen pixels of the press, then release: no source/history change. Repeat a drag canceled with Escape; repeat with right click, middle pan, wheel/camera change, focus loss, and release outside. Save a checkpoint after each meaningful cancellation; expect O3 exactly. Camera-only state is outside Project. After these checks, one Undo still reverses the last real reset (O2), showing no hidden history entry; Redo restores O3.
6. Open view **02 Dispatch — untouched reference**: its independent target `{Bottom,0.25}` and placements remain baseline. Return to 01, save, close normally, reopen the saved working file and save `06`: O3 exactly. Confirm the saved source contains the attachment and existing waypoint/label.
## State workflow and exact expectations
Continue from saved O3 so the State trial also proves preservation of the prior OA edits. The frozen expected catalog composes State changes on O3; B remains the unchanged comparison for view 201.
1. Open **Rescue readiness** in Behavior / State diagram and choose **03 Readiness — edit attachments**. Check source-defined membership is visible, with Ready and Active readable and no nested hierarchy. Select `Activate`; choose **Adjust source attachment** from its actions, then drag the highlighted source to (250,210). Save `S01`: S1 = O3 with only `diagrams[200].routes[edge41].source_attachment={Top,0.25}`. Preserve target `{Left,0.6}`, waypoint `(500,310)`, label `{fraction:0.5,offset:(8,-16)}`, all semantic transitions and view 201.
2. Undo → O3, redo → S1. Adjust target to (750,350), save `S02`: S2 adds only target `{Bottom,0.75}`. Reset target and save `S03`: S3 has target None, preserves source/bend/label. Cancel an attachment gesture with Escape and perform away-return and wheel interruption; checkpoints remain S3. View **04 Readiness — untouched reference** stays B exactly.
3. Choose `Initialize` → **Adjust source attachment**, drag the initial circle source to (118,252), save `S04`: S4 = S3 with only route edge40 source `{Top,0.5}`. Choose `Finish` → Adjust source, drag the choice source to (684,430), save `S05`: S5 = S4 with only route edge44 source `{Top,0.5}`. Capture both painted contours; the typed checkpoint is the persistence oracle. Undo/redo each as practical. Save/normal-close/reopen final checkpoint: S5 exactly.
4. Inspect external `Refresh: Active → Active`: both Adjust source and Adjust target must be discoverable and identify the corresponding end even on a self-transition. Inspect `Internal status check` row: it must not offer external attachment handles/actions. Do not infer every symbol was exercised from these checks.
5. **Separate reversible reconnect trial from S5:** with Select / move, drag the selected `Activate` target handle from Active to `Dispatch decision` (or use explicitly labelled Reconnect target). Expected R1 differs from S5 only in relation41's semantic target: 35 → 36. Route presentation fields and every other relation field remain unchanged. Undo must restore S5. This distinguishes semantic reconnect from Adjust; do not mix its checkpoint with S5. If cardinality/validity blocks the trial, record it and do not claim reconnect success without a valid alternative approved against source rules.
## Real semantic ports regression
Open **05 Status — semantic ports regression**. Select the `Readiness status` functional exchange. Both attachment Move/Reset actions must be disabled or absent because source_port52 and target_port53 remain authoritative. Drag Status output to its owner's Top/0.25 position through the established port-layout interaction, then Undo. That trial may change only the emitting node's port_anchor and must restore S5 exactly on Undo; relation/source_port/target_port identities must never change. No edge-attachment record should be created by port dragging. Record UI hit selection and full saved Project, then leave this trial out of the final OA/State checkpoint.
## Acceptance and limits
Require exact full-Project checks for every named checkpoint, normal-close/reopen retention, source/target discoverability, useful previews, and cancellation without hidden history. Inspect native fit/readability and any port/label/handle overlap; reduce claim or fix defects rather than interpreting screenshots as model proof. This fixture does not establish Process/Scenario authoring support, all state symbols, every projection, promoted/folded endpoints, continuous held-motion performance, or exact camera persistence. Those require separate evidence; targeted automated tests remain complementary. Runtime source parsing/validation and native execution are pending parent gates.
### Prepared layout clearance correction
The edited State view places Ready and Active at y=210 and Startup at y=252. The reference view intentionally retains its earlier positions. Region header clearance reaches y=180 (104 + 64 + 12); the planned Ready top lead now ends at y=192 (210 - 18), above the card but below the header obstacle. This changes only the three edited-view placements; the manual waypoint (500,310), both route labels, semantic objects, and reference view are preserved. `crates/oa-core/tests/attachment_workshop.rs` validates the fixture and routes the named edited edges at every planned checkpoint. Test execution is pending the parent gate; no clear-path claim is made from static inspection alone.
@@ -0,0 +1,51 @@
# Transition lifecycle: closed-gate evidence
Closed Cargo-gate checkpoint with eight runs. Native work remains separate.
Run `python verify.py` using
Python 3.10 or newer. The five package files are sufficient; no archived code,
binary, Git command or network operation is executed by verification.
Raw logs/receipts preserve the initial lifecycle-1 fixture admission failure as
a distinct input state. Successful core-transition-2, desktop-workflow-1 and
core-workflows-1 inputs are bound to
acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8 by an independent local comparison of
all 710 captured input hashes with exact committed bytes using Git cat-file.
The readback summary records Git blob identities; its raw Git stream is not
archived. Offline verification checks those blob hashes and input relationships,
not authenticated membership of a remote commit. Receipt HEADs may precede dirty
changes, so per-run input manifests are authoritative for tested source bytes.
Strict all-target workspace Clippy passed on those tested inputs. The first
format check failed only on an inherited tuple layout in diagram/focus.rs;
the raw failure is retained. Commit d46854c2dc719bb8b44a0978102de44ecc3a6b39
reformats that tuple and the second format check passes. Its separate 710-input
inventory is independently compared with that commit; per-run inputCommit
bindings and formattingInputChanges distinguish these source states. Earlier
execution and lint results are not represented as reruns on the formatted
commit. No application behavior change is claimed from formatting evidence.
The Windows workspace build completed on d46854c with the same 710 unchanged
inputs as format-2 and three executable artifact receipts. Those recorded
artifacts identify CLI, server and desktop binaries; binaries are not packaged.
| Gate | Recorded outcome |
| --- | --- |
| lifecycle-1 | Initial fixture admission failed: 6 failures |
| core-transition-2 | 10 passed |
| desktop-workflow-1 | 25 passed, 1,164 filtered out |
| core-workflows-1 | 28 passed |
| clippy-1 | Strict workspace/all-target lint passed |
| format-1 | Initial tuple-format check failed |
| format-2 | Format check passed with empty streams |
| windows-build-1 | Build passed; three executable artifact records |
The manifest maps each run to exact manifests and archives; only byte-identical
input objects are deduplicated. Verification checks safe exact ZIP membership,
hashes/lengths, source inventory and contents, 71 vendored iced_widget files,
helper/start/process/result relationships, time order, commands, exit outcomes,
unchanged input assertions, exact test summary rows and artifact receipt records.
Outer text uses UTF-8 LF before pinning; inner bytes remain raw.
This is process evidence and internal consistency, not a test rerun, proof of
complete coverage, native acceptance or release readiness. Binaries, dependency
caches and credentials are excluded; artifact readback hashes are historical
observations, not independently verified binaries.
@@ -0,0 +1,18 @@
{
"evidence.zip": {
"bytes": 8496343,
"sha256": "5f44ad5d83024a04a873142e77c5b8961c278a19d76492618c3ef2db63801070"
},
"manifest.json": {
"bytes": 14323,
"sha256": "7d48cb7e0788fb409b196f8be94c5947b30c698219bd016141fec136dbce3148"
},
"README.md": {
"bytes": 3017,
"sha256": "7946dd1317e326a6319c8e6399a6177ce251c6c8bc6c6c9cc7eac1517130d2db"
},
"verify.py": {
"bytes": 8239,
"sha256": "13cd285e8c5331bc9b1b7ada1ee7d3ebce4135570e45c047fb56c2f66b558700"
}
}
@@ -0,0 +1,477 @@
{
"schema": 1,
"testedSourceCommit": "acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8",
"formattedSourceCommit": "d46854c2dc719bb8b44a0978102de44ecc3a6b39",
"runs": {
"lifecycle-1": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-core",
"--test",
"transition_lifecycle",
"--message-format=json",
"--",
"--nocapture"
],
"head": "3f9e6a18af96b5cd10940da4093be8da28b9c835",
"exitCode": 101,
"inputCount": 708,
"kind": "cargo",
"inputs": "inputs/c637fea08bdfad17a86c335b58f79666a22b677174a84e817300451a11260a7a.json",
"archive": "inputs/6f54f538a71dfcd81cfe1309e6fa338f27f6e844f2f315983ef3556134e3eaf6.zip",
"testRows": [
[
0,
6,
0,
0,
0
]
],
"testTotals": [
0,
6,
0,
0,
0
]
},
"core-transition-2": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-core",
"--test",
"transition_lifecycle",
"--test",
"transition_counterpart_compatibility",
"--message-format=json",
"--",
"--nocapture"
],
"head": "3f9e6a18af96b5cd10940da4093be8da28b9c835",
"exitCode": 0,
"inputCount": 710,
"kind": "cargo",
"inputs": "inputs/65138ba4ac5843e411a068a8309dfe62c0c4186579aec9e755f6decbed8ecc26.json",
"archive": "inputs/e68321a18e2c1951263744ac9a4e505b18d4bdd520992360f652937fd0abf88b.zip",
"inputCommit": "acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8",
"testRows": [
[
4,
0,
0,
0,
0
],
[
6,
0,
0,
0,
0
]
],
"testTotals": [
10,
0,
0,
0,
0
]
},
"desktop-workflow-1": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-desktop",
"--bin",
"arcadia-studio",
"--message-format=json",
"workflow::",
"--",
"--nocapture"
],
"head": "3f9e6a18af96b5cd10940da4093be8da28b9c835",
"exitCode": 0,
"inputCount": 710,
"kind": "cargo",
"inputs": "inputs/65138ba4ac5843e411a068a8309dfe62c0c4186579aec9e755f6decbed8ecc26.json",
"archive": "inputs/e68321a18e2c1951263744ac9a4e505b18d4bdd520992360f652937fd0abf88b.zip",
"inputCommit": "acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8",
"testRows": [
[
25,
0,
0,
0,
1164
]
],
"testTotals": [
25,
0,
0,
0,
1164
]
},
"core-workflows-1": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-core",
"--test",
"workflows",
"--message-format=json",
"--",
"--nocapture"
],
"head": "3f9e6a18af96b5cd10940da4093be8da28b9c835",
"exitCode": 0,
"inputCount": 710,
"kind": "cargo",
"inputs": "inputs/65138ba4ac5843e411a068a8309dfe62c0c4186579aec9e755f6decbed8ecc26.json",
"archive": "inputs/e68321a18e2c1951263744ac9a4e505b18d4bdd520992360f652937fd0abf88b.zip",
"inputCommit": "acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8",
"testRows": [
[
28,
0,
0,
0,
0
]
],
"testTotals": [
28,
0,
0,
0,
0
]
},
"clippy-1": {
"command": [
"cargo",
"clippy",
"--locked",
"--offline",
"--workspace",
"--all-targets",
"--message-format=json",
"--",
"-D",
"warnings"
],
"head": "acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8",
"exitCode": 0,
"inputCount": 710,
"kind": "cargo",
"inputs": "inputs/65138ba4ac5843e411a068a8309dfe62c0c4186579aec9e755f6decbed8ecc26.json",
"archive": "inputs/e68321a18e2c1951263744ac9a4e505b18d4bdd520992360f652937fd0abf88b.zip",
"inputCommit": "acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8",
"testRows": [],
"testTotals": [
0,
0,
0,
0,
0
]
},
"format-1": {
"command": [
"cargo",
"fmt",
"--all",
"--",
"--check"
],
"head": "acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8",
"exitCode": 1,
"inputCount": 710,
"kind": "cargo",
"inputs": "inputs/65138ba4ac5843e411a068a8309dfe62c0c4186579aec9e755f6decbed8ecc26.json",
"archive": "inputs/e68321a18e2c1951263744ac9a4e505b18d4bdd520992360f652937fd0abf88b.zip",
"inputCommit": "acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8",
"testRows": [],
"testTotals": [
0,
0,
0,
0,
0
]
},
"format-2": {
"command": [
"cargo",
"fmt",
"--all",
"--",
"--check"
],
"head": "d46854c2dc719bb8b44a0978102de44ecc3a6b39",
"exitCode": 0,
"inputCount": 710,
"kind": "cargo",
"inputs": "inputs/feb3e7ff11a74571fcfe290016131165d7be82e83b8e4a4daae1667e8a9feb3c.json",
"archive": "inputs/00a630ff5d3aa838a3d6ba582f3e54ce12fe45ab52569f4da2cc583be1b1f802.zip",
"inputCommit": "d46854c2dc719bb8b44a0978102de44ecc3a6b39",
"testRows": [],
"testTotals": [
0,
0,
0,
0,
0
]
},
"windows-build-1": {
"command": [
"cargo",
"build",
"--locked",
"--offline",
"--workspace",
"--bins",
"--message-format=json"
],
"head": "d46854c2dc719bb8b44a0978102de44ecc3a6b39",
"exitCode": 0,
"inputCount": 710,
"kind": "cargo",
"inputs": "inputs/feb3e7ff11a74571fcfe290016131165d7be82e83b8e4a4daae1667e8a9feb3c.json",
"archive": "inputs/00a630ff5d3aa838a3d6ba582f3e54ce12fe45ab52569f4da2cc583be1b1f802.zip",
"inputCommit": "d46854c2dc719bb8b44a0978102de44ecc3a6b39",
"testRows": [],
"testTotals": [
0,
0,
0,
0,
0
]
}
},
"members": {
"helpers/commit-readbacks.json": {
"bytes": 149015,
"sha256": "d78b2c4182f2b1db7413cc3eaa36614ade32686e520d6e565bab959b36067838"
},
"helpers/gate-transition.py": {
"bytes": 3500,
"sha256": "065a752452e297ba8a000ee69cfae3996b4e6df9b4edbe002f233a66c32e1c04"
},
"inputs/00a630ff5d3aa838a3d6ba582f3e54ce12fe45ab52569f4da2cc583be1b1f802.zip": {
"bytes": 2843714,
"sha256": "00a630ff5d3aa838a3d6ba582f3e54ce12fe45ab52569f4da2cc583be1b1f802"
},
"inputs/65138ba4ac5843e411a068a8309dfe62c0c4186579aec9e755f6decbed8ecc26.json": {
"bytes": 118907,
"sha256": "65138ba4ac5843e411a068a8309dfe62c0c4186579aec9e755f6decbed8ecc26"
},
"inputs/6f54f538a71dfcd81cfe1309e6fa338f27f6e844f2f315983ef3556134e3eaf6.zip": {
"bytes": 2836501,
"sha256": "6f54f538a71dfcd81cfe1309e6fa338f27f6e844f2f315983ef3556134e3eaf6"
},
"inputs/c637fea08bdfad17a86c335b58f79666a22b677174a84e817300451a11260a7a.json": {
"bytes": 118565,
"sha256": "c637fea08bdfad17a86c335b58f79666a22b677174a84e817300451a11260a7a"
},
"inputs/e68321a18e2c1951263744ac9a4e505b18d4bdd520992360f652937fd0abf88b.zip": {
"bytes": 2843705,
"sha256": "e68321a18e2c1951263744ac9a4e505b18d4bdd520992360f652937fd0abf88b"
},
"inputs/feb3e7ff11a74571fcfe290016131165d7be82e83b8e4a4daae1667e8a9feb3c.json": {
"bytes": 118907,
"sha256": "feb3e7ff11a74571fcfe290016131165d7be82e83b8e4a4daae1667e8a9feb3c"
},
"runs/clippy-1/process.json": {
"bytes": 199,
"sha256": "e8aa78d9ea3dd2b623d990a9481e70354707e6aa5a591cb7b93dba5180a90aa9"
},
"runs/clippy-1/result.json": {
"bytes": 1801,
"sha256": "b6b63d0069a0194d4a1fa91f17d2f6a69555087ec6a9224c322891a3b3047db2"
},
"runs/clippy-1/started.json": {
"bytes": 1405,
"sha256": "165e5bb3b433703336dff190ad594ff3438da491d3396398e616dc5bc700e376"
},
"runs/clippy-1/stderr.log": {
"bytes": 456,
"sha256": "e3de1e791b11f30e85b682df843625d4ca6556f7eb298d6a1bf310aa5b38aa30"
},
"runs/clippy-1/stdout.log": {
"bytes": 310455,
"sha256": "e2692121e9c3536fe9f673de7388c393f23e6fe22dc08bb191a37610392dc0f9"
},
"runs/core-transition-2/process.json": {
"bytes": 263,
"sha256": "39b8ea38cfb392a251a3279cb7d4904545702ef10a1fb870b5e866af843b8b3b"
},
"runs/core-transition-2/result.json": {
"bytes": 4667,
"sha256": "3ed748bc46d4e7ebf21881ab6ffea85da6f94a36dde0778068b7a0282212a49d"
},
"runs/core-transition-2/started.json": {
"bytes": 1484,
"sha256": "3cca432872797dddf8de33e0cf9f931274851acdc8002d5d3599a4fd43ca853d"
},
"runs/core-transition-2/stderr.log": {
"bytes": 489,
"sha256": "92fd897c8f9608b06cdca0033a5008a6f00c5c28c4a88b3cba4bcbb3aeb4f986"
},
"runs/core-transition-2/stdout.log": {
"bytes": 43653,
"sha256": "1c35f25e869d6151cbb6de224282a05395caa41d27cd8d2452872c651ba08dbf"
},
"runs/core-workflows-1/process.json": {
"bytes": 202,
"sha256": "b6a5f0a3c4887888198f10d8e93b2422a1559c4b1214162f6e0954a8f0826828"
},
"runs/core-workflows-1/result.json": {
"bytes": 3128,
"sha256": "89dccdd6655ffb2cd792d25ce76e7da258efbd884dc4ab47d0ba48f8aa23ec6e"
},
"runs/core-workflows-1/started.json": {
"bytes": 1413,
"sha256": "a33e662e9743d650f57401b7276b696f97d97b8c5a916c0c783465b02eb3fc3c"
},
"runs/core-workflows-1/stderr.log": {
"bytes": 271,
"sha256": "bb006c1bb74a6ce4d938f3eb0db36bb9179395fbc3a1b42a7dfb3edad691ca46"
},
"runs/core-workflows-1/stdout.log": {
"bytes": 43869,
"sha256": "a51bf33e8e8767c73efcdc176f615801c63ff3ec9e369586f640fc634d60c528"
},
"runs/desktop-workflow-1/process.json": {
"bytes": 223,
"sha256": "3360e8316f62834c74da7c08742e9f2e62856b7a43974f3fd311d95074bfe4cd"
},
"runs/desktop-workflow-1/result.json": {
"bytes": 3170,
"sha256": "a9626e778ea9626fe76080c95faeac312455c7fd6b7511f1b5887da07bb6676a"
},
"runs/desktop-workflow-1/started.json": {
"bytes": 1439,
"sha256": "cf9cee18e2a6cd06d1ed12306eecb79a056f2a85ca3913557a219302f9b650c5"
},
"runs/desktop-workflow-1/stderr.log": {
"bytes": 539,
"sha256": "84734875bfd88145ebb582986937ccaf5fad3fc32ca0b50d03f57554195742e5"
},
"runs/desktop-workflow-1/stdout.log": {
"bytes": 296022,
"sha256": "0ec72412bc1b3976e0c7470853a06efdf99f051aace8dad540fb3afc3a99bae2"
},
"runs/format-1/process.json": {
"bytes": 116,
"sha256": "3ed4ed5a510a0b116a3c0fd2baf3e4db7ff7de66b7df0a0464cc098841fff115"
},
"runs/format-1/result.json": {
"bytes": 1688,
"sha256": "1d5eac33f2174bcb464d89bc858483affe3c73d5016d19db7d0a32412d9ea475"
},
"runs/format-1/started.json": {
"bytes": 1297,
"sha256": "60fcb6b008530a620534fba89c0cb8e143d423fab2dd2fe08a19a72d21eab904"
},
"runs/format-1/stderr.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"runs/format-1/stdout.log": {
"bytes": 538,
"sha256": "e6eac9f78ecb50fdaa288974ac26df928f6aa437d293d5aaf9ac60190394b88f"
},
"runs/format-2/process.json": {
"bytes": 116,
"sha256": "20c7c2177f85bb157b2cfff08b26c447bebed42eb11e0e7f6b43c6664c4a494d"
},
"runs/format-2/result.json": {
"bytes": 1686,
"sha256": "b220a0401f478cde47f2e1386a311de58e11bf7937a722490ac630ab223ab279"
},
"runs/format-2/started.json": {
"bytes": 1297,
"sha256": "6d87fb899116fed854f2c2f2c358e0810103f462aa2f3f225feaf89b0855af6d"
},
"runs/format-2/stderr.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"runs/format-2/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"runs/lifecycle-1/process.json": {
"bytes": 213,
"sha256": "ef6976e24b19e881d5d435f5dd2d7a54fd228f8013be6cd4a97110a5078f4494"
},
"runs/lifecycle-1/result.json": {
"bytes": 3188,
"sha256": "149a0f3c8747b3c5227916aa88e4b94b77415b0c8d765445e92bfdd201069fb0"
},
"runs/lifecycle-1/started.json": {
"bytes": 1425,
"sha256": "3e3458c4227da8bfbc4d4b6244fe0510687b8c98c2167a4802b2b691431565fa"
},
"runs/lifecycle-1/stderr.log": {
"bytes": 21458,
"sha256": "2e13355db62b04032baa7007f7d7d691eadaad974f4fde8e0bf0ddd4a8b12027"
},
"runs/lifecycle-1/stdout.log": {
"bytes": 42779,
"sha256": "9d545eaba4c279233a344b1e8bc845d0df4dbce5de5c4af9de14d288d3d8d7d8"
},
"runs/windows-build-1/process.json": {
"bytes": 167,
"sha256": "5292a76450540df9e145cd21cc20118b310ed306126da85e893d0c9c221a8564"
},
"runs/windows-build-1/result.json": {
"bytes": 5583,
"sha256": "91b38045319adc1fb9aa0e51d45d79a4be99d2de9aec08a47f73915e3ac9dd79"
},
"runs/windows-build-1/started.json": {
"bytes": 1358,
"sha256": "826453a44cd915065caf1db9c0cc547dca0fa5a2b56594e4a72146b19ffe9e8c"
},
"runs/windows-build-1/stderr.log": {
"bytes": 276,
"sha256": "0a035e700f490f73889da441e363052b34f33b3de5690c30eb1e6c2666c75bc6"
},
"runs/windows-build-1/stdout.log": {
"bytes": 295257,
"sha256": "6ecb53aef4a76dda4174213dd5ba9b03aabc1434577c3141c4ed3e71adf068fa"
}
},
"formattingInputChanges": {
"crates/oa-desktop/src/diagram/focus.rs": {
"before": {
"bytes": 9197,
"sha256": "b88a7c43123b08cf6228d8e32257f6ed7fe361f7278f427ec138e233e1ea6ccf"
},
"after": {
"bytes": 9276,
"sha256": "44e7c13f8f56deb21c62cb7757a412ec8b790344387cebdee983c3a28b6b0160"
}
}
}
}
@@ -0,0 +1,112 @@
"""Read-only, Python-standard-library verification. Never executes archived code."""
from pathlib import Path, PurePosixPath
from datetime import datetime
import hashlib
import io
import json
import re
import zipfile
ROOT = Path(__file__).resolve().parent
def require(ok, message):
if not ok:
raise ValueError(message)
def digest(data):
return {'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()}
def members(z, expected):
names = z.namelist()
require(len(names) == len(set(names)), 'duplicate ZIP members')
require(set(names) == set(expected), 'archive membership mismatch')
require(all(not PurePosixPath(n).is_absolute() and '..' not in PurePosixPath(n).parts
and '\\' not in n and ':' not in n for n in names), 'unsafe member')
def main():
inventory = json.loads((ROOT / 'files.json').read_text())
for name, expected in inventory.items():
require(digest((ROOT / name).read_bytes()) == expected, 'external file hash: ' + name)
manifest = json.loads((ROOT / 'manifest.json').read_text())
with zipfile.ZipFile(ROOT / 'evidence.zip') as z:
members(z, manifest['members'])
for name, expected in manifest['members'].items():
require(digest(z.read(name)) == expected, 'member hash: ' + name)
def raw(name): return z.read(name)
def obj(name): return json.loads(raw(name))
checked = set()
for run, spec in manifest['runs'].items():
if spec['kind'] != 'cargo':
continue
prefix = 'runs/' + run + '/'
start, result, process = (obj(prefix + n + '.json') for n in ('started', 'result', 'process'))
require(all(result[k] == v for k, v in start.items()), run + ': start/result relationship')
require(result['command'] == spec['command'] == process['command'], run + ': command')
require(result['head'] == spec['head'], run + ': HEAD')
require(result['exitCode'] == spec['exitCode'] and result['inputsUnchanged'] is True, run + ': outcome')
require(datetime.fromisoformat(result['finish']) >= datetime.fromisoformat(start['start']), run + ': times')
require(result['seconds'] >= 0, run + ': duration')
require(process['pid']>0 and datetime.fromisoformat(start['start'])<=datetime.fromisoformat(process['observed'])<=datetime.fromisoformat(result['finish']), run+': process observation')
require(result['helper'] == digest(raw('helpers/gate-transition.py')), run + ': helper')
for field, file in [('inputs', spec['inputs']), ('archive', spec['archive']), ('stdout', prefix+'stdout.log'), ('stderr', prefix+'stderr.log')]:
require(result[field] == digest(raw(file)), run + ': receipt ' + field)
inputs = obj(spec['inputs'])
require(len(inputs) == result['inputCount'] == spec['inputCount'], run + ': input count')
require(sum(p.startswith('crates/vendor/iced_widget/') for p in inputs) == 71, run + ': vendor count')
pair = (spec['inputs'], spec['archive'])
if pair not in checked:
with zipfile.ZipFile(io.BytesIO(raw(spec['archive']))) as source:
members(source, inputs)
for name, expected in inputs.items():
require(digest(source.read(name)) == expected, run + ': source hash ' + name)
checked.add(pair)
stdout = raw(prefix+'stdout.log').decode('utf-8', errors='replace')
rows = [[int(x) for x in row] for row in re.findall(r'test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored; (\d+) measured; (\d+) filtered out;', stdout)]
require(rows == spec['testRows'], run + ': exact test summaries')
require([sum(row[i] for row in rows) for i in range(5)] == spec['testTotals'], run + ': test totals')
artifacts = []
for line in stdout.splitlines():
try: item = json.loads(line)
except json.JSONDecodeError: continue
if isinstance(item, dict) and item.get('reason') == 'compiler-artifact' and item.get('executable'):
artifacts.append(item)
require(artifacts == [a['cargo'] for a in result['artifacts']], run + ': artifact receipt relationship')
print(run, 'exit', result['exitCode'], 'test totals', spec['testTotals'])
readbacks=obj('helpers/commit-readbacks.json')
require(set(readbacks)=={spec['inputCommit'] for spec in manifest['runs'].values() if 'inputCommit' in spec}, 'readback commit coverage')
base_inputs=obj(manifest['runs']['core-transition-2']['inputs'])
for commit,readback in readbacks.items():
require(readback['status']=='PASS' and readback['commit']==commit, 'commit readback status')
specs=[spec for spec in manifest['runs'].values() if spec.get('inputCommit')==commit]
success=specs[0];inputs=obj(success['inputs'])
require(all(obj(spec['inputs'])==inputs for spec in specs), 'per-commit input equality')
require(readback['inputs']==digest(raw(success['inputs'])) and len(inputs)==readback['inputCount']==710, '710 readback')
require(set(readback['gitBlobs'])==set(inputs), 'readback blob inventory')
with zipfile.ZipFile(io.BytesIO(raw(success['archive']))) as source:
for name,oid in readback['gitBlobs'].items():
b=source.read(name)
require(hashlib.sha1(b'blob '+str(len(b)).encode()+b'\0'+b).hexdigest()==oid, 'readback blob '+name)
if commit==manifest['formattedSourceCommit']:
changed={p:{'before':base_inputs.get(p),'after':inputs.get(p)} for p in sorted(set(base_inputs)|set(inputs)) if base_inputs.get(p)!=inputs.get(p)}
require(changed==manifest['formattingInputChanges'] and set(changed)=={'crates/oa-desktop/src/diagram/focus.rs'}, 'one-file formatting input difference')
with zipfile.ZipFile(io.BytesIO(raw(manifest['runs']['core-transition-2']['archive']))) as old, zipfile.ZipFile(io.BytesIO(raw(success['archive']))) as new:
path='crates/oa-desktop/src/diagram/focus.rs'
before=old.read(path);after=new.read(path)
compact=b' != (self.context.document, self.context.diagram, self.context.epoch)'
expanded=b' != (\n self.context.document,\n self.context.diagram,\n self.context.epoch,\n )'
require(before.count(compact)==1 and before.replace(compact,expanded,1)==after, 'exact tuple-format-only delta')
for run in ('core-transition-2','desktop-workflow-1','core-workflows-1','clippy-1','format-1'):
if run in manifest['runs']:require(manifest['runs'][run]['inputCommit']==manifest['testedSourceCommit'], 'earlier execution source scope '+run)
for run in ('format-2','windows-build-1'):
if run in manifest['runs']:require(manifest['runs'][run]['inputCommit']==manifest['formattedSourceCommit'], 'formatted source scope '+run)
if 'format-2' in manifest['runs']:
require(not raw('runs/format-2/stdout.log') and not raw('runs/format-2/stderr.log'), 'format2 empty success streams')
if 'windows-build-1' in manifest['runs']:
build=obj('runs/windows-build-1/result.json')
require(build['exitCode']==0 and len(build['artifacts'])==3, 'Windows build outcome')
require({a['cargo']['target']['name'] for a in build['artifacts']}=={'arcadia','arcadia-server','arcadia-studio'}, 'Windows build executable membership')
require(obj(manifest['runs']['lifecycle-1']['inputs'])!=base_inputs, 'initial failure remains distinct')
print('PASS:',len(manifest['runs']),'closed runs;',len(manifest['members']),'members;',len(checked),'input pairs;710 successful input blob bindings.')
print('No archived code executed. Local commit-readback summary is not authenticated commit membership.')
if __name__=='__main__':main()
@@ -0,0 +1,55 @@
# Closed lifecycle checker and footer gates
Run `python verify.py` with Python 3.10 or newer. Verification uses only these
package files and the standard library; it does not execute archived source,
binaries, Cargo, Git or network requests. `prepare.py [CHECKOUT]` reproducibly
packages the explicitly named raw captures if those local originals are present.
The input capsules are source snapshots, not a hermetic toolchain distribution.
Ten closed Cargo runs are preserved: footer-format-1, footer-windows-build-1,
footer-windows-build-2, footer-clippy-1, lifecycle-producer-build-1,
lifecycle-producer-generate-1, lifecycle-producer-build-2, footer-windows-build-3,
footer-format-2 and lifecycle-producer-format-1. Each remains bound
to its original command, source inventory, helper, logs and receipts. The first
footer build has no JSON executable artifact records; the second captures them.
Recorded HEAD d0b7c28 does not identify uncommitted footer/producer source bytes;
the archived per-run input manifests do.
The three final gates use the same 715-file manifest, SHA-256
`b5a62817a1b5df936b9d759241bdfc4e8283901c1d4b540b9e494995e1544d8d`.
Compared with producer build2's captured inputs, only the footer literal changes
from "changes" to "reviewed items". The verifier checks that exact byte delta.
The final GUI build and both format checks pass. Earlier lint and oracle captures
retain their original input scope; they are not presented as reruns of the final
wording. The checker source is unchanged by that label refinement.
Producer build1 and its generation succeeded, but that older checker incorrectly
rejected expected engineering warnings during saved-file admission. Build2
contains the corrected admission logic. The 40 PASS falsifier trials are bound
to build2's exact checker artifact receipt and producer source, not to build1.
Build success alone does not demonstrate checker correctness. The earlier native
admission failure is outside these selected captures, not fabricated as a Cargo
failure or silently converted into successful native evidence.
The oracle archive retains all original fixtures, repeated generation outputs,
mutated specimens, identity receipts, copied build evidence, runner/source,
stdout/stderr and per-trial results. The 40 expected outcomes include accepted
original/deleted/recreated/relinked states and deliberate rejection cases for
retained prose/geometry corruption, source/trace identity drift, missing persisted
IDs, absent identity receipts and receipt overwrite. These are synthetic checker
tests, not native interaction. A PASS summary includes expected negative exits.
`manifest.json` maps every logical raw path to a stored member. Only byte-identical
inputs.json/inputs.zip files are deduplicated; exact run mapping is retained.
`files.json` pins outer files including the reproducible packager. Outer text is
UTF-8 LF; all raw archived bytes are preserved. The verifier checks exact safe ZIP
membership, hashes, nested input contents/counts, helpers, timestamps, receipt and
binary-pin relationships, repeated fixture equality, watched specimen identities,
all 40 outcomes and their recorded rejection text. It rereads evidence rather
than rerunning the model checker, and does not authenticate historical execution.
Native trial1 lacks a closing exit receipt and is excluded. No successful native
deletion, Undo, relinking, save/reopen, or full lifecycle acceptance is claimed.
No runtime binaries, credentials or whole user profile are included. Recorded
absolute paths are historical labels; verification maps them to archive members
without accessing the original machine. Binary hashes remain receipt observations.
@@ -0,0 +1,22 @@
{
"evidence.zip": {
"bytes": 14254708,
"sha256": "663bf83db4ead276063b321fd5e9857e29d2305e6046c2e87dbbac87a366b19c"
},
"manifest.json": {
"bytes": 79071,
"sha256": "5d2be3d68206871a560dd1a83064ff779e8aab6e11302d09aaf1a655502dd38e"
},
"README.md": {
"bytes": 3696,
"sha256": "0e33fe1a85a1ee17ebde57ed7994280dcb95d3a7c4338b78c1f7e0ae9fdcbb7d"
},
"verify.py": {
"bytes": 10908,
"sha256": "1e8ba1cc2759fbe76e14636a37925c9dfce9a6f14bf63569acb76498bd8380ad"
},
"prepare.py": {
"bytes": 3572,
"sha256": "147a63f26b7131076df52057f1cc263dfefdf99d937b2c2a819217a46c56105f"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
"""Repackage the explicitly closed captures; reads raw evidence, never runs Cargo."""
from pathlib import Path
import hashlib, json, sys, zipfile
OUT=Path(__file__).resolve().parent
ROOT=Path(sys.argv[1]).resolve() if len(sys.argv)>1 else OUT.parents[3]
GATES=ROOT/'.local/transition-gates'
ORACLE=ROOT/'.local/transition-native/oracle-falsifiers-1'
RUNS=['footer-format-1','footer-windows-build-1','footer-windows-build-2','footer-clippy-1',
'lifecycle-producer-build-1','lifecycle-producer-generate-1','lifecycle-producer-build-2',
'footer-windows-build-3','footer-format-2','lifecycle-producer-format-1']
def pin(b):return {'bytes':len(b),'sha256':hashlib.sha256(b).hexdigest()}
def main():
payload={};logical={};run_specs={}
def add(name,b):
# Deduplicate only byte-identical input manifests and source archives.
stored='capsules/'+pin(b)['sha256']+Path(name).suffix if Path(name).name in ('inputs.json','inputs.zip') else name
if stored in payload:assert payload[stored]==b
payload[stored]=b;logical[name]=stored
for run in RUNS:
result=json.loads((GATES/run/'result.json').read_bytes())
assert 'finish' in result and result['inputsUnchanged'] is True,run
for f in sorted((GATES/run).iterdir()):
assert f.is_file(),f
add('cargo/'+run+'/'+f.name,f.read_bytes())
helpers=[f for f in GATES.glob('*.py') if pin(f.read_bytes())==result['helper']]
assert helpers,run+' helper unavailable'
helper='helpers/'+result['helper']['sha256']+'.py'
add(helper,helpers[0].read_bytes())
run_specs[run]={k:result[k] for k in ('command','head','exitCode','inputCount')}
run_specs[run]['helper']=helper
for f in sorted(ORACLE.rglob('*')):
if f.is_file():add('oracle/'+f.relative_to(ORACLE).as_posix(),f.read_bytes())
summary=json.loads((ORACLE/'summary.json').read_bytes())
assert summary['status']=='PASS' and summary['trialCount']==40
manifest={'schema':1,'captureCheckout':'d0b7c285018a516c6dc34c196d1359a861ddaeb3',
'cargo':run_specs,'oracleExpectedTrials':summary['trials'],
'oracleOriginalDirectory':str(ORACLE).replace('\\','/'),
'logicalFiles':dict(sorted(logical.items())),
'members':{n:pin(b) for n,b in sorted(payload.items())}}
with zipfile.ZipFile(OUT/'evidence.zip','w',compression=zipfile.ZIP_DEFLATED,compresslevel=9) as z:
for name,b in sorted(payload.items()):
info=zipfile.ZipInfo(name,date_time=(2026,9,13,0,0,0));info.compress_type=zipfile.ZIP_DEFLATED
z.writestr(info,b)
(OUT/'manifest.json').write_bytes((json.dumps(manifest,indent=2)+'\n').encode())
for name in ('README.md','verify.py','prepare.py'):
(OUT/name).write_bytes((OUT/name).read_bytes().replace(b'\r\n',b'\n'))
files={name:pin((OUT/name).read_bytes()) for name in ('evidence.zip','manifest.json','README.md','verify.py','prepare.py')}
(OUT/'files.json').write_bytes((json.dumps(files,indent=2)+'\n').encode())
with zipfile.ZipFile(OUT/'evidence.zip') as z:
assert all(z.read(logical[n])==b for n,b in (
[('cargo/'+r+'/'+f.name,f.read_bytes()) for r in RUNS for f in sorted((GATES/r).iterdir())]+
[('oracle/'+f.relative_to(ORACLE).as_posix(),f.read_bytes()) for f in sorted(ORACLE.rglob('*')) if f.is_file()]))
print(json.dumps(files,indent=2))
print('Exact raw fidelity:',len(logical),'logical files;',len(payload),'stored members')
if __name__=='__main__':main()
@@ -0,0 +1,132 @@
"""Read-only standard-library verification; no archived source/binary execution."""
from pathlib import Path, PurePosixPath
from datetime import datetime
import hashlib, io, json, zipfile
ROOT=Path(__file__).resolve().parent
def pin(b):return {'bytes':len(b),'sha256':hashlib.sha256(b).hexdigest()}
def need(ok,msg):
if not ok:raise ValueError(msg)
def safe(n):return not PurePosixPath(n).is_absolute() and '..' not in PurePosixPath(n).parts and '\\' not in n and ':' not in n
def members(z,expected):
names=z.namelist()
need(len(names)==len(set(names)) and set(names)==set(expected),'archive membership')
need(all(safe(n) for n in names),'unsafe archive member')
def time_order(*values):return list(map(datetime.fromisoformat,values))==sorted(map(datetime.fromisoformat,values))
def main():
files=json.loads((ROOT/'files.json').read_bytes())
need(set(files)=={'evidence.zip','manifest.json','README.md','verify.py','prepare.py'},'outer inventory')
for n,p in files.items():need(pin((ROOT/n).read_bytes())==p,'outer hash '+n)
m=json.loads((ROOT/'manifest.json').read_bytes())
with zipfile.ZipFile(ROOT/'evidence.zip') as z:
members(z,m['members'])
for n,p in m['members'].items():need(pin(z.read(n))==p,'member hash '+n)
logical=m['logicalFiles']
need(all(safe(n) for n in logical) and set(logical.values())==set(m['members']),'logical mapping')
def raw(n):return z.read(logical[n])
def obj(n):return json.loads(raw(n))
def directory(prefix):return {n[len(prefix):]:pin(raw(n)) for n in logical if n.startswith(prefix)}
pairs=set()
for run,spec in m['cargo'].items():
p='cargo/'+run+'/'
start=obj(p+'started.json');result=obj(p+'result.json');process=obj(p+'process.json')
need(all(result[k]==v for k,v in start.items()),run+' start/result')
need(result['command']==process['command']==spec['command'],run+' commands')
need(result['head']==spec['head'] and result['exitCode']==spec['exitCode'],run+' recorded outcome')
need(result['inputsUnchanged'] is True and result['seconds']>=0,run+' source/duration')
need(process['pid']>0 and time_order(start['start'],process['observed'],result['finish']),run+' process times')
for field,n in [('inputs','inputs.json'),('archive','inputs.zip'),('stdout','stdout.log'),('stderr','stderr.log')]:need(result[field]==pin(raw(p+n)),run+' '+field)
need(result['helper']==pin(raw(spec['helper'])),run+' helper')
inputs=obj(p+'inputs.json')
need(len(inputs)==result['inputCount']==spec['inputCount'],run+' input count')
need(sum(n.startswith('crates/vendor/iced_widget/') for n in inputs)==71,run+' vendor inventory')
pair=(logical[p+'inputs.json'],logical[p+'inputs.zip'])
if pair not in pairs:
with zipfile.ZipFile(io.BytesIO(raw(p+'inputs.zip'))) as source:
members(source,inputs)
for n,expected in inputs.items():need(pin(source.read(n))==expected,run+' source '+n)
pairs.add(pair)
artifacts=[]
for line in raw(p+'stdout.log').decode('utf-8',errors='replace').splitlines():
try:item=json.loads(line)
except json.JSONDecodeError:continue
if isinstance(item,dict) and item.get('reason')=='compiler-artifact' and item.get('executable'):artifacts.append(item)
need(artifacts==[a['cargo'] for a in result['artifacts']],run+' artifact relationships')
print(run,'exit',result['exitCode'],'inputs',len(inputs),'artifact records',len(artifacts))
# Successful build1/generation are not the corrected checker admission.
older=obj('cargo/lifecycle-producer-build-1/inputs.json')
newer=obj('cargo/lifecycle-producer-build-2/inputs.json')
need(older==obj('cargo/lifecycle-producer-generate-1/inputs.json'),'generation uses older producer inputs')
source_path='harness/experiments/transition-native-lifecycle-20260913/producer/src/main.rs'
need(older[source_path]!=newer[source_path],'distinct producer revisions')
final_runs=('footer-windows-build-3','footer-format-2','lifecycle-producer-format-1')
final_inputs=obj('cargo/footer-windows-build-3/inputs.json')
final_pin='b5a62817a1b5df936b9d759241bdfc4e8283901c1d4b540b9e494995e1544d8d'
for run in final_runs:
p='cargo/'+run+'/'
need(pin(raw(p+'inputs.json'))['sha256']==final_pin and obj(p+'inputs.json')==final_inputs,run+' final source manifest')
need(obj(p+'result.json')['exitCode']==0 and len(final_inputs)==715,run+' final successful gate')
for run in final_runs[1:]:
need(not raw('cargo/'+run+'/stdout.log') and not raw('cargo/'+run+'/stderr.log'),run+' empty format streams')
final_build=obj('cargo/footer-windows-build-3/result.json')
need(len(final_build['artifacts'])==1 and final_build['artifacts'][0]['cargo']['target']['name']=='arcadia-studio','final GUI artifact')
changed={n for n in set(newer)|set(final_inputs) if newer.get(n)!=final_inputs.get(n)}
need(changed=={'crates/oa-desktop/src/workflow.rs'},'final wording-only file scope')
with zipfile.ZipFile(io.BytesIO(raw('cargo/lifecycle-producer-build-2/inputs.zip'))) as before, zipfile.ZipFile(io.BytesIO(raw('cargo/footer-windows-build-3/inputs.zip'))) as after:
path='crates/oa-desktop/src/workflow.rs';a=before.read(path);b=after.read(path)
old='{} changes · {} boundary choices · {} conflicts · {} warnings'.encode()
new='{} reviewed items · {} boundary choices · {} conflicts · {} warnings'.encode()
need(a.count(old)==1 and a.replace(old,new,1)==b,'exact final footer label delta')
build=obj('cargo/lifecycle-producer-build-2/result.json')
artifact=[a for a in build['artifacts'] if a['cargo']['target']['name']=='transition-native-lifecycle']
need(len(artifact)==1,'corrected checker artifact')
# Oracle copied build capsule must match the independently included gate.
for name in ('result.json','started.json','process.json','inputs.json','inputs.zip','stdout.log','stderr.log'):
need(raw('oracle/build-capture/'+name)==raw('cargo/lifecycle-producer-build-2/'+name),'oracle build capsule '+name)
need(pin(raw('oracle/build-capture/build-helper.py'))==build['helper'],'oracle build helper')
start=obj('oracle/started.json');summary=obj('oracle/summary.json')
need(summary['status']=='PASS' and summary['trialCount']==40,'oracle completion')
need(all(summary['context'][k]==v for k,v in start.items() if k!='start'),'oracle context')
need(start['checker']==artifact[0]['readback'] and start['buildReceipt']==pin(raw('cargo/lifecycle-producer-build-2/result.json')),'oracle checker chain')
need(start['runner']==pin(raw('oracle/oracle-falsifiers.py')),'oracle runner pin')
for path,p in directory('oracle/producer/').items():need(p==newer['harness/experiments/transition-native-lifecycle-20260913/producer/'+path],'oracle producer source '+path)
fixtures=directory('oracle/fixtures/')
need(fixtures==start['fixtureInput']==directory('oracle/repeat-generation-a/')==directory('oracle/repeat-generation-b/'),'deterministic repeated fixtures')
need(directory('oracle/specimens/')==summary['specimens'],'mutation inventory')
rows=summary['trials']
need(rows==m['oracleExpectedTrials'] and len(rows)==len({r['label'] for r in rows})==40,'forty distinct trials')
actual_labels={n.split('/')[2] for n in logical if n.startswith('oracle/trials/')}
need(actual_labels=={r['label'] for r in rows},'exact trial membership')
# Original absolute names are mapped to archive members, never opened.
original=m['oracleOriginalDirectory'].rstrip('/')+'/'
def mapped(path):
norm=path.replace('\\','/')
need(norm.startswith(original),'unmapped trial input path')
rel=norm[len(original):];need(safe(rel),'unsafe trial input mapping')
return 'oracle/'+rel
for row in rows:
label=row['label'];p='oracle/trials/'+label+'/'
r=obj(p+'result.json')
need(r['label']==label and r['exitCode']==r['expectedExitCode']==row['exitCode']==row['expectedExitCode'],label+' outcome')
need(time_order(start['start'],r['start'],r['finish'],summary['finish']),label+' time order')
need(r['checkerBefore']==r['checkerAfter']==start['checker'] and r['runner']==start['runner'] and r['buildReceipt']==start['buildReceipt'],label+' provenance')
for stream in ('stdout','stderr'):need(r[stream]==pin(raw(p+stream+'.log')),label+' '+stream)
need(r['watchedBefore']==r['watchedAfter'],label+' unchanged inputs')
for path,expected in r['watchedBefore'].items():need(pin(raw(mapped(path)))==expected,label+' watched input')
cmd=r['command'];need(cmd[0]==artifact[0]['cargo']['executable'],label+' executable command')
if cmd[1]=='check':
need(mapped(cmd[3]) in logical,label+' check entry')
opts=cmd[4:];need(len(opts)%2==0,label+' option arity')
for flag,path in zip(opts[::2],opts[1::2]):
need(flag in ('--identities','--write-identities') and mapped(path) in logical,label+' identity option')
if r['exitCode']==0:
report=obj(p+'stdout.log')
need(report['status']=='PASS' and report['comparison']=='complete Project equality' and report['state']==cmd[2] and report['entry']==cmd[3],label+' positive report')
if cmd[2].endswith(('-reopened','-noop')):need(report['trace_identity_pinned'] is True and '--identities' in opts,label+' continuity receipt')
if '--write-identities' in opts:need(obj(p+'written-identities.json')==report['new_ids']==obj(mapped(opts[opts.index('--write-identities')+1])),label+' identity write')
else:need(cmd[1]=='generate' and len(cmd)==3,label+' generation command')
stderr=raw(p+'stderr.log').decode('utf-8',errors='replace')
if r['expectedStderrFragment']:need(r['expectedStderrFragment'] in stderr,label+' rejection reason')
elif r['exitCode']==0:need(not stderr,label+' successful stderr')
print('PASS:',len(m['cargo']),'closed Cargo runs; 40 recorded oracle trials;',len(logical),'logical raw files;',len(m['members']),'stored members;',len(pairs),'source capsule pairs.')
print('Offline consistency only: no binaries/source executed; no native lifecycle completion claim.')
if __name__=='__main__':main()
@@ -0,0 +1,45 @@
# Closed native transition lifecycle captures
Keep this directory beside `../checks/`. Run `python verify.py` with Python 3.10
or newer. Verification requires the exact pinned `../checks/evidence.zip` and
these native package files, uses only the standard library, and executes no
archived helper, application, checker, Cargo command or network request.
`prepare.py [CHECKOUT]` rebuilds this archive from the named closed originals.
Trials lifecycle-2/3 capture OA deletion, recreation, first relink, a separate
reopening and no-op check. Trials lifecycle-4/5 capture reverse SA deletion,
Undo/Redo, recreation, first relink, reopening and no-op. All four GUI receipts
close successfully with empty stdout/stderr and unchanged fixture/binary pins.
Trial1 is excluded entirely because it lacked a closing process receipt. This
package does not turn that interrupted history into successful evidence.
Trials 2/3 use footer-windows-build-2; trials 4/5 use footer-windows-build-3 with
the refined "reviewed items" footer. Snapshot checks use the corrected producer
build2. Exact build receipts, helpers, artifact records and complete source
capsules are read from the pinned sibling checks archive; source proof is not
self-contained in the native archive alone. Runtime binaries are excluded.
Every original frame, adjacent metadata, trial file, accepted identity receipt,
saved source snapshot and helper is retained without editing. The launch-pinned
captureHelper refers to the corresponding capture-lifecycle-N.js source. Each
trial's separately recorded capture-function.js is the serialized function used
in the persistent UI session; it is different bytes and was not that launch pin.
The files document the distinction rather than asserting byte equivalence or
authenticated execution. Frame labels/timestamps/application paths record states,
not a complete action invocation log or independent PID attestation. Image/window
dimensions vary; these captures are not all minimum-window checks.
The verifier checks exact safe archive membership, hashes, linked build/source
capsules, trial/helper/stream relationships, frame membership and timestamps,
snapshot helper and corrected-checker provenance, and the recorded complete
Project comparison results. Accepted first-relink IDs match later reopen/no-op
receipts in each direction, and both later saved source bundles are byte-identical
to their first-relink bundle. The producer's full-model result is recorded evidence;
the portable verifier does not rerun its Rust comparisons or derive model equality
from screenshots. Snapshot receipts record start time but no completion timestamp;
only their start can be located in the GUI process interval.
These finite workflows do not establish exhaustive usability/accessibility,
performance, deployment readiness, or behavior on another platform. No credentials,
whole user profile, or runtime binaries are packaged. Outer text is UTF-8 LF;
archived raw bytes and historical local-path strings remain unchanged.
@@ -0,0 +1,22 @@
{
"evidence.zip": {
"bytes": 6888298,
"sha256": "b53253fbe9b98db6a0028ed3629426460e7086c8943be0d13ab5ec540e7e4b68"
},
"manifest.json": {
"bytes": 56348,
"sha256": "f6bd0bb5e3d29d5e47f1dfb8f1cb7747a3c2e12aa62b6e4e81b68e68ce5ccf95"
},
"README.md": {
"bytes": 3019,
"sha256": "01aed34547156bede9c2fea75162c8fecda18203fa575c4cf81e28e440130a95"
},
"verify.py": {
"bytes": 10474,
"sha256": "5f93ff8b266c0c7c3e64630f64a522ff5f8f8bd41d7d0a14cc4856c16a529306"
},
"prepare.py": {
"bytes": 2861,
"sha256": "c1884b10724cc618b58abd84b93c03623ea9e80dbda4e5a8b866fff499e93fca"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
"""Package only closed lifecycle trials 2-5; never runs a GUI or checker."""
from pathlib import Path
import hashlib,json,sys,zipfile
OUT=Path(__file__).resolve().parent
ROOT=Path(sys.argv[1]).resolve() if len(sys.argv)>1 else OUT.parents[3]
SRC=ROOT/'.local/transition-native'
def pin(b):return {'bytes':len(b),'sha256':hashlib.sha256(b).hexdigest()}
def main():
payload={};runs={}
for number in (2,3,4,5):
run=f'lifecycle-{number}';folder=SRC/run
result=json.loads((folder/'result.json').read_bytes())
assert result['exitCode']==0 and 'finish' in result,run+' not closed successfully'
for f in sorted(folder.rglob('*')):
if f.is_file():payload['native/'+f.relative_to(SRC).as_posix()]=f.read_bytes()
helpers={}
for field,name in [('runner',f'run-lifecycle-{number}.py'),('launchCapture',f'capture-lifecycle-{number}.js'),('snapshot',f'check-lifecycle-snapshot-{number}.py')]:
helpers[field]='helpers/'+name;payload[helpers[field]]=(SRC/name).read_bytes()
captures=[n for n,b in payload.items() if n.startswith('native/'+run+'/') and n.endswith('.json') and isinstance(json.loads(b),dict) and 'images' in json.loads(b)]
snapshots={f.name:json.loads((f/'result.json').read_bytes())['command'][2] for f in sorted((folder/'snapshots').iterdir()) if f.is_dir()}
runs[run]={'helpers':helpers,'guiBuild':'footer-windows-build-2' if number<4 else 'footer-windows-build-3','captures':captures,'snapshots':snapshots}
for f in sorted((SRC/'lifecycle-input-1').rglob('*')):
if f.is_file():payload['fixtures/'+f.relative_to(SRC/'lifecycle-input-1').as_posix()]=f.read_bytes()
checks=OUT.parent/'checks'
payload['references/checks-manifest.json']=(checks/'manifest.json').read_bytes()
manifest={'schema':1,'checksEvidence':pin((checks/'evidence.zip').read_bytes()),
'checksRelativePath':'../checks/evidence.zip','originalNativeDirectory':str(SRC).replace('\\','/'),
'runs':runs,'members':{n:pin(b) for n,b in sorted(payload.items())}}
with zipfile.ZipFile(OUT/'evidence.zip','w',compression=zipfile.ZIP_DEFLATED,compresslevel=9) as z:
for n,b in sorted(payload.items()):
info=zipfile.ZipInfo(n,date_time=(2026,9,22,0,0,0));info.compress_type=zipfile.ZIP_DEFLATED;z.writestr(info,b)
(OUT/'manifest.json').write_bytes((json.dumps(manifest,indent=2)+'\n').encode())
for n in ('README.md','verify.py','prepare.py'):(OUT/n).write_bytes((OUT/n).read_bytes().replace(b'\r\n',b'\n'))
files={n:pin((OUT/n).read_bytes()) for n in ('evidence.zip','manifest.json','README.md','verify.py','prepare.py')}
(OUT/'files.json').write_bytes((json.dumps(files,indent=2)+'\n').encode())
print(json.dumps(files,indent=2));print('Native raw members:',len(payload))
if __name__=='__main__':main()
@@ -0,0 +1,115 @@
"""Read-only offline verification. Requires the pinned sibling checks/evidence.zip."""
from pathlib import Path,PurePosixPath
from datetime import datetime
import hashlib,io,json,zipfile
ROOT=Path(__file__).resolve().parent
def pin(b):return {'bytes':len(b),'sha256':hashlib.sha256(b).hexdigest()}
def need(ok,msg):
if not ok:raise ValueError(msg)
def safe(n):return not PurePosixPath(n).is_absolute() and '..' not in PurePosixPath(n).parts and '\\' not in n and ':' not in n
def members(z,expected):
names=z.namelist();need(len(names)==len(set(names)) and set(names)==set(expected),'exact ZIP membership');need(all(safe(n) for n in names),'safe ZIP members')
def ordered(*times):return list(map(datetime.fromisoformat,times))==sorted(map(datetime.fromisoformat,times))
def main():
files=json.loads((ROOT/'files.json').read_bytes())
need(set(files)=={'evidence.zip','manifest.json','README.md','verify.py','prepare.py'},'outer inventory')
for n,p in files.items():need(pin((ROOT/n).read_bytes())==p,'outer hash '+n)
m=json.loads((ROOT/'manifest.json').read_bytes())
need(m['checksRelativePath']=='../checks/evidence.zip','fixed sibling evidence location')
checks_bytes=(ROOT/'../checks/evidence.zip').read_bytes()
need(pin(checks_bytes)==m['checksEvidence'],'pinned sibling checks archive')
with zipfile.ZipFile(ROOT/'evidence.zip') as z,zipfile.ZipFile(io.BytesIO(checks_bytes)) as checks:
members(z,m['members'])
for n,p in m['members'].items():need(pin(z.read(n))==p,'native member hash '+n)
def raw(n):return z.read(n)
def obj(n):return json.loads(raw(n))
def directory(prefix):return {n[len(prefix):]:pin(raw(n)) for n in m['members'] if n.startswith(prefix)}
cm=obj('references/checks-manifest.json');members(checks,cm['members'])
def craw(logical):
n=cm['logicalFiles'][logical];b=checks.read(n);need(pin(b)==cm['members'][n],'checks member '+n);return b
def cobj(n):return json.loads(craw(n))
# Source capsules and receipt helper chains are independently checked here.
builds={};artifacts={};pairs=set()
for run in ('footer-windows-build-2','footer-windows-build-3','lifecycle-producer-build-2'):
p='cargo/'+run+'/';r=cobj(p+'result.json');start=cobj(p+'started.json');process=cobj(p+'process.json')
need(all(r[k]==v for k,v in start.items()) and r['exitCode']==0 and r['inputsUnchanged'] is True,run+' build closure')
need(r['command']==process['command'] and ordered(r['start'],process['observed'],r['finish']),run+' build process')
for field,n in [('inputs','inputs.json'),('archive','inputs.zip'),('stdout','stdout.log'),('stderr','stderr.log')]:need(r[field]==pin(craw(p+n)),run+' receipt '+field)
need(r['helper']==pin(craw(cm['cargo'][run]['helper'])),run+' build helper')
inputs=cobj(p+'inputs.json');need(len(inputs)==r['inputCount'] and sum(n.startswith('crates/vendor/iced_widget/') for n in inputs)==71,run+' input inventory')
pair=(r['inputs']['sha256'],r['archive']['sha256'])
if pair not in pairs:
with zipfile.ZipFile(io.BytesIO(craw(p+'inputs.zip'))) as source:
members(source,inputs)
for n,expected in inputs.items():need(pin(source.read(n))==expected,run+' source '+n)
pairs.add(pair)
records=[]
for line in craw(p+'stdout.log').decode('utf-8',errors='replace').splitlines():
try:item=json.loads(line)
except json.JSONDecodeError:continue
if isinstance(item,dict) and item.get('reason')=='compiler-artifact' and item.get('executable'):records.append(item)
need(records==[a['cargo'] for a in r['artifacts']],run+' artifact records')
expected='transition-native-lifecycle' if run.startswith('lifecycle') else 'arcadia-studio'
selected=[a for a in r['artifacts'] if a['cargo']['target']['name']==expected];need(len(selected)==1,run+' unique artifact')
builds[run]=r;artifacts[run]=selected[0]
original=m['originalNativeDirectory'].rstrip('/')+'/'
def mapped(path):
norm=path.replace('\\','/');need(norm.startswith(original),'snapshot path outside captured native root')
rel=norm[len(original):];need(safe(rel),'unsafe snapshot path');return 'native/'+rel
fixtures=directory('fixtures/');snapshots={};reports={};capture_count=image_count=0
need(set(m['runs'])=={'lifecycle-2','lifecycle-3','lifecycle-4','lifecycle-5'},'closed trial membership')
for run,spec in m['runs'].items():
p='native/'+run+'/';r=obj(p+'result.json');start=obj(p+'started.json');process=obj(p+'process.json')
build=builds[spec['guiBuild']];artifact=artifacts[spec['guiBuild']]
need(all(r[k]==v for k,v in start.items()),run+' start/result relationship')
need(r['exitCode']==0 and r['inputUnchanged'] is True and r['binary']==r['binaryAfter']==artifact['readback'],run+' native closure and binary pin')
need(r['buildReceipt']==pin(craw('cargo/'+spec['guiBuild']+'/result.json')) and r['buildHead']==build['head'],run+' GUI build relationship')
need(r['command']==[artifact['cargo']['executable']] and process['pid']>0,run+' native command/PID')
need(ordered(build['finish'],r['start'],process['observed'],r['finish']),run+' native times')
need(r['helper']==pin(raw(spec['helpers']['runner'])) and r['captureHelper']==pin(raw(spec['helpers']['launchCapture'])),run+' launch helper pins')
need(p+'capture-function.js' in m['members'],run+' actual capture-function record')
need(r['inputs']==pin(raw(p+'inputs.json')) and obj(p+'inputs.json')==fixtures,run+' unchanged fixture bytes')
for stream in ('stdout','stderr'):need(not raw(p+stream+'.log') and r[stream]==pin(raw(p+stream+'.log')),run+' empty '+stream)
images=[]
actual_captures=[n for n in m['members'] if n.startswith(p) and n.endswith('.json') and isinstance(obj(n),dict) and 'images' in obj(n)]
need(set(actual_captures)==set(spec['captures']) and len(actual_captures)==len(spec['captures']),run+' exact metadata coverage')
for n in spec['captures']:
frame=obj(n);need(ordered(r['start'],frame['time'],r['finish']),n+' frame timestamp')
need(frame['window']['app'].replace('\\','/')=='process:'+r['command'][0].replace('\\','/'),n+' window executable path')
for image in frame['images']:
name=p+image['file'];b=raw(name)
need(image['width']>0 and image['height']>0 and (b.startswith(b'\xff\xd8') or b.startswith(b'\x89PNG')),n+' image record')
images.append(name)
actual_images={n for n in m['members'] if n.startswith(p) and n.endswith(('.jpg','.png'))}
need(len(images)==len(set(images)) and set(images)==actual_images,run+' all original images')
capture_count+=len(spec['captures']);image_count+=len(images)
actual_snapshots={n.split('/')[3] for n in m['members'] if n.startswith(p+'snapshots/')}
need(actual_snapshots==set(spec['snapshots']),run+' snapshot inventory')
for label,state in spec['snapshots'].items():
q=p+'snapshots/'+label+'/';s=obj(q+'result.json');ss=obj(q+'started.json');report=obj(q+'stdout.log')
checker=artifacts['lifecycle-producer-build-2']
need(all(s[k]==v for k,v in ss.items()) and s['exitCode']==0 and s['binaryUnchanged'] is True and s['workingSourceUnchanged'] is True,label+' snapshot closure')
need(ordered(r['start'],s['time'],r['finish']),label+' snapshot start during trial')
need(s['binary']==checker['readback'] and s['buildReceipt']==pin(craw('cargo/lifecycle-producer-build-2/result.json')),label+' corrected checker build')
need(s['helper']==pin(raw(spec['helpers']['snapshot'])),label+' snapshot helper pin')
need(s['source']==directory(q+'source/'),label+' exact saved source bytes')
for stream in ('stdout','stderr'):need(s[stream]==pin(raw(q+stream+'.log')),label+' '+stream)
need(not raw(q+'stderr.log'),label+' empty snapshot stderr')
cmd=s['command'];need(cmd[:3]==[checker['cargo']['executable'],'check',state] and mapped(cmd[3])==q+'source/original.arcadia',label+' checker command')
need(report['status']=='PASS' and report['comparison']=='complete Project equality' and report['state']==state and report['entry']==cmd[3],label+' accepted model oracle')
opts=cmd[4:];need(len(opts)%2==0,label+' option pairs')
for flag,path in zip(opts[::2],opts[1::2]):
need(flag in ('--identities','--write-identities'),label+' identity-only options')
need(obj(mapped(path))==report['new_ids'],label+' exact identity receipt')
if state.endswith(('-reopened','-noop')):need('--identities' in opts and report['trace_identity_pinned'] is True,label+' continuity identity pin')
if state.endswith('-relinked'):need('--write-identities' in opts and mapped(report['identity_receipt'])==mapped(opts[opts.index('--write-identities')+1]),label+' initial accepted identities')
snapshots[(run,label)]=s['source'];reports[(run,label)]=report
print(run,'captures',len(spec['captures']),'images',len(images),'accepted snapshots',len(spec['snapshots']))
for side,first,later in [('oa','lifecycle-2','lifecycle-3'),('sa','lifecycle-4','lifecycle-5')]:
base=snapshots[(first,side+'-relinked')];ids=reports[(first,side+'-relinked')]['new_ids']
for phase in ('reopened','noop'):
key=(later,side+'-'+phase)
need(snapshots[key]==base and reports[key]['new_ids']==ids,side+' saved bytes and identity continuity '+phase)
print('PASS: 4 closed trials;',len(snapshots),'accepted full-model receipts;',capture_count,'capture records;',image_count,'original images;',len(pairs),'linked source capsules.')
print('No UI history authentication or checker re-execution; snapshot receipts lack completion timestamps. Frame dimensions vary.')
if __name__=='__main__':main()
@@ -0,0 +1,434 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "oa-core"
version = "0.2.0"
dependencies = [
"libc",
"num-bigint",
"num-integer",
"num-traits",
"same-file",
"serde",
"serde_json",
"tempfile",
"thiserror",
"uuid",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "transition-native-lifecycle"
version = "0.1.0"
dependencies = [
"oa-core",
"serde",
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "uuid"
version = "1.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
dependencies = [
"getrandom",
"js-sys",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,12 @@
[package]
name = "transition-native-lifecycle"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[dependencies]
oa-core = { path = "../../../../crates/oa-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -0,0 +1,154 @@
# Native transition lifecycle fixture and oracle
This isolated producer/checker prepares native OA↔SA acceptance work. It does
not automate the application. The project owns no production code. It has its
own Cargo workspace and depends on this checkout's `oa-core` by relative path.
## Commands
From the repository root, with the configured Rust toolchain and compiler gate:
```powershell
cargo run --manifest-path harness/experiments/transition-native-lifecycle-20260913/producer/Cargo.toml -- generate .local/transition-native/lifecycle-input-1
cargo run --manifest-path harness/experiments/transition-native-lifecycle-20260913/producer/Cargo.toml -- check original .local/transition-native/lifecycle-input-1/original.arcadia
```
The parent owns compilation, execution and native verification. These commands
are instructions, not evidence that they have run. Generation refuses to
overwrite an existing deliverable; use a new output directory for a new run.
All fixture identities are reproducible UUIDv5 values of fixed aliases in one
fixed namespace. Generating into a second directory must reproduce file bytes.
`generate OUTDIR` validates and round-trips each expected project, then emits:
- `original.arcadia`, `original.json`, and `manifest.json` with owned removal IDs.
- `oa-deleted.arcadia` / `.json` and `sa-deleted.arcadia` / `.json`.
- `oa-recreated.arcadia` / `.json` and `sa-recreated.arcadia` / `.json`.
- `oa-recreate-root.snippet` and `sa-recreate-root.snippet`.
The deleted/recreated files are independent expected artifacts. Opening them
does not establish that the native deletion, Undo or editing operation works.
Each snippet contains only its replacement root declaration, with the original
alias, name and description, a deterministic new UUID, no children, and no
placement. Generation verifies that inserting it immediately before the deleted
project's final closing brace compiles to the exact expected recreated Project.
`check STATE SAVED_ENTRY [--placements PATH] [--identities PATH]
[--write-identities PATH]` loads the actual saved entry and its reachable
includes through the public disk loader/compiler. It rejects any source bundle
changed by compilation, including declarations with missing UUIDs. An identity
inserted only into the compiler's memory cannot prove a saved identity. States:
| State | Required saved model |
| --- | --- |
| `original` | Entire original Project, including every original UUID |
| `oa-deleted`, `sa-deleted` | Explicit source-owned removals; opposite design and shared definitions retained exactly |
| `oa-recreated`, `sa-recreated` | Deleted state plus only the original source root under its pinned replacement UUID |
| `oa-relinked`, `sa-relinked` | First relink: recreated state plus exactly one new refinement trace, with both existing prose values acknowledged |
| `oa-reopened`, `sa-reopened` | Same relinked model; requires `--identities` with its previously accepted trace UUID |
| `oa-noop`, `sa-noop` | Same relinked model after unchanged retry; requires `--identities` with its previously accepted trace UUID |
The checker compares every Project field, including IDs, aliases, prose, typed
bodies, ownership, item/port contracts, relations, trace baselines, diagram
geometry/routes and artifacts. A mismatch reports its first differing JSON path.
No deletion impact helper, transition planner, or observed post-deletion state
constructs the expected removal set. Relinking expectations construct a new
Trace directly. The source UUID is always the deterministic snippet UUID unless
an explicit `--identities` file supplies another source UUID. Only the first
`*-relinked` check can observe a fresh trace UUID. Use `--write-identities PATH`
on that successful check to write a receipt, then pass that exact receipt using
`--identities PATH` for reopening and no-op checks. Those stages refuse to run
without a pinned trace UUID and reject any replacement of source, trace or
declared placement identities. Receipt writes refuse overwrite and happen only
after complete-model comparison succeeds.
An identity receipt is a JSON object with `source` and, after relinking, `trace`
UUID strings. Optional declared placements add `placement:ALIAS` UUID entries.
An explicitly authored source-only identity file can establish a different
GUI-created root UUID for initial recreation/relinking. Do not re-extract it
from later saves. The checker rejects unused identity entries and reports whether
trace identity was pinned; the first relink establishes identity evidence but
does not itself prove preservation across save/reopen.
Example using the already compiled producer executable:
```powershell
transition-native-lifecycle check oa-relinked saved-first-relink.arcadia --write-identities first-relink.identities.json
transition-native-lifecycle check oa-reopened saved-after-reopen.arcadia --identities first-relink.identities.json
transition-native-lifecycle check oa-noop saved-after-noop.arcadia --identities first-relink.identities.json
```
## Native workflow
| Layer | Root to select | Alias | Diagrams |
| --- | --- | --- | --- |
| OA | Coordinate distress response | `oa_response` | `oa_work`, `oa_overview` |
| SA | Deliver coordinated response | `sa_response` | `sa_work`, `sa_overview` |
Both roots own two work children with an internal report exchange. The SA
children own typed report input/output ports. Shared report definitions have
Flow mechanisms and String payload fields. OA intentionally has no ports.
Both layers have an expanded decomposition and overview diagram, authored
placements, exchange routing/labels, and artifacts on roots, children,
exchanges, placements and shared items. The initial correspondence and its
artifact must disappear when either root is deleted. Shared item definitions,
their fields/evidence, diagrams and the independent System remain.
1. Open `original.arcadia`. Save a native snapshot and check `original`.
2. Graphically delete **Coordinate distress response** (`oa_response`). Save a
snapshot and check `oa-deleted`. Inspect the complete retained SA design.
3. Native Undo, save another snapshot and check `original`. This checks the
actual saved result, including original IDs and all owned records.
4. Delete OA again. In the native Textual model editor, insert the inspected
`oa-recreate-root.snippet` immediately before the project's final `}`. Apply,
save, and check `oa-recreated`. Do not open the generated recreated fixture
as a substitute for this editing step.
5. Select the newly recreated `oa_response` and start OA→SA transition. Choose
existing `sa_response` explicitly. Select **Keep target** for both Name and
Description; do not request child/required dependency creation. The existing
target already has placements. Apply, save, and check `oa-relinked` with
`--write-identities first-relink.identities.json`.
6. Reopen the saved result and save a fresh snapshot; check `oa-reopened` with
`--identities first-relink.identities.json`.
Re-preview unchanged and inspect reuse/no changes. Applying any no-op and
saving must pass `oa-noop` with the same original identity receipt. Do not
substitute another unpinned `oa-relinked` observation for these later checks.
7. For the reverse workflow, start again from `original.arcadia`, delete
`sa_response`, use the SA snippet, and explicitly relink to retained
`oa_response`, using the corresponding `sa-*` checker states.
These instructions separate source recreation in the text editor from relinking
through the graphical transition dialog, avoiding unpredictable canvas placement
side effects. The newly recreated root deliberately has no children or placement.
## Optional explicitly authored source placement
If a separate GUI recreation task intentionally creates a placement, pass
`--placements` with an expectation file shaped like `oa-placement.example.json`. Set its diagram, alias,
position, size and collapsed state to the intended interaction inputs. Only the
new placement UUID is observed during the first check; all its other fields are
compared with the authored expectation. It must reference the new source root
and use the existing source-layer work/overview diagram. Undeclared placements,
changed target geometry and extra artifacts fail comparison. The snippet
workflow requires no placement expectation file. Later checks must supply the
same placement specifications and the saved identity receipt; the placement UUID
is then pinned rather than re-learned from the new saved model.
## Explicit incompleteness and limits
The architecture fixture deliberately omits performer allocations and
process/chain/scenario contexts. Admission requires exactly `unallocated_leaf`
(`allocation`) and `work_without_behavior_context` (no field) on each surviving
leaf work UUID, and `exchange_without_behavior_context` (no field) on each
surviving work exchange UUID. Errors, extra warnings, wrong warning owners and
duplicate warnings fail. System and data contracts are modeled explicitly.
The disk loader and source compiler may report these same engineering warnings.
Their diagnostic multisets must exactly match the validated model warnings by
severity, code, object UUID and field. Additional loading warnings, missing or
duplicate warnings, and all source-loading errors remain failures.
Whole-model comparison and source/JSON round trips do not prove usability,
authored comment/whitespace preservation, native Undo without saving, or HTTP
retry behavior. The parent must record actual native interactions, executable
provenance, saved snapshots and checker results. This tool does not perform
native interaction or publish an acceptance result itself.
@@ -0,0 +1,9 @@
[
{
"diagram": "oa_work",
"alias": "view_coordinate_distress_response",
"position": { "x": 60.0, "y": 60.0 },
"size": { "width": 224.0, "height": 94.0 },
"collapsed": false
}
]
@@ -0,0 +1,914 @@
use oa_core::{
language, source_files,
source_project::{ProjectLimits, SourceBundle, compile_bundle},
validation::{self, Severity},
*,
};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, io::Write, path::Path};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn id(alias: &str) -> Id {
Id::new_v5(
&Id::from_u128(0xc1192026_0913_5000_8000_000000000028),
alias.as_bytes(),
)
}
#[derive(Serialize)]
struct Side {
prefix: String,
root: Id,
elements: Vec<Id>,
exchange: Id,
placements: Vec<(Id, Id)>,
artifacts: Vec<Id>,
}
struct Fixture {
project: Project,
oa: Side,
sa: Side,
}
fn add(
p: &mut Project,
kind: Kind,
layer: Layer,
alias: &str,
name: &str,
parent: Option<Id>,
) -> Id {
let key = id(alias);
assert!(
p.elements
.insert(
key,
Element {
id: key,
alias: alias.into(),
name: name.into(),
description: String::new(),
parent,
body: ElementBody::new(kind, layer).unwrap(),
}
)
.is_none()
);
key
}
fn artifact(p: &mut Project, owner: Id, alias: &str, name: &str) -> Id {
let key = id(alias);
p.artifact_links.insert(
key,
ArtifactLink {
id: key,
alias: alias.into(),
name: name.into(),
description: "Reviewed evidence must survive opposite-layer deletion.".into(),
owner,
relation: ArtifactRelation::References,
uri: "git:response/design.md".into(),
revision: Some("review-17".into()),
fragment: Some("response-handling".into()),
},
);
key
}
fn port(p: &mut Project, alias: &str, owner: Id, item: Id, output: bool) -> Id {
let key = id(alias);
p.elements.insert(
key,
Element {
id: key,
alias: alias.into(),
name: if output {
"Report output"
} else {
"Report input"
}
.into(),
description: "Retain the typed report contract.".into(),
parent: Some(owner),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract: if output {
PortContract::FunctionOutput {
outgoing_items: vec![item],
}
} else {
PortContract::FunctionInput {
incoming_items: vec![item],
}
},
legacy: None,
},
),
},
);
key
}
fn side(p: &mut Project, layer: Layer) -> Side {
let (prefix, kind, root_name, send_name, receive_name, description) = match layer {
Layer::Oa => (
"oa",
Kind::Activity,
"Coordinate distress response",
"Report distress",
"Assess incident",
"Coordinate responders while preserving operational responsibility.",
),
Layer::Sa => (
"sa",
Kind::Function,
"Deliver coordinated response",
"Transmit report",
"Classify incident",
"Technical design: preserve independent processing and report contracts.",
),
};
let root = add(
p,
kind,
layer,
&format!("{prefix}_response"),
root_name,
None,
);
p.elements.get_mut(&root).unwrap().description = description.into();
let send = add(
p,
kind,
layer,
&format!("{prefix}_send"),
send_name,
Some(root),
);
let receive = add(
p,
kind,
layer,
&format!("{prefix}_receive"),
receive_name,
Some(root),
);
p.elements.get_mut(&send).unwrap().description =
"Keep the detailed sending responsibility.".into();
p.elements.get_mut(&receive).unwrap().description =
"Keep the detailed receiving responsibility.".into();
let item = add(
p,
Kind::ExchangeItem,
layer,
&format!("{prefix}_report"),
"Incident report",
None,
);
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
add(
p,
Kind::DataField,
layer,
&format!("{prefix}_report_text"),
"Report text",
Some(item),
);
let mut elements = vec![root, send, receive];
let body = if layer == Layer::Sa {
let output = port(p, "sa_send_output", send, item, true);
let input = port(p, "sa_receive_input", receive, item, false);
elements.extend([output, input]);
RelationBody::FunctionalExchange {
source: send,
target: receive,
source_port: output,
target_port: input,
items: vec![item],
}
} else {
RelationBody::Interaction {
source: send,
target: receive,
items: vec![item],
}
};
let exchange_alias = format!("{prefix}_report_delivery");
let exchange = id(&exchange_alias);
p.relations.insert(
exchange,
Relation {
id: exchange,
alias: exchange_alias,
name: "Deliver incident report".into(),
description: "Preserve direction, item identity and endpoint contracts.".into(),
body,
},
);
let mut placements = vec![];
let mut artifacts = vec![
artifact(
p,
root,
&format!("{prefix}_root_evidence"),
"Response rationale",
),
artifact(
p,
send,
&format!("{prefix}_send_evidence"),
"Sending requirement",
),
artifact(
p,
exchange,
&format!("{prefix}_exchange_evidence"),
"Report contract evidence",
),
];
for (suffix, title, projection) in [
(
"work",
"Response decomposition",
ProjectionKind::Decomposition,
),
("overview", "Response overview", ProjectionKind::Overview),
] {
let alias = format!("{prefix}_{suffix}");
let diagram = id(&alias);
let mut nodes = BTreeMap::new();
for (index, (element, short)) in [(root, "root"), (send, "send"), (receive, "receive")]
.into_iter()
.enumerate()
{
let node_alias = format!("{prefix}_{suffix}_{short}");
let node_id = id(&node_alias);
nodes.insert(
node_id,
NodePlacement {
id: node_id,
alias: node_alias,
element,
position: match index {
0 => Position { x: 40., y: 40. },
1 => Position { x: 80., y: 120. },
_ => Position { x: 400., y: 120. },
},
size: if index == 0 {
Size {
width: 660.,
height: 240.,
}
} else {
Size {
width: 224.,
height: 94.,
}
},
collapsed: false,
port_anchors: vec![],
},
);
placements.push((diagram, node_id));
if index == 0 {
artifacts.push(artifact(
p,
node_id,
&format!("{prefix}_{suffix}_layout_evidence"),
"Reviewed layout",
));
}
}
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias,
name: format!("{} {title}", layer),
description: "Inspect the response root, its children and their original exchange."
.into(),
layer,
projection,
view_options: Default::default(),
nodes,
routes: vec![EdgeRoute {
edge: exchange,
waypoints: vec![Position { x: 360., y: 167. }],
label: Some(EdgeLabel {
fraction: 0.55,
offset: Position { x: 0., y: -18. },
}),
}],
},
);
}
artifact(
p,
item,
&format!("{prefix}_item_evidence"),
"Shared report definition",
);
Side {
prefix: prefix.into(),
root,
elements,
exchange,
placements,
artifacts,
}
}
fn fixture() -> Fixture {
let mut project = Project::empty();
project.id = id("response_lifecycle");
project.alias = "response_lifecycle".into();
project.name = "Distress response: transition lifecycle".into();
project.description =
"Native acceptance fixture for preserving opposite-layer engineering work.".into();
add(
&mut project,
Kind::System,
Layer::Sa,
"response_system",
"Response support system",
None,
);
let oa = side(&mut project, Layer::Oa);
let sa = side(&mut project, Layer::Sa);
let key = id("response_refinement");
project.traces.insert(
key,
Trace {
id: key,
alias: "response_refinement".into(),
name: "Reviewed response refinement".into(),
description: "Operational and technical prose are intentionally different.".into(),
oa: oa.root,
sa: sa.root,
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: project.properties(oa.root).unwrap(),
sa: project.properties(sa.root).unwrap(),
semantic: None,
}),
},
);
artifact(
&mut project,
key,
"response_trace_evidence",
"Review decision",
);
Fixture { project, oa, sa }
}
// The removal oracle enumerates fixture-owned records. No deletion command,
// impact helper, transition planner or observed post-deletion model is used.
fn deleted(f: &Fixture, side: &Side) -> Project {
let mut p = f.project.clone();
for key in &side.elements {
assert!(p.elements.remove(key).is_some());
}
assert!(p.relations.remove(&side.exchange).is_some());
assert!(p.traces.remove(&id("response_refinement")).is_some());
for key in side
.artifacts
.iter()
.copied()
.chain([id("response_trace_evidence")])
{
assert!(p.artifact_links.remove(&key).is_some());
}
for (diagram, node) in &side.placements {
assert!(
p.diagrams
.get_mut(diagram)
.unwrap()
.nodes
.remove(node)
.is_some()
);
}
for suffix in ["work", "overview"] {
let diagram = p
.diagrams
.get_mut(&id(&format!("{}_{suffix}", side.prefix)))
.unwrap();
assert_eq!(diagram.routes.len(), 1);
assert_eq!(diagram.routes[0].edge, side.exchange);
diagram.routes.clear();
}
p
}
fn accepted(p: &Project) -> Result<Vec<validation::Issue>> {
// Intentional isolated architecture: no allocations, processes or scenarios.
// Require the exact warning multiset by record UUID, code and field.
let issues = validation::validate(p);
if issues.iter().any(|i| i.severity != Severity::Warning) {
return Err(format!("Structural validation failed: {issues:?}").into());
}
let mut expected = vec![];
for e in p.elements.values().filter(|e| {
matches!(e.kind(), Kind::Activity | Kind::Function)
&& !p
.elements
.values()
.any(|child| child.parent == Some(e.id) && child.kind() == e.kind())
}) {
expected.push(("unallocated_leaf", Some(e.id), Some("allocation")));
expected.push(("work_without_behavior_context", Some(e.id), None));
}
for r in p.relations.values().filter(|r| {
matches!(
r.kind(),
RelationKind::Interaction | RelationKind::FunctionalExchange
)
}) {
expected.push(("exchange_without_behavior_context", Some(r.id), None));
}
let mut actual = issues
.iter()
.map(|i| (i.code.as_str(), i.element, i.field.as_deref()))
.collect::<Vec<_>>();
expected.sort();
actual.sort();
if expected != actual {
return Err(
format!("Unexpected warnings: expected {expected:?}; actual {actual:?}").into(),
);
}
Ok(issues)
}
fn compile(source: String) -> Result<Project> {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
};
let result = compile_bundle(&bundle, ProjectLimits::default())
.map_err(|e| format!("Source compile: {e:?}"))?;
Ok(result.decoded.project)
}
fn round_trip(p: &Project) -> Result<String> {
accepted(p)?;
let bytes = serde_json::to_vec_pretty(p)?;
compare(p, &persistence::decode(&bytes)?)?;
let source = language::format(p);
compare(p, &compile(source.clone())?)?;
Ok(source)
}
fn recreated(f: &Fixture, side: &Side, new_id: Id) -> Project {
assert!(!f.project.contains(new_id));
let mut p = deleted(f, side);
let mut root = f.project.elements[&side.root].clone();
root.id = new_id;
p.elements.insert(new_id, root);
p
}
fn snippet(f: &Fixture, side: &Side) -> String {
let e = &f.project.elements[&side.root];
let keyword = if side.prefix == "oa" {
"oa_activity"
} else {
"sa_function"
};
format!(
" {keyword} {} {} id \"{}\" {{\n description {};\n }}\n",
e.alias,
serde_json::to_string(&e.name).unwrap(),
id(&format!("{}_recreated_response", side.prefix)),
serde_json::to_string(&e.description).unwrap()
)
}
fn generate(out: &Path) -> Result<()> {
let f = fixture();
let mut files: Vec<(String, Vec<u8>)> = vec![
(
"original.arcadia".into(),
round_trip(&f.project)?.into_bytes(),
),
(
"original.json".into(),
serde_json::to_vec_pretty(&f.project)?,
),
(
"manifest.json".into(),
serde_json::to_vec_pretty(&serde_json::json!({
"fixture": "transition-native-lifecycle-20260913", "oa": &f.oa, "sa": &f.sa,
"trace": id("response_refinement"), "trace_artifact": id("response_trace_evidence"),
"recreated_oa_id": id("oa_recreated_response"), "recreated_sa_id": id("sa_recreated_response")
}))?,
),
];
for side in [&f.oa, &f.sa] {
let removal = deleted(&f, side);
let removal_source = round_trip(&removal)?;
let replacement = recreated(&f, side, id(&format!("{}_recreated_response", side.prefix)));
let replacement_source = round_trip(&replacement)?;
let fragment = snippet(&f, side);
let (body, tail) = removal_source
.rsplit_once('}')
.ok_or("Missing project closing brace")?;
compare(&replacement, &compile(format!("{body}{fragment}}}{tail}"))?)?;
files.extend([
(
format!("{}-deleted.json", side.prefix),
serde_json::to_vec_pretty(&removal)?,
),
(
format!("{}-deleted.arcadia", side.prefix),
removal_source.into_bytes(),
),
(
format!("{}-recreated.json", side.prefix),
serde_json::to_vec_pretty(&replacement)?,
),
(
format!("{}-recreated.arcadia", side.prefix),
replacement_source.into_bytes(),
),
(
format!("{}-recreate-root.snippet", side.prefix),
fragment.into_bytes(),
),
]);
}
if files.iter().any(|(name, _)| out.join(name).exists()) {
return Err(
"Generation refuses to overwrite fixture files; use a fresh output directory".into(),
);
}
fs::create_dir_all(out)?;
for (name, bytes) in files {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(out.join(name))?
.write_all(&bytes)?;
}
println!(
"Generated and round-trip verified both directions in {}",
out.display()
);
Ok(())
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PlacementExpectation {
diagram: String,
alias: String,
position: Position,
size: Size,
collapsed: bool,
}
fn first_difference(
expected: &serde_json::Value,
actual: &serde_json::Value,
path: &str,
) -> Option<String> {
use serde_json::Value;
if expected == actual {
return None;
}
match (expected, actual) {
(Value::Object(a), Value::Object(b)) => {
for key in a.keys().chain(b.keys()) {
match (a.get(key), b.get(key)) {
(Some(x), Some(y)) => {
if let Some(diff) = first_difference(x, y, &format!("{path}.{key}")) {
return Some(diff);
}
}
_ => {
return Some(format!(
"{path}.{key}: expected presence {}, actual presence {}",
a.contains_key(key),
b.contains_key(key)
));
}
}
}
}
(Value::Array(a), Value::Array(b)) if a.len() == b.len() => {
for (index, (x, y)) in a.iter().zip(b).enumerate() {
if let Some(diff) = first_difference(x, y, &format!("{path}[{index}]")) {
return Some(diff);
}
}
}
_ => return Some(format!("{path}: expected {expected}, actual {actual}")),
}
Some(format!("{path}: values differ"))
}
fn compare(expected: &Project, actual: &Project) -> Result<()> {
if expected != actual {
return Err(first_difference(
&serde_json::to_value(expected)?,
&serde_json::to_value(actual)?,
"Project",
)
.unwrap_or_else(|| "Project differs".into())
.into());
}
Ok(())
}
fn check(
state: &str,
entry: &Path,
placement_path: Option<&Path>,
identity_path: Option<&Path>,
receipt_path: Option<&Path>,
) -> Result<()> {
let f = fixture();
let identity_expectations: Option<BTreeMap<String, Id>> = identity_path
.map(|path| -> Result<_> { Ok(serde_json::from_slice(&fs::read(path)?)?) })
.transpose()?;
let disk = source_files::load_project(entry, ProjectLimits::default())?;
if disk
.diagnostics
.iter()
.any(|located| located.diagnostic.severity != Severity::Warning)
{
return Err(format!("Source loading errors: {:?}", disk.diagnostics).into());
}
let disk_bundle = disk.reachable_bundle();
let compiled = compile_bundle(&disk_bundle, ProjectLimits::default())
.map_err(|e| format!("Saved source compile: {e:?}"))?;
if compiled.bundle != disk_bundle {
return Err("Saved source is not identity-stable: compilation inserted UUIDs or changed source; save explicit identities before checking".into());
}
let actual = &compiled.decoded.project;
let validated_warnings = accepted(actual)?;
let mut expected_diagnostics = validated_warnings
.iter()
.map(|issue| {
(
issue.severity == Severity::Warning,
issue.code.as_str(),
issue.element,
issue.field.as_deref(),
)
})
.collect::<Vec<_>>();
expected_diagnostics.sort();
// Loading/compilation may return the fixture's intentional engineering
// warnings. Admit exactly the independently validated warning multiset,
// retaining owner, field and severity checks; no extra discovery warning or
// error is silently discarded. Source location spelling is not semantic.
for (stage, diagnostics) in [
("loading", &disk.diagnostics),
("compilation", &compiled.diagnostics),
] {
let mut actual_diagnostics = diagnostics
.iter()
.map(|located| {
let diagnostic = &located.diagnostic;
(
diagnostic.severity == Severity::Warning,
diagnostic.code.as_str(),
diagnostic.object_id,
diagnostic.field.as_deref(),
)
})
.collect::<Vec<_>>();
actual_diagnostics.sort();
if actual_diagnostics != expected_diagnostics {
return Err(format!("Unexpected source {stage} diagnostics: expected {expected_diagnostics:?}; actual {actual_diagnostics:?}").into());
}
}
let mut generated_ids = BTreeMap::<String, Id>::new();
let expected = if state == "original" {
if placement_path.is_some() || identity_path.is_some() || receipt_path.is_some() {
return Err("original does not accept placement/identity options".into());
}
f.project.clone()
} else {
let (prefix, phase) = state
.split_once('-')
.ok_or("State must be original or oa-/sa-deleted/recreated/relinked/reopened/noop")?;
let side = match prefix {
"oa" => &f.oa,
"sa" => &f.sa,
_ => return Err("Unknown source layer".into()),
};
if phase == "deleted" {
if placement_path.is_some() || identity_path.is_some() || receipt_path.is_some() {
return Err("deleted does not accept placement/identity options".into());
}
deleted(&f, side)
} else if matches!(phase, "recreated" | "relinked" | "reopened" | "noop") {
if matches!(phase, "reopened" | "noop")
&& identity_expectations
.as_ref()
.and_then(|ids| ids.get("trace"))
.is_none()
{
return Err("Reopened/noop checks require --identities with the first accepted relink trace UUID".into());
}
let original_root = &f.project.elements[&side.root];
let new_source = match &identity_expectations {
Some(ids) => *ids
.get("source")
.ok_or("Identity expectations must explicitly include source")?,
None => id(&format!("{prefix}_recreated_response")),
};
let roots = actual
.elements
.values()
.filter(|e| e.alias == original_root.alias)
.collect::<Vec<_>>();
if roots.len() != 1 || roots[0].id != new_source || f.project.contains(new_source) {
return Err(
"Expected exactly one recreated root with the original alias and the pinned new UUID (snippet UUID unless --identities supplied)"
.into(),
);
}
generated_ids.insert("source".into(), new_source);
let mut candidate = recreated(&f, side, new_source);
if let Some(path) = placement_path {
let specs: Vec<PlacementExpectation> = serde_json::from_slice(&fs::read(path)?)?;
for spec in specs {
if ![format!("{prefix}_work"), format!("{prefix}_overview")]
.contains(&spec.diagram)
{
return Err("A new source placement must use its existing source-layer work/overview diagram".into());
}
let diagram = id(&spec.diagram);
let nodes = actual
.diagrams
.get(&diagram)
.ok_or("Expected diagram is missing")?
.nodes
.values()
.filter(|node| node.alias == spec.alias)
.collect::<Vec<_>>();
if nodes.len() != 1 || f.project.contains(nodes[0].id) {
return Err(
"Expected one newly identified declared source placement".into()
);
}
let receipt_key = format!("placement:{}", spec.alias);
let key = match &identity_expectations {
Some(ids) => *ids.get(&receipt_key).ok_or(
"Identity receipt lacks an explicitly declared placement UUID",
)?,
None => nodes[0].id,
};
if nodes[0].id != key {
return Err(
"Source placement UUID changed from its identity expectation".into(),
);
}
if candidate
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.insert(
key,
NodePlacement {
id: key,
alias: spec.alias.clone(),
element: new_source,
position: spec.position,
size: spec.size,
collapsed: spec.collapsed,
port_anchors: vec![],
},
)
.is_some()
{
return Err("Duplicate placement expectation".into());
}
generated_ids.insert(receipt_key, key);
}
}
if phase != "recreated" {
if actual.traces.len() != 1 {
return Err("Relinking must create exactly one trace".into());
}
let actual_key = actual.traces.values().next().unwrap().id;
let key = identity_expectations
.as_ref()
.and_then(|ids| ids.get("trace"))
.copied()
.unwrap_or(actual_key);
if actual_key != key {
return Err(
"Refinement trace UUID changed from its accepted identity receipt".into(),
);
}
if f.project.contains(key) || candidate.contains(key) {
return Err("Expected a fresh trace UUID".into());
}
let (oa, sa) = if prefix == "oa" {
(new_source, f.sa.root)
} else {
(f.oa.root, new_source)
};
candidate.traces.insert(
key,
Trace {
id: key,
alias: "trace_oa_response_sa_response".into(),
name: format!(
"{} ↔ {}",
candidate.elements[&oa].name, candidate.elements[&sa].name
),
description: String::new(),
oa,
sa,
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: candidate.properties(oa).unwrap(),
sa: candidate.properties(sa).unwrap(),
semantic: None,
}),
},
);
generated_ids.insert("trace".into(), key);
}
candidate
} else {
return Err("Unknown lifecycle phase".into());
}
};
// No actual body, prose, ownership, geometry, alias or baseline is copied
// into the expected model: only explicitly permitted fresh identities.
compare(&expected, actual)?;
round_trip(&expected)?;
if let Some(ids) = &identity_expectations {
for (label, expected_id) in ids {
if generated_ids.get(label) != Some(expected_id) {
return Err(format!("Unused or mismatching identity expectation: {label}").into());
}
}
}
if let Some(path) = receipt_path {
// Publish a receipt only after complete-model validation succeeds. Never
// replace earlier accepted identity evidence with a new observation.
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?
.write_all(&serde_json::to_vec_pretty(&generated_ids)?)?;
}
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "PASS", "state": state, "entry": entry,
"comparison": "complete Project equality", "new_ids": generated_ids,
"trace_identity_pinned": identity_expectations.as_ref().is_some_and(|ids| ids.contains_key("trace")),
"identity_receipt": receipt_path,
"recreated_id_matches_snippet": generated_ids.get("source").map(|key|
*key == id(if state.starts_with("oa-") { "oa_recreated_response" } else { "sa_recreated_response" }))
}))?
);
Ok(())
}
fn run() -> Result<()> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
match args.as_slice() {
[command, out] if command == "generate" => generate(Path::new(out)),
[command, state, entry, options @ ..] if command == "check" => {
if options.len() % 2 != 0 { return Err("Every check option requires a path".into()); }
let (mut placements, mut identities, mut receipt) = (None, None, None);
for pair in options.chunks_exact(2) {
let slot = match pair[0].as_str() {
"--placements" => &mut placements,
"--identities" => &mut identities,
"--write-identities" => &mut receipt,
_ => return Err(format!("Unknown check option: {}", pair[0]).into()),
};
if slot.replace(Path::new(&pair[1])).is_some() { return Err("Duplicate check option".into()); }
}
check(state, Path::new(entry), placements, identities, receipt)
},
_ => Err("Usage: transition-native-lifecycle generate OUTDIR | check STATE SAVED_ENTRY [--placements PATH] [--identities PATH] [--write-identities PATH]".into()),
}
}
fn main() {
if let Err(error) = run() {
eprintln!("FAIL: {error}");
std::process::exit(1);
}
}
@@ -0,0 +1,102 @@
# Independent bounded transition review
Reviewer: `transition_picker_review`, 2026-09-22. This review inspected code,
original native screenshots and closed-run/snapshot receipts. The reviewer did
not operate the application, build it, rerun the checker, or alter the specimens.
Only this review note was written. The parent owns execution and publication.
The reviewed production baseline is `d0b7c285018a516c6dc34c196d1359a861ddaeb3`
plus the captured, initially uncommitted footer changes in
`crates/oa-desktop/src/workflow.rs`. HEAD alone does not identify those changes.
Lifecycle trials 2 and 3 identify GUI SHA-256
`3813e79d84cf4a83f2290639b93c3daaa8de427b0268b288f87e4f535fa1aea2`;
trials 4 and 5 identify final-footer GUI SHA-256
`f0bb021e350bcc55435d29e3daf6a4b3dd7389620df99f5d169f9cb669fd53d0`.
Exact build/source inventories belong to the accompanying evidence packages.
## Code and evaluator findings
The picker shares the core layer/category/kind predicate, preserves selected
identity independently of search results, and keeps query state outside the
request and recovery schema. Query events return before replanning. Compatibility
does not imply that dependencies or the complete transition plan are valid.
Distinct traced IDs, rather than trace-record count, determine inferred reuse;
ambiguous traces remain an explicit decision. Manual tracing remains separate.
The desktop tests compare the whole request, plan and recovery snapshot during
search, including generated preview identities. The initial second-query test
used a target ID rather than a second rendered source card; native corrected
picker frame 17 separately demonstrates two visible source cards with independent
queries. These checks do not prove keyboard access or every focus transition.
The core lifecycle tests and [native producer/checker](producer/src/main.rs)
construct expected deletions from fixture-owned identities, not a deletion
impact helper or observed deletion result. Expected recreation adds only the
replacement root. Expected relinking adds a directly constructed refinement
trace and reviewed baseline while preserving target prose, bodies, ownership,
geometry, routes and artifacts. Whole-Project equality detects incidental changes.
First-relink admission may observe the fresh trace UUID; reopen/no-op admission
requires the previously written identity receipt and rejects replacement IDs.
Source loading rejects compiler-inserted identities. Round trips compare the
decoded model; they do not promise original comments or formatting preservation.
No blocking conservation-oracle defect was found in the inspected paths.
## Native observations
Trial/frame labels below refer to original `*-0.jpg` files and matching JSON
under `.local/transition-native/` at capture time. Receipt evidence is distinct
from what screenshots visibly establish. Capture helpers and checksums provide
provenance context, not authenticated historical action logs.
| Originals inspected | Visible observation |
| --- | --- |
| `picker-baseline-1` 10–11 | Activity menu includes incompatible ports; selecting one yields a kind conflict. |
| `picker-corrected-2` 06–07, 10–12, 15, 17–19, 22–23 | Six compatible Functions are sorted; alias filtering preserves current selection until explicit selection; zero results preserve the selected target; two cards retain independent queries; field review remains required; clearing search retains Keep target; Cancel returns to the saved-status view. |
| `lifecycle-2` 05, 11–12 | OA deletion is visible; a replacement root declaration is entered and applied through Textual model. |
| `lifecycle-2` 17, 21, 27–30 | Recreation initially offers Create rather than silently relinking. Selecting the retained SA target requires Name and Description decisions. Sticky guidance advances from Name/two conflicts to Description/one, then clears after both Keep target decisions. Seven warnings do not block Apply. |
| `lifecycle-2` 31–32 | Apply displays the retained SA root, children and exchange; subsequent frame reports all changes saved. |
| `lifecycle-3` 02, 06–08 | A fresh process opens the saved model; unchanged transition infers reuse without field conflicts; Apply and save return to the retained SA design. |
| `lifecycle-4` 12, 14, 16, 22–23 | SA deletion, Undo restoration, Redo deletion and replacement-root entry/application are visible. |
| `lifecycle-4` 28, 30–38 | Reverse transition defaults to Create; alias search offers the retained OA Activity; explicit selection requires both prose decisions. Persistent guidance follows two/one/zero conflicts. Apply displays the retained OA decomposition and save reports completion. The final `reviewed items` wording is visible. |
| `lifecycle-5` 02, 07–09 | A fresh process opens the retained OA design; unchanged SA-to-OA review infers the accepted counterpart without conflicts; Apply and save retain the visible OA design. |
Closed GUI receipts for lifecycle trials 2–5 record exit 0, empty stdout/stderr,
and unchanged executable hashes. Snapshot receipts record exit 0 for OA deletion,
recreation and relink; OA reopen/no-op; and SA deletion, original-state Undo,
deletion-state Redo, recreation, relink and subsequent reopen/no-op. Checks operate on saved source copies
and report the working source unchanged. SA Redo's source hash equals deletion's.
OA relink, reopen and no-op snapshots have identical hashes for both
`original.arcadia` and `traceability.arcadia`. Reopen/no-op use source
`8eb83649-2810-58e1-94cd-71e25422658e` and accepted trace
`856eb758-33fc-4ebf-9a87-22ad40a89952`. SA first relink establishes source
`074eab66-7092-5e7e-8b83-b31b95afdbcb` and trace
`acc7969b-08c3-43f4-a31d-9140ca873ffe`. Trial 5 passes that exact first-relink
receipt to both saved-source checks. Its reopen and no-op source hashes match the
first relink for both files (`original.arcadia`: `e75b4cb55e5b205bd5784a1f35573931f1e64491b646f4ffb241f90288447b5d`;
`traceability.arcadia`: `5046e2d2731c5d8fcbcb7fe2deede0677ec61de03b85890852fa20f778c57552`).
This supports conservation and persisted identity across these finite reopen and
unchanged-review workflows in both directions. No new blocking finding arose
from the reviewed lifecycle evidence.
## Feedback correction and remaining limits
Corrected picker frames 12/15 initially left Apply disabled while its explanation
was below the fold. The reviewed footer change moves counts outside the scrolling
body and derives guidance from the same conflicts and destination validity used
by Apply. It adds no persisted state and weakens no validation. Generic guidance
was corrected from “adjust mapping or scope” because operation-omission rationale
and component-port decisions require other controls. Final native trial 4 shows
the Name/Description guidance and `reviewed items` count. Destination, boundary
and general-conflict guidance were checked against code, not exercised in these
native lifecycle frames.
All inspected originals are 1442×932. Resize attempts did not establish a smaller
viewport, regardless of frame filenames; minimum-window behavior is unverified.
The inherited picker is pointer-dependent. This review does not establish full
keyboard/screen-reader accessibility, packaged launch, other platforms, arbitrary
models, implicit creation applied natively, or relink Undo/Redo. Recreated roots
were authored through Textual model and intentionally have no new placement;
the empty source canvas is consistent with that fixture. Screenshots alone do
not establish complete model conservation: the independent saved-source comparisons
provide that bounded evidence.
@@ -0,0 +1,41 @@
# Transition picker: native comparison evidence
Run `python verify.py` using Python 3.10 or newer. This five-file package supports
read-only verification using the standard library. It does not execute archived
helpers, binaries, Git commands or network calls. Outer text is UTF-8 LF before
hashing; archived files retain exact original bytes.
All three trials, launch helpers, copied release1 inputs and original frames are
preserved. Baseline picker-baseline-1 used the f714a25 GUI and closed with exit 0.
Corrected picker-corrected-1 exited immediately with 3221225781 and empty logs;
the operator attributes this to omitted runtime environment setup. That cause
is not independently proven by empty logs. Corrected picker-corrected-2 used
the d46854c GUI, ran from 15:22:22 to 15:27:13 UTC, and closed with exit 0 and
empty logs. Its frame 02 is a loading state, not proof that loading finished.
The capture-snippet.js source was retained from a previous persistent session.
It was not pinned in these trials' launch receipts. Archiving it now does not
establish the exact capture function executed during the trials. Frame JSON
records timestamps, a window/application path and image references; it is not
an action invocation log or independent PID attestation. No frame is fabricated,
renumbered, cropped or omitted, including auxiliary dialog images.
Both exact GUI build result receipts are included. Full build source evidence
remains external: the sibling transition-lifecycle-20260913 Cargo package at
commit fe43730bec196cc6ebbc06dcf827169f04c120f0 covers the corrected d46854c
build, and the ordinary-text-native-20260913 package at
commit 3f9e6a18af96b5cd10940da4093be8da28b9c835 includes the baseline f714a25
build/source evidence. The manifest pins those related evidence ZIPs. This is
not a self-contained source or runtime distribution; the verifier does not
require those external packages for its local receipt/frame checks.
Verification checks exact safe ZIP membership/hashes, launch helper pins,
start/process/result timing, expected exit outcomes, empty streams, GUI binary
and build receipt relationships, identical copied input membership/bytes across
all trials, and complete frame/image references within process intervals. Binary
hashes are receipt observations; binaries are excluded. Screenshots require
separate interpretation and do not prove action execution, complete model
equality, persistence or full usability. The included independent-review.md is
a parent-consolidated record of separate human-readable review observations.
No credentials or whole user profile are
packaged; raw receipts retain original runtime path strings.
@@ -0,0 +1,18 @@
{
"evidence.zip": {
"bytes": 3412321,
"sha256": "c801eda9e33cd796a3594e73c34d6d29691597552a68307d2dc5a5e113c1e470"
},
"manifest.json": {
"bytes": 26082,
"sha256": "f80092a483a00e17c3ffcdbf2dd71480ed6ead4d003248de2f9ef708104cfba1"
},
"README.md": {
"bytes": 2673,
"sha256": "86251ca15d4950cd0b037ba05b63433877608c1d0d6424fedef8ff4e34340141"
},
"verify.py": {
"bytes": 5143,
"sha256": "23c5040f91f29e243e65ad31b4f8f82f7ad1d692efd862b1bc87450c31e5ed5f"
}
}
@@ -0,0 +1,614 @@
{
"schema": 1,
"runs": {
"picker-baseline-1": {
"helper": "native/run-picker-baseline.py",
"build": "build/baseline-result.json",
"exitCode": 0,
"captures": [
"native/picker-baseline-1/01-start.json",
"native/picker-baseline-1/02-open-pending.json",
"native/picker-baseline-1/03-open-dialog.json",
"native/picker-baseline-1/04-teaching-path.json",
"native/picker-baseline-1/05-teaching-opened.json",
"native/picker-baseline-1/06-teaching-ready.json",
"native/picker-baseline-1/07-source-selected.json",
"native/picker-baseline-1/08-source-actions.json",
"native/picker-baseline-1/09-transition-dialog.json",
"native/picker-baseline-1/10-unfiltered-counterparts.json",
"native/picker-baseline-1/11-incompatible-selection.json",
"native/picker-baseline-1/12-cancel-preserved.json"
]
},
"picker-corrected-1": {
"helper": "native/run-picker-corrected.py",
"build": "build/corrected-result.json",
"exitCode": 3221225781,
"captures": []
},
"picker-corrected-2": {
"helper": "native/run-picker-corrected-2.py",
"build": "build/corrected-result.json",
"exitCode": 0,
"captures": [
"native/picker-corrected-2/01-opened.json",
"native/picker-corrected-2/02-teaching-reopened.json",
"native/picker-corrected-2/03-loaded.json",
"native/picker-corrected-2/04-source-selected.json",
"native/picker-corrected-2/05-actions.json",
"native/picker-corrected-2/06-transition-dialog.json",
"native/picker-corrected-2/07-compatible-menu.json",
"native/picker-corrected-2/08-menu-dismissed.json",
"native/picker-corrected-2/09-search-focus.json",
"native/picker-corrected-2/10-alias-query.json",
"native/picker-corrected-2/11-filtered-menu.json",
"native/picker-corrected-2/12-existing-selection.json",
"native/picker-corrected-2/13-refocus-search.json",
"native/picker-corrected-2/14-query-selected.json",
"native/picker-corrected-2/15-no-results-selection-retained.json",
"native/picker-corrected-2/16-parent-search-focus.json",
"native/picker-corrected-2/17-independent-card-queries.json",
"native/picker-corrected-2/18-field-review.json",
"native/picker-corrected-2/19-keep-target-reviewed.json",
"native/picker-corrected-2/20-reviewed-search-focus.json",
"native/picker-corrected-2/21-reviewed-query-selected.json",
"native/picker-corrected-2/22-cleared-search-review-retained.json",
"native/picker-corrected-2/23-cancel-restores-saved.json"
]
}
},
"members": {
"build/baseline-result.json": {
"bytes": 5532,
"sha256": "5e9e227c66a4b71ade734b8ebeb12070b8a5c64a9a6cf9d8d0b6d70379d4ffc2"
},
"build/corrected-result.json": {
"bytes": 5583,
"sha256": "91b38045319adc1fb9aa0e51d45d79a4be99d2de9aec08a47f73915e3ac9dd79"
},
"native/capture-snippet.js": {
"bytes": 1579,
"sha256": "55279ed080289ccf5ba482cb24b5a807669a9360012e2d87f794d4465227cb2c"
},
"native/independent-review.md": {
"bytes": 1898,
"sha256": "c59d2ff78635f94f3f225c27999b69d24c11977c89026fc8a7331ddbd160c8c6"
},
"native/input/release1/README.md": {
"bytes": 11137,
"sha256": "113cfb1cf38a98b51bff60442d86a35d636a3d80ef206bece7d53d0aae67f2d5"
},
"native/input/release1/evidence/repair-check.md": {
"bytes": 1419,
"sha256": "f4db17bdca4ecbb1bf0caef01b2da5f1f7f798ab3152b648ce6dd8696a6a68e5"
},
"native/input/release1/expected-diagnostics.json": {
"bytes": 288,
"sha256": "3700393d76b940cfc076bf52861bd7b1f43370f8eabce3594531bd0bf1fc851e"
},
"native/input/release1/model.json": {
"bytes": 134520,
"sha256": "ef0ef4006c3e09d5db6e1b621256b3f7d6642f3a7b09cc34607cfbcca830c727"
},
"native/input/release1/model.rs": {
"bytes": 28762,
"sha256": "15f30f6bde655808fe754665c26d560e36f2a9bad340f346b09137d0d05045f5"
},
"native/input/release1/oa/activities/activity_oa_restore_work.arcadia": {
"bytes": 1130,
"sha256": "21cbd7176af8fad68453306f2371ff03e127a994b8796cdf044567332de9686f"
},
"native/input/release1/oa/behavior/process_oa_assessment_procedure.arcadia": {
"bytes": 550,
"sha256": "23cabc213c264a16c8595414db6acb09248c1cef9cd699f1b0316e5bb82c062a"
},
"native/input/release1/oa/behavior/process_oa_field_procedure.arcadia": {
"bytes": 1178,
"sha256": "9c52086759b7344b4809ceb8d372f65647b16fc24153a03b887faa378c37fe96"
},
"native/input/release1/oa/behavior/process_oa_response.arcadia": {
"bytes": 2560,
"sha256": "675798bd19e6b40d03e7076b0e59b110cdaaab7b6d16c47c3ac80fa960072e84"
},
"native/input/release1/oa/behavior/scenario_oa_conversation.arcadia": {
"bytes": 1515,
"sha256": "e217e5394a0771dc16206ed9437a231da27905d2f19be64b3f48281c0a32fb6b"
},
"native/input/release1/oa/behavior/scenario_oa_full_scenario.arcadia": {
"bytes": 1121,
"sha256": "c5a285f2d7c61bd2a136107f267fbf9cddb1cba3f63ff36a6f53f7ab98f33df5"
},
"native/input/release1/oa/behavior/state_machine_oa_monitor.arcadia": {
"bytes": 1358,
"sha256": "e7349e1e5336282eed3f5a103129db33d3ae4ed500788ee7129e8fc193ac57f6"
},
"native/input/release1/oa/capabilities.arcadia": {
"bytes": 630,
"sha256": "26a3d9b615a3251ff010e0b47127f2d16bc2dcd45ed95967208b0076d725843a"
},
"native/input/release1/oa/model.arcadia": {
"bytes": 531,
"sha256": "069a5eaaf525089935cc9d73ae7b916866ed78c6534705d75739650ecdea7ada"
},
"native/input/release1/oa/participants.arcadia": {
"bytes": 1580,
"sha256": "591a75a9ecad280302be4d4ae0171ad616377ed11ead5e233848ca2dc0a71819"
},
"native/input/release1/oa/types.arcadia": {
"bytes": 554,
"sha256": "f02c391d584674c879f662a418abba3a220f1fee88471ca49322ac9873de628d"
},
"native/input/release1/project.arcadia": {
"bytes": 1958,
"sha256": "b2674e6bf77cfe0f6f0ba79c81d2e96d92f39c02ca2c6266d80e91723bfb9fda"
},
"native/input/release1/sa/behavior/process_sa_assessment_procedure.arcadia": {
"bytes": 559,
"sha256": "5c414fbdaabf9f9e402b0441fcacf1cacf4dbbc6c822162f028de35abf59be2d"
},
"native/input/release1/sa/behavior/process_sa_field_procedure.arcadia": {
"bytes": 1187,
"sha256": "ece846fdf93857e71a9ab0efcbdf64a5c41c71180f62afa3f79077fffb53b8f2"
},
"native/input/release1/sa/behavior/process_sa_response.arcadia": {
"bytes": 2569,
"sha256": "af7fdda280a917cf626f6e077162d481f9aaad280f8e81330c07d7fe384b1c52"
},
"native/input/release1/sa/behavior/scenario_sa_conversation.arcadia": {
"bytes": 1847,
"sha256": "01339699ec3ee409418d9bb232542f2bcd9419f1e67b183e77700c56bb9f71d2"
},
"native/input/release1/sa/behavior/scenario_sa_full_scenario.arcadia": {
"bytes": 1116,
"sha256": "5dce6b00a3a441162e8dee3e23d9ca5ab7bed4a24de669b1fba03664a3086dda"
},
"native/input/release1/sa/behavior/state_machine_sa_monitor.arcadia": {
"bytes": 1358,
"sha256": "17dbcfa5bbfd10fc8e23a325fc950ce853c219b4aa89c74f96278cbb3f34acc8"
},
"native/input/release1/sa/capabilities.arcadia": {
"bytes": 630,
"sha256": "81706a0ca9d7db2aa0350888b9c00d853885185db2e6b90557d3d51a11c3d645"
},
"native/input/release1/sa/functions/function_sa_audit_thresholds.arcadia": {
"bytes": 280,
"sha256": "27f798f78f36e72b38edab3725af2feb0eaa010fd52a60b5c6b6bef57ad3b686"
},
"native/input/release1/sa/functions/function_sa_restore_work.arcadia": {
"bytes": 2251,
"sha256": "357c6acb80f86b4e8d8f5c98f9f79d140f85fbe09f3406b41b7ac6eba464ec8c"
},
"native/input/release1/sa/model.arcadia": {
"bytes": 531,
"sha256": "805b90262e27090ae7da81a51bcefa49c97fb0b3d3220b4a72323b4c4351c395"
},
"native/input/release1/sa/participants.arcadia": {
"bytes": 3604,
"sha256": "ca144d6a8f45fe720b5d18c7b59c7671444baa8c37e5ce9117bcba34b3a6aa84"
},
"native/input/release1/sa/types.arcadia": {
"bytes": 903,
"sha256": "540ddbc2bcd0ccf45298e3ea5660637341aff63ff65fd87ac2f26469d1adc064"
},
"native/input/release1/traceability.arcadia": {
"bytes": 28041,
"sha256": "474bd28d955ff7af7722f6e9a23220d816879e3f46202e79a233544b9c593116"
},
"native/input/release1/transition-before.json": {
"bytes": 35801,
"sha256": "2253e6434f741687e22327c37d520ed15c043bc36025498650bc2366bb6a874d"
},
"native/input/release1/transition-request.json": {
"bytes": 4276,
"sha256": "43b9a22e5f95ad3bc0ebd2478ead0e14406854abc989ae94ded9367b013d812a"
},
"native/input/release1/views/diagram_oa_allocation_view.arcadia": {
"bytes": 1786,
"sha256": "c45d7467839219d894804c1ef2d22ff4d4a5a84a6a273c526bafd4c1facde624"
},
"native/input/release1/views/diagram_sa_allocation_view.arcadia": {
"bytes": 1797,
"sha256": "352a3ca745517592b5c9b1239b5770f62f0466a329a91fd03457ef2b53c06ed6"
},
"native/input/release1/views/diagram_sa_capability_view.arcadia": {
"bytes": 1286,
"sha256": "8f18fcccbcddcf3c1ea0ffe727b5b23b77fc148c5da4fde953abe681e4c20cd2"
},
"native/input/release1/views/diagram_sa_service_view.arcadia": {
"bytes": 1960,
"sha256": "4c72cb7593d9c4be8d2600b0656a7a3a6ce84c45bb2f3e58a4ee5ae3bf91913c"
},
"native/input/release1/views/diagram_sa_state_view.arcadia": {
"bytes": 1576,
"sha256": "dd1bea81ac7dc549a53f9b190be6f13052e00ede345bd180444cc31edb892230"
},
"native/picker-baseline-1/01-start-0.jpg": {
"bytes": 116509,
"sha256": "d5c669575ffa4f8c0fe2027d39ad0b655bf6557a6315a9f27ef4cc81e3e4a9d8"
},
"native/picker-baseline-1/01-start.json": {
"bytes": 920,
"sha256": "f8ac2638390d3dc2a4bf7d64a9c6b8988c59ef377b53d7067fc41c1d30b5f091"
},
"native/picker-baseline-1/02-open-pending-0.jpg": {
"bytes": 115140,
"sha256": "d06c8963b5b752f6248ecead582c317cadfcf699955f7ecd629d6052ef157f70"
},
"native/picker-baseline-1/02-open-pending.json": {
"bytes": 927,
"sha256": "f326b85978dfa21cc2fd2e4454ebfee6a6d856e439de5ab41a8cec7c2998fb23"
},
"native/picker-baseline-1/03-open-dialog-0.jpg": {
"bytes": 674,
"sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b"
},
"native/picker-baseline-1/03-open-dialog-1.jpg": {
"bytes": 52573,
"sha256": "23ab19c5286f26ead42aec7e3690fda96cad512518243593f37e26579eb08ef6"
},
"native/picker-baseline-1/03-open-dialog.json": {
"bytes": 4915,
"sha256": "b1d4e6c59104b3d4d36210e855750cc7fc445a8d05e12f3c63cc43196df9df95"
},
"native/picker-baseline-1/04-teaching-path-0.jpg": {
"bytes": 674,
"sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b"
},
"native/picker-baseline-1/04-teaching-path-1.jpg": {
"bytes": 55565,
"sha256": "569f70974c0347c3accecb412de468751fc2294ba5f577b572765335a1cbca7c"
},
"native/picker-baseline-1/04-teaching-path.json": {
"bytes": 4919,
"sha256": "e7ef7212f637bff8125fa86892cc0b610a0c29a64c3715baeab12a6b36992620"
},
"native/picker-baseline-1/05-teaching-opened-0.jpg": {
"bytes": 115272,
"sha256": "c74bba329bca67a25c83352467842dadb8d393b17785b87929a4b7f10a8a2ae2"
},
"native/picker-baseline-1/05-teaching-opened.json": {
"bytes": 930,
"sha256": "dacbdbc29a7a61de78eb987069ed8e31f5818cdf6269b34836927109badd87ec"
},
"native/picker-baseline-1/06-teaching-ready-0.jpg": {
"bytes": 148650,
"sha256": "89957f78f5744c35f600b09f55dfbe52fddc4aac1b76ea1e820d1063b15c2833"
},
"native/picker-baseline-1/06-teaching-ready.json": {
"bytes": 1114,
"sha256": "4a708684d4469e4b5612ffe10216ad81d492eaf28730f0e11a4f5c4dcd30c903"
},
"native/picker-baseline-1/07-source-selected-0.jpg": {
"bytes": 146555,
"sha256": "ca53af5035524280f3144309a12c931193115e025bec6fe5b6b1799de3c8c949"
},
"native/picker-baseline-1/07-source-selected.json": {
"bytes": 1115,
"sha256": "4a725e4db48f2f8db7bc41a37ce69d49b2cd01a9b046791fee57b1bb580183a0"
},
"native/picker-baseline-1/08-source-actions-0.jpg": {
"bytes": 153686,
"sha256": "367876958aa58ed08c913d33530ba8da663d247ee3b0b9503625e7d3f945480c"
},
"native/picker-baseline-1/08-source-actions.json": {
"bytes": 1114,
"sha256": "4d244ccced055bdff280bbf99269554173fe85c816d15f9dae03aca40ad64ece"
},
"native/picker-baseline-1/09-transition-dialog-0.jpg": {
"bytes": 147596,
"sha256": "f197b031669070f731e354bca993ffc77ff43966349f4699d69e361287f2a704"
},
"native/picker-baseline-1/09-transition-dialog.json": {
"bytes": 1127,
"sha256": "3de33b54e5b15a598a901103acfa48650170d94724c5f5708b90d8fac6bcf6dd"
},
"native/picker-baseline-1/10-unfiltered-counterparts-0.jpg": {
"bytes": 138703,
"sha256": "a3c8464125657ea37550eb46a269f9a0c0646001ba387a8188b987ba74c6a270"
},
"native/picker-baseline-1/10-unfiltered-counterparts.json": {
"bytes": 1133,
"sha256": "977c0bbc366937ce2242b871b12f350cb56af4661f09c214e4945296195f4c55"
},
"native/picker-baseline-1/11-incompatible-selection-0.jpg": {
"bytes": 144348,
"sha256": "53884020ceb9c0b416386ceaa1e06801614ac132760e58c7c3320c820c251d04"
},
"native/picker-baseline-1/11-incompatible-selection.json": {
"bytes": 1132,
"sha256": "94f6814f61b75dc4ad9f9f14babed492865079c45fa5de17e6fc9c8a4fabc349"
},
"native/picker-baseline-1/12-cancel-preserved-0.jpg": {
"bytes": 145507,
"sha256": "d3db7643267cdc532f630c972d6c53823679326910e482179d447efad6289a61"
},
"native/picker-baseline-1/12-cancel-preserved.json": {
"bytes": 1116,
"sha256": "259769191f79cd381e2c8be3230b0da8912d35ef0ce5c362b2dd4f1189773647"
},
"native/picker-baseline-1/inputs.json": {
"bytes": 5843,
"sha256": "c1e7219e30171f3854c97cfe6b2fa2411d7b3f39c60a12ad41b57ca61a8f4dff"
},
"native/picker-baseline-1/process.json": {
"bytes": 61,
"sha256": "a99574647fda39a1529b9733794064c5214005df5a5ea821f7e0d0e61e4136a9"
},
"native/picker-baseline-1/result.json": {
"bytes": 1247,
"sha256": "a4cfe1faa8edc3ae8a7eaef9bb0714b357ecbaf91ad5a5bc6e787fea7fe013d3"
},
"native/picker-baseline-1/started.json": {
"bytes": 780,
"sha256": "b059824ca0e7b1a528ec8ececbe1b8d37335bcd5a34de921c3c9b6fda448acbe"
},
"native/picker-baseline-1/stderr.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"native/picker-baseline-1/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"native/picker-corrected-1/inputs.json": {
"bytes": 5843,
"sha256": "c1e7219e30171f3854c97cfe6b2fa2411d7b3f39c60a12ad41b57ca61a8f4dff"
},
"native/picker-corrected-1/process.json": {
"bytes": 61,
"sha256": "749d13d70bb8c9d7b9ad35a11e63980562b5e2140a1325454d61c6fd2cc5ef7d"
},
"native/picker-corrected-1/result.json": {
"bytes": 1256,
"sha256": "98d9e77ed93d56f2fb79f21c70ba5dceb012605f491fa6d704bd4f2f3bb2f8ea"
},
"native/picker-corrected-1/started.json": {
"bytes": 780,
"sha256": "9260aaee5e9e733eeb38a334a96881a1ed796149d9ece8438e0682f1990a8404"
},
"native/picker-corrected-1/stderr.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"native/picker-corrected-1/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"native/picker-corrected-2/01-opened-0.jpg": {
"bytes": 118158,
"sha256": "84db89be21fabc31958b4521587ff708619a4a4cbf0ded6fe8b2c58e94a26a68"
},
"native/picker-corrected-2/01-opened.json": {
"bytes": 922,
"sha256": "44cb8a61c47648bdcdfdc278ebfd43e45942eb5211e8f77624b9982186c89c25"
},
"native/picker-corrected-2/02-teaching-reopened-0.jpg": {
"bytes": 117821,
"sha256": "aa6fd6a99c2f808c6b55c734c83b0aa8b2fbd1ce03a14b02f609e5c84275bc66"
},
"native/picker-corrected-2/02-teaching-reopened.json": {
"bytes": 933,
"sha256": "b8a9dd0a0462f9b2703bfd9d88823b9ca00efba304f0f1cd923d637e8a01b6da"
},
"native/picker-corrected-2/03-loaded-0.jpg": {
"bytes": 148546,
"sha256": "dfe2700568a0e2f688c8c197224d75d7c15dfde5f060dfe2aba09631837687ef"
},
"native/picker-corrected-2/03-loaded.json": {
"bytes": 1107,
"sha256": "e8b18a5e75b52649dd87118f70c55be4b0e9c7e2ee00c2a3db730376a6c36aa4"
},
"native/picker-corrected-2/04-source-selected-0.jpg": {
"bytes": 146472,
"sha256": "74baf9f9059b4f0741d500a1f6d84ccb91474ff48de7466a03736e5decc868ca"
},
"native/picker-corrected-2/04-source-selected.json": {
"bytes": 1116,
"sha256": "45f7cfeab95e2ca2ca2ed2cd45bea9a269c1c6a1515dbe9cbcb3087fb2917c55"
},
"native/picker-corrected-2/05-actions-0.jpg": {
"bytes": 153725,
"sha256": "c00b412af778af51cb2a6658bae4f4e11bb024a0770c3ea5aa4a54383be381c4"
},
"native/picker-corrected-2/05-actions.json": {
"bytes": 1108,
"sha256": "1132f230694873a7e9536fd59f06b976e3b52041b119e2e9632b7c66de53d400"
},
"native/picker-corrected-2/06-transition-dialog-0.jpg": {
"bytes": 149653,
"sha256": "a4736781ac296164ddffe12005f9f1ad29d73ddd2bc752d14fb2b63ac2579153"
},
"native/picker-corrected-2/06-transition-dialog.json": {
"bytes": 1126,
"sha256": "2a2b8e8d9ac75a7c354e79bd5ba764c4024b045db009d5feb2b7f3909423f753"
},
"native/picker-corrected-2/07-compatible-menu-0.jpg": {
"bytes": 148938,
"sha256": "80621a49998cc7fc2a643e7338a6867c722b4365fabc713fac0b39f7812703f9"
},
"native/picker-corrected-2/07-compatible-menu.json": {
"bytes": 1126,
"sha256": "0f3c6a3d512e163953b9773de45df81d4129c0ded48a13c4d79de32dd8ab2d9e"
},
"native/picker-corrected-2/08-menu-dismissed-0.jpg": {
"bytes": 149416,
"sha256": "867236aeac2dcf839104692f73cb63fb45d7dcc38d95eaabd5cba8d0a7ff862a"
},
"native/picker-corrected-2/08-menu-dismissed.json": {
"bytes": 1125,
"sha256": "65ce375a1918a848cc57739f90eb15fbd55e9173c2343701791d0cf9740b818e"
},
"native/picker-corrected-2/09-search-focus-0.jpg": {
"bytes": 149668,
"sha256": "4a08a7955982956d518397832a0befdda951347f04da9af75af787fe5a163332"
},
"native/picker-corrected-2/09-search-focus.json": {
"bytes": 1123,
"sha256": "de505bdab419c6c60a3ad260479036d373f915333eb6fc62cd68c57ce2974af9"
},
"native/picker-corrected-2/10-alias-query-0.jpg": {
"bytes": 149373,
"sha256": "dceaa7f1e0e0959aa6e654d0b9f84cbe20badec8d99315e3fd2ebc3cb47f6f5f"
},
"native/picker-corrected-2/10-alias-query.json": {
"bytes": 1122,
"sha256": "c080f52656f6f603065e763cefb4a6ef790f4cfad96a3ddd3b6d58c5053cd62d"
},
"native/picker-corrected-2/11-filtered-menu-0.jpg": {
"bytes": 149367,
"sha256": "6893983b46da3a6f3da1ee3eac90dc50f34dbb257671835d7e893244d18b1b03"
},
"native/picker-corrected-2/11-filtered-menu.json": {
"bytes": 1124,
"sha256": "f8c5c8573840faef1337a7b429645e46652d29340104c2d6b15a9e1fbc14f152"
},
"native/picker-corrected-2/12-existing-selection-0.jpg": {
"bytes": 147991,
"sha256": "839dee6d22448d12d90758ca76f5503fae69a099af0e055e20cf814d7dd498ec"
},
"native/picker-corrected-2/12-existing-selection.json": {
"bytes": 1129,
"sha256": "84bc484447049dbafa1cda1b4c1ce0ade93c7ddcbf1c0489f2b18167deabed00"
},
"native/picker-corrected-2/13-refocus-search-0.jpg": {
"bytes": 148017,
"sha256": "70f5a9d3ea5d2b82b68e19d5682659ba0bba1abaeac0f56aca4a88ffeec74b76"
},
"native/picker-corrected-2/13-refocus-search.json": {
"bytes": 1125,
"sha256": "c2e42830b2d2364699210f716c375f2e63a447f910e729be0aa60c157f8341f6"
},
"native/picker-corrected-2/14-query-selected-0.jpg": {
"bytes": 148447,
"sha256": "e0e5807ed5f0f535110ceecc6c6aabb9d077ea67fa1d83367d2c50141e8cdc53"
},
"native/picker-corrected-2/14-query-selected.json": {
"bytes": 1125,
"sha256": "5d97ec91b3fd752d9f5e896c6fe97abc97ca40a91b6a708209663d4c198cc2e0"
},
"native/picker-corrected-2/15-no-results-selection-retained-0.jpg": {
"bytes": 150165,
"sha256": "25be189c30acb4c8dab03d458b2fc73bd09d0766513d5c9ee4e0e0bffcee2457"
},
"native/picker-corrected-2/15-no-results-selection-retained.json": {
"bytes": 1140,
"sha256": "a4f3c3e357aed8f66cad8b3c89f0e89d9eaf0662e3d740a06ba8eee6e7a33748"
},
"native/picker-corrected-2/16-parent-search-focus-0.jpg": {
"bytes": 149957,
"sha256": "84bdd2c3ed40178a16452a680932d27d42142220f09f79dc747906732aafb97d"
},
"native/picker-corrected-2/16-parent-search-focus.json": {
"bytes": 1130,
"sha256": "c3346906c51cf087a1fa850b46c867491630c2ec9a1565671e5674f2600ee261"
},
"native/picker-corrected-2/17-independent-card-queries-0.jpg": {
"bytes": 149082,
"sha256": "a48e99ca120c7e34ce2823bc0140a99778b88849eb7a6b245867f129a0e46aff"
},
"native/picker-corrected-2/17-independent-card-queries.json": {
"bytes": 1135,
"sha256": "ced4961b7bffb3d5f82757036ac77dab3cffba705bdd0a596183e686b3a59691"
},
"native/picker-corrected-2/18-field-review-0.jpg": {
"bytes": 144509,
"sha256": "734e83058cc66d1af13098b5b69c7e2cbad8daf2a3afd2a25780e65faa38bf71"
},
"native/picker-corrected-2/18-field-review.json": {
"bytes": 1123,
"sha256": "7489d77a9b1d4718f43aa10edca688e42a76a05507198538bd714ac8de638432"
},
"native/picker-corrected-2/19-keep-target-reviewed-0.jpg": {
"bytes": 144547,
"sha256": "8eafc33857650748a059a2c1dc345112b300a6d16d0395b33ff8f7e596d18d04"
},
"native/picker-corrected-2/19-keep-target-reviewed.json": {
"bytes": 1131,
"sha256": "509c9c3f0237a860c61e2413f05ab7bd9c51e5ffcbf97caede5c7298de0d479a"
},
"native/picker-corrected-2/20-reviewed-search-focus-0.jpg": {
"bytes": 144893,
"sha256": "0e46bb4787aba3c19cb27461cf70a22fdb579f3a63839a91ee68d37d7f9dd1d9"
},
"native/picker-corrected-2/20-reviewed-search-focus.json": {
"bytes": 1132,
"sha256": "33325c0cdac00fd49f25d4606c88a241dc6957f91f38d97b3ebafd4f5c42eaf2"
},
"native/picker-corrected-2/21-reviewed-query-selected-0.jpg": {
"bytes": 144916,
"sha256": "bec9c198292a5d4f6883c003112da1634fc7b8b8694a2910e51ed29079eaeb4b"
},
"native/picker-corrected-2/21-reviewed-query-selected.json": {
"bytes": 1134,
"sha256": "cf01d193ba011ed141e98236dff49fc06f789bbb26bb92b5a848a3f059c4319a"
},
"native/picker-corrected-2/22-cleared-search-review-retained-0.jpg": {
"bytes": 145507,
"sha256": "ff8670ba72a6b516e01bfcb9a8036940682e8f2caec92342b0902d9c0bb26b87"
},
"native/picker-corrected-2/22-cleared-search-review-retained.json": {
"bytes": 1141,
"sha256": "9a6a51578618a6c8aaf238329624102f3f37fda306be1dadffbc97dca178c221"
},
"native/picker-corrected-2/23-cancel-restores-saved-0.jpg": {
"bytes": 145951,
"sha256": "c79b52574c7ad9ea3a442679fbccb7e2683be6c0c8d015260398bc31fe9c705c"
},
"native/picker-corrected-2/23-cancel-restores-saved.json": {
"bytes": 1122,
"sha256": "6524ed5fd1cadc41de60d60818ec69e47a406189d03ede0822ea70ed502798b9"
},
"native/picker-corrected-2/inputs.json": {
"bytes": 5843,
"sha256": "c1e7219e30171f3854c97cfe6b2fa2411d7b3f39c60a12ad41b57ca61a8f4dff"
},
"native/picker-corrected-2/process.json": {
"bytes": 61,
"sha256": "6c4ea33570bd26271c5ec70c884dd4aeeab0642c33f46896ecd8832cb7ff5361"
},
"native/picker-corrected-2/result.json": {
"bytes": 2837,
"sha256": "b483791514deb4c1e16272dfabdf78f5a8092ddc8deeefc01a23baaed2913c2d"
},
"native/picker-corrected-2/started.json": {
"bytes": 2370,
"sha256": "cd97a270ae7e4eae8c6777ec311eb2437c4dd55e8b5882cbd8011c82fca2f7af"
},
"native/picker-corrected-2/stderr.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"native/picker-corrected-2/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"native/run-picker-baseline.py": {
"bytes": 2106,
"sha256": "86c88e94ccb5fcfd8f81db429db9991170839bc4a20dab408ab1d284c7e234f1"
},
"native/run-picker-corrected-2.py": {
"bytes": 2162,
"sha256": "e61347fa440337bc9629c0d3b532c39e16cfb8d1966132992676bfa1c8f546f5"
},
"native/run-picker-corrected.py": {
"bytes": 2124,
"sha256": "0e82acfca234bade68a6263f1c5a2222e1eeaff932fa7c92eb88063f254f6701"
}
},
"externalEvidence": {
"correctedCargo": {
"repositoryPath": "harness/experiments/transition-lifecycle-20260913/evidence.zip",
"commit": "fe43730bec196cc6ebbc06dcf827169f04c120f0",
"bytes": 8496343,
"sha256": "5f44ad5d83024a04a873142e77c5b8961c278a19d76492618c3ef2db63801070"
},
"baselineNative": {
"repositoryPath": "harness/experiments/ordinary-text-native-20260913/evidence.zip",
"commit": "3f9e6a18af96b5cd10940da4093be8da28b9c835",
"bytes": 5704887,
"sha256": "b827d0bdaec76b7b86d8a169530dcf98767c9883187ecdda02b4ea87171a42b5"
}
},
"captureCount": 35,
"imageCount": 37
}
@@ -0,0 +1,63 @@
"""Read-only portable receipt and frame verification, without archived execution."""
from pathlib import Path, PurePosixPath
from datetime import datetime
import hashlib,json,zipfile
ROOT=Path(__file__).resolve().parent
def pin(b):return {'bytes':len(b),'sha256':hashlib.sha256(b).hexdigest()}
def need(ok,msg):
if not ok:raise ValueError(msg)
def main():
files=json.loads((ROOT/'files.json').read_bytes())
for n,p in files.items():need(pin((ROOT/n).read_bytes())==p,'outer hash '+n)
manifest=json.loads((ROOT/'manifest.json').read_bytes())
with zipfile.ZipFile(ROOT/'evidence.zip') as z:
names=z.namelist()
need(len(names)==len(set(names)) and set(names)==set(manifest['members']),'exact ZIP membership')
need(all(not PurePosixPath(n).is_absolute() and '..' not in PurePosixPath(n).parts and '\\' not in n and ':' not in n for n in names),'safe members')
for n,p in manifest['members'].items():need(pin(z.read(n))==p,'member hash '+n)
def obj(n):return json.loads(z.read(n))
input_names={n[len('native/input/'):]:n for n in names if n.startswith('native/input/')}
reference=None;frame_count=0;image_count=0
for run,spec in manifest['runs'].items():
prefix='native/'+run+'/'
start=obj(prefix+'started.json');result=obj(prefix+'result.json');process=obj(prefix+'process.json')
need(all(result[k]==v for k,v in start.items()),run+' start/result relationship')
need(result['exitCode']==spec['exitCode'],run+' exit code')
need(isinstance(process['pid'],int) and process['pid']>0,run+' process ID')
need(datetime.fromisoformat(start['start'])<=datetime.fromisoformat(process['observed'])<=datetime.fromisoformat(result['finish']),run+' process time order')
need(result['helper']==pin(z.read(spec['helper'])),run+' launch helper')
need(result['inputs']==pin(z.read(prefix+'inputs.json')) and result['inputUnchanged'] is True,run+' input receipt')
inputs=obj(prefix+'inputs.json')
need(set(inputs)==set(input_names),run+' input inventory')
for n,p in inputs.items():need(pin(z.read(input_names[n]))==p,run+' input bytes '+n)
if reference is None:reference=inputs
need(inputs==reference,run+' all trials identical fixture')
for stream in ['stdout','stderr']:
b=z.read(prefix+stream+'.log');need(not b and result[stream]==pin(b),run+' empty stream '+stream)
build=obj(spec['build'])
need(result['buildReceipt']==pin(z.read(spec['build'])) and result['buildHead']==build['head'],run+' build receipt')
need(build['exitCode']==0 and build['inputsUnchanged'] is True,run+' build status')
need(datetime.fromisoformat(build['start'])<=datetime.fromisoformat(build['finish'])<=datetime.fromisoformat(start['start']),run+' build before launch')
gui=[a for a in build['artifacts'] if a['cargo']['target']['name']=='arcadia-studio']
need(len(gui)==1,run+' unique GUI artifact')
need(result['binary']==result['binaryAfter']==gui[0]['readback'],run+' unchanged GUI pin')
need(result['command']==[gui[0]['cargo']['executable']],run+' exact launch command')
images=[]
for metadata in spec['captures']:
frame=obj(metadata)
need(datetime.fromisoformat(start['start'])<=datetime.fromisoformat(frame['time'])<=datetime.fromisoformat(result['finish']),metadata+' capture interval')
need(frame['window']['app'].replace('\\','/')=='process:'+result['command'][0].replace('\\','/'),metadata+' window application path')
for image in frame['images']:
n=prefix+image['file'];need(n in manifest['members'] and z.read(n).startswith(b'\xff\xd8'),metadata+' JPEG reference')
need(image['width']>0 and image['height']>0,metadata+' frame dimensions')
images.append(n)
actual_images=[n for n in names if n.startswith(prefix) and n.endswith('.jpg')]
need(len(images)==len(set(images)) and set(images)==set(actual_images),run+' all images referenced once')
actual_metadata=[n for n in names if n.startswith(prefix) and n.endswith('.json') and 'images' in obj(n)]
need(set(actual_metadata)==set(spec['captures']) and len(actual_metadata)==len(spec['captures']),run+' all capture metadata')
frame_count+=len(actual_metadata);image_count+=len(images)
print(run,'exit',result['exitCode'],'captures',len(actual_metadata),'images',len(images))
need(frame_count==manifest['captureCount'] and image_count==manifest['imageCount'],'aggregate frame counts')
print('PASS:',len(names),'raw members;',len(reference),'unchanged fixture files;',frame_count,'capture records;',image_count,'original JPEGs.')
print('Build source inventories are external. Frames prove recorded states, not action execution or PID attestation. Capture helper was not start-pinned.')
if __name__=='__main__':main()
+134
View File
@@ -0,0 +1,134 @@
# Ordinary transition lifecycle and counterpart selection
Scope: issue 28 C1 cases 1–3 from the [published assessment](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/94a2bb2e5f4ad700f5d34b4fd818d421dcea6d66/harness/issue28-transition-overview-assessment-9e8r6t.md). Candidate branch `codex/transition-lifecycle-9e8r6t` extends ordinary/Text checkpoint `3f9e6a18af96b5cd10940da4093be8da28b9c835`. The behavioral change is `acbfb6cc929cb08b599b2e8ccf4d6752e63b84a8`; `d46854c2dc719bb8b44a0978102de44ecc3a6b39` adds only an inherited tuple-formatting correction. Live ownership and integration status remain in [issue 28](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/28#issuecomment-997).
## Findings and change
The existing endpoint deletion and ordinary transition engine conserves the retained counterpart in the six new lifecycle cases. These tests exercise both directions with independently authored root/child work, shared exchange-item definitions, SA typed ports, internal exchanges, placements, collapsed state, route labels and external artifact links. Expected deletions enumerate fixture-owned IDs independently of deletion implementation; whole-project equality detects incidental target changes. Old plans become stale, explicitly deleted target IDs are refused, a fresh implicit retry creates one counterpart, and a fresh subsequent retry reuses it without an undo entry. Recreating the source with the same name/alias but a new UUID does not silently recover its old correspondence. Explicit relinking changes only reviewed fields and adds the new trace.
These tests prove exact decoded-project JSON and canonical source round trips. They do not establish preservation of original source comments/formatting or file reopening in the packaged GUI. No tombstones, automatic name matching, structural merge policy, or new metamodel are introduced.
The native baseline exposed a separate obstacle: an Activity's long, unsorted counterpart menu offered opposite-layer ports, scenario records and other incompatible kinds. Selecting an offered output port caused an incompatibility error and disabled Apply. The correction shares the core's existing kind compatibility policy through `counterpart_compatible`, filters existing choices by name or alias, and sorts by name/alias/identity. Labels distinguish duplicate names using kind and alias. Explicit creation remains available.
Search state is separate from mappings and field resolutions. Typing does not replan or replace preview IDs; selected targets remain visible when filtered out. Missing and incompatible recovered selections remain identifiable and refused by the normal planner. Unique inferred correspondence counts distinct target IDs, so duplicate pair traces do not create false ambiguity; several distinct targets show an unresolved choice.
The chosen UI adds a text field to the existing picker. A searchable combo box was considered; it brings per-card cached option and selection state, while the separate field supports the needed filtering without changing reviewed mapping semantics. The inherited picker still requires pointer/touch interaction for opening and selection; this change is not complete keyboard accessibility.
## Verification record
Windows environment: Rust/Cargo 1.98.1, `stable-x86_64-pc-windows-gnullvm`, LLVM MinGW, locked offline dependencies, one Cargo job, incremental caching disabled. Linux work remains paused by the user.
- First lifecycle run: compiled, then six failures in the new fixture's incomplete warning expectation. No product failure was established. The fixture now supplies a system of interest and concrete item contracts and checks the exact remaining allocation/context warning multiset by ID, code and field.
- Corrected core run: four compatibility tests and six lifecycle tests passed; 710 recorded inputs remained unchanged. Compatibility covers both-direction element/relation families, wrong layers/categories, missing/nonsemantic identities, and the observed real SA-port refusal against the full planner.
- Desktop workflow filter: 25 passed, zero failed/ignored, 1,164 filtered; includes query preservation, duplicate-name selection, recovery refusal and trace inference. Existing core workflows: 28 passed, zero failed/ignored/filtered.
- Strict Clippy: `cargo clippy --locked --offline --workspace --all-targets -- -D warnings` passed on `acbfb6c`. All successful test/lint input bytes match its 710 recorded Git blobs.
- Initial `cargo fmt --all -- --check` found only the inherited tuple layout in `diagram/focus.rs`. Commit `d46854c` fixes those line breaks; the repeated format check passed with empty output. Tests/lint above were not rerun on this formatting-only commit.
- Windows workspace/binaries build passed on `d46854c`, with the same 710 unchanged inputs as the final format check. GUI: 117,737,472 bytes, SHA256 `6d3b0996bb153ab55c21672feec1e7303eb6c59eaad01f8dd8aa09609e08086c`. This is a development build, not a packaged release.
Reproduction commands from the repository root with the [documented Windows toolchain/runtime](../README.md) on PATH (`CARGO_BUILD_JOBS=1`, `CARGO_INCREMENTAL=0` for this small disk):
```text
cargo test --locked --offline -p oa-core --test transition_lifecycle --test transition_counterpart_compatibility -- --nocapture
cargo test --locked --offline -p oa-core --test workflows -- --nocapture
cargo test --locked --offline -p oa-desktop --bin arcadia-studio workflow:: -- --nocapture
cargo clippy --locked --offline --workspace --all-targets -- -D warnings
cargo fmt --all -- --check
cargo build --locked --offline --workspace --bins
```
See [closed Cargo evidence](experiments/transition-lifecycle-20260913/README.md) for source inventories, raw results, hashes and an offline verifier. Its initial failures remain separate from corrected runs. The parent independently ran the reviewed verifier against both working and exact staged package bytes.
## Native picker observation
The [native comparison package](experiments/transition-picker-native-20260913/README.md) retains all three launch attempts, original frames, copied teaching inputs, both build receipts and the independent frame review. It links the separate build/source evidence. The capture-helper source was retained from the earlier session but was not pinned at launch; frames establish recorded states, not independent action/PID attestation.
The corrected GUI trial ran 2026-09-13 15:22:22–15:27:13 UTC on `d46854c`. It reopened the copied teaching model from Recent models, selected **Assess pressure alarm**, and used **Create System counterpart…** to open the ordinary transition dialog. All 23 original state captures are retained. The copied files and executable remained byte-identical; the process exited zero with empty stdout/stderr. An earlier launch without the toolchain environment exited immediately with Windows code 3221225781 and empty logs; the same binary launched successfully after loading that environment. This is recorded as an invocation failure, not a passed launch or a reproduced in-app crash.
- Frames 06–07: two source mapping cards; Create plus six alphabetically sorted Function candidates, with full teaching-model aliases. Baseline frame 10 offered incompatible kinds; baseline 11 shows the resulting port-selection error.
- Frames 10–12: uppercase `@SA_REPAIR` narrows the list to the matching function, retaining the current Assess mapping until the user explicitly selects Repair.
- Frames 15 and 17: zero results retain Repair; two visible cards retain independent queries.
- Frames 18–19: normal Name conflict requires review; Keep target enables Apply. Frame 22 shows that clearing search retains the selected target and Keep target decision.
- Frame 23: Cancel dismisses the workflow and restores All changes saved. No reviewed transition was applied in this trial.
Independent read-only reviews found no blocking defect in the shared predicate, selection/query handling, recovery behavior, or lifecycle conservation evaluator. Two refinements were incorporated: empty-query feedback no longer asks to clear an already empty search, and query-isolation tests use two actual OA source cards. A separate review of original native frames confirmed the bounded observations above without a blocking visual regression. Remaining friction: disabled Apply can have its required field-review explanation below the fold, requiring a scroll. Keyboard-only picker operation and broader transition-dialog feedback remain incomplete.
That picker-only trial establishes the finite Windows selection/review/cancel workflow. The applied lifecycle follow-up below supplies separate native and saved-model evidence; neither trial is a packaged-release acceptance claim.
## Applied native lifecycle follow-up, 2026-09-22
The original disabled-Apply observation led to a small presentation correction:
transition counts and the next blocking decision now remain beside Apply, outside
the scrolling mappings. Guidance identifies destination, boundary-kind, field or
other conflict decisions; it does not weaken Apply validation. The count says
`reviewed items` because a reused item need not change the model. No new persisted
state or transition semantics are introduced.
The [isolated producer/checker](experiments/transition-native-lifecycle-20260913/producer/README.md)
defines a deterministic two-layer fixture and independently constructs expected
deletion, recreation and relink states. It compares complete Projects, exact
engineering-warning multisets, JSON/source round trips and persisted identities.
An independent critic exposed two potential false passes: learning replacement
identities again after reopening, and accepting identities inserted only into
compiler memory. Both were corrected. First relink records accepted identities;
reopen/reapply require that unchanged receipt. Forty synthetic falsification
trials include deliberate prose/geometry corruption, identity drift, omitted
persisted IDs and receipt misuse. They are checker evidence, not GUI actions.
The [closed Cargo and oracle package](experiments/transition-native-lifecycle-20260913/checks/README.md)
retains ten successful invocations, including strict all-target Clippy, final
desktop build and both format checks. Earlier checker build1 incorrectly rejected
the fixture's expected engineering warnings during native admission; corrected
build2 accepts only the exact expected diagnostic multiset. The original failed
admission and an interrupted first GUI session were not counted as successful
native trials. The final build differs from the linted footer only in the
`changes` to `reviewed items` literal; the package verifies that exact delta.
Four fresh, closed Windows sessions provide the
[native evidence](experiments/transition-native-lifecycle-20260913/native/README.md):
| Flow | Native operations and saved-source acceptance |
| --- | --- |
| OA source, trials 2–3 | Delete OA root; recreate only that root through Textual model; search `@sa_response`; explicitly select the retained SA function; keep target Name and Description; apply/save; restart/open/save; unchanged review/apply/save. Five full-Project checks pass. |
| SA source, trials 4–5 | Delete SA root; Undo/save restores the complete original; Redo/save repeats the deletion; recreate only that root through Textual model; search `@oa_response`; explicitly select the retained OA activity; keep both prose fields; apply/save; restart/open/save; unchanged review/apply/save. Seven full-Project checks pass. |
The retained opposite-layer design includes children, internal exchanges, typed
SA ports, shared contracts, authored diagram geometry/routes and artifact links.
Recreated roots intentionally have no children or placement. The relink adds one
trace and its reviewed baseline; it does not recreate deleted design work.
For each direction, both saved source files remain byte-identical through relink,
restart/save and unchanged reapply/save, with the first accepted source/trace IDs
pinned. This is evidence about these concrete files, not a general promise to
preserve arbitrary authored comments or whitespace.
All four processes exited zero with empty stdout/stderr and unchanged executable
hashes. Trials 2–3 used GUI SHA256
`3813e79d84cf4a83f2290639b93c3daaa8de427b0268b288f87e4f535fa1aea2`;
trials 4–5 used the final 117,090,816-byte GUI SHA256
`f0bb021e350bcc55435d29e3daf6a4b3dd7389620df99f5d169f9cb669fd53d0`.
Build receipts record HEAD `d0b7c28` plus captured uncommitted input bytes;
the 715-file final inventory, rather than that old HEAD alone, identifies the
tested source. All 715 captured inputs were checked against exact staged Git
blobs before source commit `05e4c83054dc8b2f156e50e7731a50ac19ae8767`.
The [independent review](experiments/transition-native-lifecycle-20260913/review.md)
separates original-frame observations from saved-model comparisons and found no
new blocking defect in this bounded workflow. Native frames show the persistent
Name/Description instruction advancing with two, one and zero conflicts, while
expected modeling warnings remain nonblocking. Destination, boundary and generic
conflict guidance were reviewed in code but not exercised by these native trials.
All main-window captures are 1442×932; attempted resizing did not establish
minimum-window behavior. Keyboard/screen-reader accessibility, native implicit
creation, relink Undo/Redo, arbitrary models and packaged launch remain outside
this acceptance. Linux checks remain paused by the user. Integration and release
status belong to issue 28; this is branch-specific evidence.
Run the read-only evidence verifiers with Python 3.10+:
```text
python harness/experiments/transition-native-lifecycle-20260913/checks/verify.py
python harness/experiments/transition-native-lifecycle-20260913/native/verify.py
```
These verify archived bytes and recorded relationships without executing archived
code or authenticating historical interaction. The producer README gives the
commands and native steps to reproduce behavior independently.