feat(server): expose typed semantic transition review

This commit is contained in:
2026-09-06 05:46:51 +03:00
parent 29df024a7f
commit d130013155
8 changed files with 6555 additions and 19 deletions
+170
View File
@@ -72,6 +72,18 @@ pub(crate) fn router() -> Router<AppState> {
"/api/v1/projects/{id}/transitions/apply",
post(commit_transition),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/query",
post(query_semantic_transitions),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/preview",
post(preview_semantic_transition),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/apply",
post(commit_semantic_transition),
)
.fallback(|| async {
ApiError::new(
StatusCode::NOT_FOUND,
@@ -1089,6 +1101,164 @@ struct TransitionApplyRequest {
plan: oa_core::transitions::TransitionPlan,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SemanticQueryRequest {
traces: Vec<Id>,
#[serde(default)]
references: BTreeMap<Id, Id>,
revision: Option<u64>,
}
fn semantic_rejected(error: impl std::fmt::Display) -> ApiError {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"semantic_transition_rejected",
error.to_string(),
)
}
async fn query_semantic_transitions(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: SemanticQueryRequest = json_body(&body)?;
if request.traces.is_empty() || request.traces.len() > 100 || request.references.len() > 5_000 {
return Err(ApiError::bad_request("Semantic queries require 1–100 trace IDs and at most 5,000 reference mappings."));
}
let selected: BTreeSet<_> = request.traces.iter().copied().collect();
if selected.len() != request.traces.len() {
return Err(ApiError::bad_request("Semantic query trace IDs must be unique."));
}
let current = store.get(id)?;
check_read_revision(&current, request.revision)?;
let mut items = Vec::new();
for trace_id in request.traces {
let trace = current.model.traces.get(&trace_id).ok_or_else(|| {
ApiError::new(StatusCode::NOT_FOUND, "trace_not_found", "Selected trace does not exist.")
})?;
let facets = oa_core::semantic::semantic_facets(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let drift = oa_core::semantic::trace_semantic_drift(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let linked_impacts = oa_core::semantic::linked_semantic_impacts(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
items.push(json!({"trace":trace.id,"oa":trace.oa,"sa":trace.sa,"facets":facets,"drift":drift,"linked_impacts":linked_impacts}));
}
Ok(response(&current, StatusCode::OK, json!({"revision":current.revision,"items":items})))
}).await
}
fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest) -> ApiResult<()> {
let transition = &request.transition;
let decisions = request.reviews.iter().try_fold(0usize, |count, review| {
count
.checked_add(review.resolutions.len())?
.checked_add(review.reset.len())
});
if transition.sources.len() > 100
|| transition.mappings.len() > 200
|| transition.resolutions.len() > 400
|| request.traces.len() > 100
|| request.references.len() > 5_000
|| request.reviews.len() > 100
|| decisions.is_none_or(|count| count > 5_000)
|| request.candidates.len() > 200
|| request.accepted_impacts.len() > 200
{
return Err(ApiError::bad_request(
"Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.",
));
}
Ok(())
}
async fn preview_semantic_transition(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
let expected = expected(&headers)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: oa_core::semantic::SemanticTransitionRequest = json_body(&body)?;
check_semantic_request(&request)?;
let current = store.checked(id, &expected)?;
let plan = oa_core::semantic::plan_semantic_transition(&current.model, request)
.map_err(semantic_rejected)?;
if !plan.commands.is_empty() {
check_commands(&plan.commands)?;
}
if plan.conflicts.is_empty() {
let candidate = oa_core::semantic::prepare_semantic_transition(&current.model, &plan)
.map_err(semantic_rejected)?;
PreparedProject::from_command(&current, candidate)?;
}
Ok(response(
&current,
StatusCode::OK,
json!({"base_revision":current.revision,"plan":plan}),
))
})
.await
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SemanticApplyRequest {
base_revision: u64,
plan: oa_core::semantic::SemanticTransitionPlan,
}
async fn commit_semantic_transition(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
let expected = expected(&headers)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: SemanticApplyRequest = json_body(&body)?;
let current = store.checked(id, &expected)?;
if current.revision != request.base_revision {
let mut error = ApiError::new(
StatusCode::PRECONDITION_FAILED,
"plan_revision_conflict",
"Semantic preview revision changed. Create a new preview.",
);
error.current_revision = Some(current.revision);
return Err(error);
}
check_semantic_request(&request.plan.request)?;
if !request.plan.commands.is_empty() {
check_commands(&request.plan.commands)?;
}
let candidate =
oa_core::semantic::prepare_semantic_transition(&current.model, &request.plan)
.map_err(semantic_rejected)?;
let prepared = PreparedProject::from_command(&current, candidate)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}),
))
})
.await
}
async fn commit_transition(
State(state): State<AppState>,
id: ProjectPath,
+2
View File
@@ -14,6 +14,8 @@ mod data_contracts;
mod port_anchors;
#[path = "http/source_projects.rs"]
mod source_projects;
#[path = "http/semantic_transitions.rs"]
mod semantic_transitions;
fn v2_missing_ports() -> Value {
let mut old: Value = serde_json::from_slice(include_bytes!(
@@ -0,0 +1,675 @@
use super::*;
use oa_core::{semantic::*, transitions::*, *};
fn fixture() -> Project {
oa_core::persistence::decode(include_bytes!(
"../../../oa-core/tests/fixtures/v4-reviewed-prose.json"
))
.unwrap()
}
fn key(n: u128) -> Id {
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
}
fn request(
source: Id,
target: Id,
decisions: Vec<(FacetSelector, SemanticDecision)>,
) -> SemanticTransitionRequest {
let direction = if source == key(3) {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
};
let mut transition = TransitionRequest::new(direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.reviews.push(SemanticReviewRequest {
source,
resolutions: decisions
.into_iter()
.map(|(facet, decision)| FacetResolution { facet, decision })
.collect(),
reset: vec![],
});
request
}
async fn post(
server: &TestServer,
id: Id,
operation: &str,
etag: Option<&str>,
value: &impl serde::Serialize,
) -> reqwest::Response {
let mut call = server
.client
.post(server.url(&format!("/projects/{id}/semantic-transitions/{operation}")))
.json(value);
if let Some(etag) = etag {
call = call.header("if-match", etag);
}
call.send().await.unwrap()
}
async fn ok(response: reqwest::Response) -> Value {
let status = response.status();
let value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{value}");
value
}
async fn preview(
server: &TestServer,
stored: &StoredProject,
request: &SemanticTransitionRequest,
) -> Value {
ok(post(
server,
stored.resource_id,
"preview",
Some(&stored.etag()),
request,
)
.await)
.await
}
fn assert_pair(stored: &StoredProject) {
assert_eq!(
oa_core::source_project::compile_bundle(&stored.source_project, Default::default())
.unwrap()
.decoded
.project,
stored.model
);
}
#[tokio::test]
async fn semantic_partial_review_query_source_and_restart_share_one_revision() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (id, _) = server.import(&fixture()).await;
let mut stored = server.get(id).await;
let mut bundle = stored.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Authored review rationale\n");
let changed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", stored.etag())
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(changed.status(), StatusCode::OK);
stored = server.get(id).await;
let disk_path = directory.path().join(format!("{id}.json"));
let before_bytes = std::fs::read(&disk_path).unwrap();
let query = json!({"traces":[key(4)],"references":{},"revision":stored.revision});
let queried = ok(post(&server, id, "query", None, &query).await).await;
assert_eq!(queried["items"][0]["drift"]["own"], "Unreviewed");
let req = request(
key(2),
key(3),
vec![
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::KeepTarget,
),
(
FacetSelector::Header(HeaderFacet::Name),
SemanticDecision::TakeSource,
),
],
);
let plan = preview(&server, &stored, &req).await;
assert_eq!(plan["plan"]["conflicts"], json!([]));
assert_eq!(
plan["plan"]["reviews"][0]["acknowledged"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(std::fs::read(&disk_path).unwrap(), before_bytes);
assert_eq!(server.get(id).await.etag(), stored.etag());
let applied = ok(post(&server, id, "apply", Some(&stored.etag()), &plan).await).await;
assert_eq!(applied["revision"], stored.revision + 1);
let committed = server.get(id).await;
assert_pair(&committed);
assert!(
committed.source_project.files[&committed.source_project.entry]
.contains("// Authored review rationale")
);
assert_eq!(committed.model.elements[&key(3)].name, "OA dimension now");
assert!(
matches!(committed.model.elements[&key(3)].shared(), Some(SharedBody::DataType { unit, definition: DataTypeDefinition::Real { .. }, .. }) if unit == "ft")
);
let queried = ok(post(&server, id, "query", None, &json!({"traces":[key(4)]})).await).await;
let facts: Vec<FacetDrift> =
serde_json::from_value(queried["items"][0]["facets"].clone()).unwrap();
assert_eq!(
facts
.iter()
.find(|f| f.selector == FacetSelector::Data(DataFacet::Unit))
.unwrap()
.drift,
Drift::Unchanged
);
assert_eq!(
facts
.iter()
.find(|f| f.selector == FacetSelector::Data(DataFacet::Constructor))
.unwrap()
.drift,
Drift::Unreviewed
);
assert!(
queried["items"][0]["drift"]["own_unreviewed"]
.as_bool()
.unwrap()
);
let repeat = preview(&server, &committed, &req).await;
let reapplied = ok(post(&server, id, "apply", Some(&committed.etag()), &repeat).await).await;
assert_eq!(reapplied["changed"], false);
assert_eq!(reapplied["revision"], committed.revision);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(id).await;
assert_eq!(reopened.model, committed.model);
assert_eq!(reopened.source_project, committed.source_project);
assert_eq!(reopened.etag(), committed.etag());
server.stop().await;
}
#[tokio::test]
async fn semantic_strict_rejection_stale_plans_and_source_only_changes_never_write() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (id, etag) = server.import(&fixture()).await;
let stored = server.get(id).await;
let disk_path = directory.path().join(format!("{id}.json"));
let before = std::fs::read(&disk_path).unwrap();
let req = request(
key(2),
key(3),
vec![(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::TakeSource,
)],
);
let plan = preview(&server, &stored, &req).await;
for operation in ["preview", "apply"] {
let body = if operation == "preview" {
serde_json::to_value(&req).unwrap()
} else {
plan.clone()
};
assert_eq!(
post(&server, id, operation, None, &body).await.status(),
StatusCode::PRECONDITION_REQUIRED
);
}
let mut malformed = serde_json::to_value(&req).unwrap();
malformed["reviews"][0]["resolutions"][0]["facet"] = json!({"Data":"arbitrary_path"});
assert_eq!(
post(&server, id, "preview", Some(&etag), &malformed)
.await
.status(),
StatusCode::BAD_REQUEST
);
malformed = serde_json::to_value(&req).unwrap();
malformed["unexpected"] = true.into();
assert_eq!(
post(&server, id, "preview", Some(&etag), &malformed)
.await
.status(),
StatusCode::BAD_REQUEST
);
for (query, expected) in [
(json!({"traces":[]}), StatusCode::BAD_REQUEST),
(json!({"traces":[key(4),key(4)]}), StatusCode::BAD_REQUEST),
(json!({"traces":[key(99)]}), StatusCode::NOT_FOUND),
(
json!({"traces":[key(4)],"revision":99}),
StatusCode::CONFLICT,
),
] {
assert_eq!(
post(&server, id, "query", None, &query).await.status(),
expected
);
}
for mutation in [
"commands",
"base_fingerprint",
"prepared_fingerprint",
"base_revision",
] {
let mut rejected = plan.clone();
let expected = if mutation == "base_revision" {
rejected[mutation] = 99.into();
StatusCode::PRECONDITION_FAILED
} else {
rejected["plan"][mutation] = if mutation == "commands" {
json!([])
} else {
json!("altered")
};
StatusCode::UNPROCESSABLE_ENTITY
};
assert_eq!(
post(&server, id, "apply", Some(&etag), &rejected)
.await
.status(),
expected
);
}
let mut duplicate = req.clone();
let repeated = duplicate.reviews[0].resolutions[0].clone();
duplicate.reviews[0].resolutions.push(repeated);
let conflicted = preview(&server, &stored, &duplicate).await;
assert!(
!conflicted["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(
post(&server, id, "apply", Some(&etag), &conflicted)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(std::fs::read(&disk_path).unwrap(), before);
assert_eq!(server.get(id).await.etag(), etag);
let mut bundle = stored.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str("\n// Source-only concurrent edit\n");
let changed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", &etag)
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(changed.status(), StatusCode::OK);
let current = server.get(id).await;
assert_eq!(current.model, stored.model);
let bytes = std::fs::read(&disk_path).unwrap();
for precondition in [&etag, &current.etag()] {
assert_eq!(
post(&server, id, "apply", Some(precondition), &plan)
.await
.status(),
StatusCode::PRECONDITION_FAILED
);
}
assert_eq!(std::fs::read(&disk_path).unwrap(), bytes);
server.stop().await;
}
#[tokio::test]
async fn semantic_candidate_constructor_reverse_direction_and_explicit_trace_selection() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let mut model = fixture();
let mut second = model.traces[&key(4)].clone();
second.id = key(5);
second.alias = "second_review".into();
second.kind = TraceKind::Manual;
model.traces.insert(second.id, second);
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let mut req = request(
key(3),
key(2),
vec![
(
FacetSelector::Data(DataFacet::Constructor),
SemanticDecision::TakeSource,
),
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::Candidate,
),
],
);
let ambiguous = preview(&server, &stored, &req).await;
assert!(
!ambiguous["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
req.traces.insert(key(3), key(5));
let mut candidate = model.elements[&key(2)].clone();
*candidate.shared_mut().unwrap() = model.elements[&key(3)].shared().unwrap().clone();
if let SharedBody::DataType { unit, .. } = candidate.shared_mut().unwrap() {
*unit = "cm".into();
}
req.candidates
.push(SemanticRecord::Element(candidate.clone()));
req.references.insert(key(900), key(901)); // Historical correspondence survives transport unchanged.
let envelope = preview(&server, &stored, &req).await;
assert_eq!(
envelope["plan"]["request"],
serde_json::to_value(&req).unwrap()
);
assert_eq!(envelope["plan"]["conflicts"], json!([]));
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
let applied = server.get(id).await;
assert_eq!(applied.model.elements[&key(2)], candidate);
assert!(
applied.model.traces[&key(4)]
.baseline
.as_ref()
.unwrap()
.semantic
.is_none()
);
assert!(
applied.model.traces[&key(5)]
.baseline
.as_ref()
.unwrap()
.semantic
.is_some()
);
assert_pair(&applied);
server.stop().await;
}
#[tokio::test]
async fn semantic_new_mapping_keeps_preview_generated_identifiers() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let mut model = Project::empty();
let source = model
.add_element(Kind::Activity, Layer::Oa, "Inspect", None)
.unwrap();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let req = SemanticTransitionRequest::new(TransitionRequest::new(
TransitionDirection::OaToSa,
vec![source],
));
let envelope = preview(&server, &stored, &req).await;
assert_eq!(envelope["plan"]["conflicts"], json!([]));
let plan: SemanticTransitionPlan = serde_json::from_value(envelope["plan"].clone()).unwrap();
let expected = oa_core::semantic::apply_semantic_transition(&model, &plan).unwrap();
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
assert_eq!(server.get(id).await.model, expected);
server.stop().await;
}
#[tokio::test]
async fn semantic_resets_and_invalid_candidates_preserve_deliberate_review_boundaries() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let model = fixture();
let reviewed = oa_core::semantic::plan_semantic_transition(
&model,
request(
key(2),
key(3),
vec![
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::KeepTarget,
),
(
FacetSelector::Header(HeaderFacet::Name),
SemanticDecision::KeepTarget,
),
],
),
)
.unwrap();
let model = oa_core::semantic::apply_semantic_transition(&model, &reviewed).unwrap();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let before = std::fs::read(directory.path().join(format!("{id}.json"))).unwrap();
let mut req = request(
key(2),
key(3),
vec![(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::Candidate,
)],
);
let mut candidate = model.elements[&key(3)].clone();
candidate.name = "Unselected incidental edit".into();
req.candidates.push(SemanticRecord::Element(candidate));
let invalid = preview(&server, &stored, &req).await;
assert!(!invalid["plan"]["conflicts"].as_array().unwrap().is_empty());
assert_eq!(
post(&server, id, "apply", Some(&stored.etag()), &invalid)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
before
);
req = request(key(2), key(3), vec![]);
req.reviews[0]
.reset
.push(FacetSelector::Data(DataFacet::Unit));
let reset = preview(&server, &stored, &req).await;
assert_eq!(
reset["plan"]["request"]["reviews"][0]["reset"],
json!([{"Data":"Unit"}])
);
assert_eq!(reset["plan"]["conflicts"], json!([]));
ok(post(&server, id, "apply", Some(&stored.etag()), &reset).await).await;
let current = server.get(id).await;
let facts = &current.model.traces[&key(4)]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets;
assert_eq!(facts.len(), 1);
assert_eq!(
facts[0].selector(),
FacetSelector::Header(HeaderFacet::Name)
);
assert_eq!(current.model.elements, model.elements);
assert_pair(&current);
server.stop().await;
}
fn cleanup_fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Source record", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "Target record", None)
.unwrap();
let base = p
.add_element(Kind::DataType, Layer::Sa, "Base record", None)
.unwrap();
for (id, supertype) in [(oa, None), (base, None), (sa, Some(base))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
default: None,
unit: String::new(),
};
}
let leaf = p
.add_element(Kind::DataField, Layer::Sa, "Coordinate", Some(base))
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Location", None)
.unwrap();
let field = p
.add_element(Kind::DataField, Layer::Sa, "Payload", Some(item))
.unwrap();
for (id, type_ref) in [(leaf, TypeRef::Real), (field, TypeRef::Named(sa))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
}
let sender = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let receiver = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let exchange = p.relate(
RelationKind::FunctionalExchange,
"Location report",
sender,
receiver,
);
let RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} = &mut p.relations.get_mut(&exchange).unwrap().body
else {
panic!()
};
items.push(item);
for id in [*source_port, *target_port] {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
} => incoming_items.push(item),
SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
} => outgoing_items.push(item),
_ => panic!(),
}
}
let scenario = p
.add_element(Kind::Scenario, Layer::Sa, "Report location", None)
.unwrap();
let message = p
.add_element(Kind::Occurrence, Layer::Sa, "Report", Some(scenario))
.unwrap();
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![MessageValue {
path: vec![field],
field: leaf,
expression: "10".into(),
}],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(message);
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "record_trace".into(),
name: "Record correspondence".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
(p, oa, sa, message)
}
#[tokio::test]
async fn semantic_cleanup_impacts_require_exact_acceptance_before_atomic_apply() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (model, oa, sa, message) = cleanup_fixture();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let path = directory.path().join(format!("{id}.json"));
let before = std::fs::read(&path).unwrap();
let mut req = request(
oa,
sa,
vec![(
FacetSelector::Data(DataFacet::RecordSupertype),
SemanticDecision::TakeSource,
)],
);
let unresolved = preview(&server, &stored, &req).await;
assert!(
!unresolved["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
let impacts: Vec<SemanticImpact> =
serde_json::from_value(unresolved["plan"]["impacts"].clone()).unwrap();
assert_eq!(impacts.len(), 1);
assert_eq!(impacts[0].object, message);
assert_ne!(impacts[0].before, impacts[0].after);
assert_eq!(
post(&server, id, "apply", Some(&stored.etag()), &unresolved)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
let mut inaccurate = impacts.clone();
inaccurate[0].after = inaccurate[0].before.clone();
req.accepted_impacts = inaccurate;
let inaccurate = preview(&server, &stored, &req).await;
assert!(
!inaccurate["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(std::fs::read(&path).unwrap(), before);
req.accepted_impacts = impacts;
let approved = preview(&server, &stored, &req).await;
assert_eq!(approved["plan"]["conflicts"], json!([]));
assert_eq!(
approved["plan"]["request"]["accepted_impacts"],
serde_json::to_value(&req.accepted_impacts).unwrap()
);
ok(post(&server, id, "apply", Some(&stored.etag()), &approved).await).await;
let current = server.get(id).await;
assert_eq!(current.revision, stored.revision + 1);
assert!(
matches!(current.model.elements[&message].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.is_empty())
);
assert_pair(&current);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.model, current.model);
server.stop().await;
}
@@ -0,0 +1,136 @@
"""Validate actual local HTTP responses against served and checked-in OpenAPI.
Requires jsonschema. Usage: python semantic_http_contract.py /path/to/arcadia-server
The supplied binary is launched on loopback with a fresh temporary store.
"""
import copy
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from jsonschema import Draft202012Validator, FormatChecker
ROOT = Path(__file__).resolve().parents[3]
local = json.loads((ROOT / "docs/openapi.json").read_text(encoding="utf-8"))
for schema in local["components"]["schemas"].values():
Draft202012Validator.check_schema(schema)
def validator(spec, name):
return Draft202012Validator({"$ref": "#/components/schemas/" + name, "components": spec["components"]},
format_checker=FormatChecker())
with tempfile.TemporaryDirectory(prefix="arcadia-semantic-http-") as directory:
env = dict(os.environ)
env.pop("ARCADIA_SERVER_TOKEN", None)
process = subprocess.Popen([sys.argv[1], "--bind", "127.0.0.1:0", "--data-dir", directory],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
try:
banner = process.stderr.readline()
address = re.search(r"listening on (127\.0\.0\.1:\d+)", banner)
assert address, banner
base = "http://" + address.group(1)
def call(method, path, body=None, etag=None, expected=200):
headers = {"Content-Type": "application/json"}
if etag:
headers["If-Match"] = etag
request = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(),
headers=headers, method=method)
try:
response = urllib.request.urlopen(request, timeout=15)
except urllib.error.HTTPError as error:
response = error
with response:
data = json.load(response)
assert response.status == expected, (response.status, data)
return data, response.headers.get("ETag")
served, _ = call("GET", "/openapi.json")
assert served == local, "Server binary embeds a different OpenAPI file; rebuild it"
checked = 0
def validate(name, value):
global checked
for spec in (local, served):
validator(spec, name).validate(value)
checked += 1
old = json.loads((ROOT / "crates/oa-core/tests/fixtures/v4-reviewed-prose.json").read_text())
imported, etag = call("POST", "/api/v1/projects/import", old, expected=201)
path = "/api/v1/projects/" + imported["resource_id"]
stored, _ = call("GET", path)
validate("StoredProject", stored)
validate("Project", stored["model"])
trace = next(iter(stored["model"]["traces"]))
oa = stored["model"]["traces"][trace]["oa"]
sa = stored["model"]["traces"][trace]["sa"]
query = {"traces": [trace], "references": {oa: sa}, "revision": stored["revision"]}
validate("SemanticQueryRequest", query)
queried, _ = call("POST", path + "/semantic-transitions/query", query)
validate("SemanticQueryResult", queried)
request = {"transition": {"direction": "OaToSa", "sources": [oa], "scope": "SelectedOnly",
"mappings": [{"source": oa, "target": sa, "target_kind": None}], "fields": [],
"resolutions": [], "destination_diagram": None}, "traces": {oa: trace},
"references": {}, "reviews": [{"source": oa, "resolutions": [
{"facet": {"Header": "Name"}, "decision": "TakeSource"},
{"facet": {"Data": "Unit"}, "decision": "KeepTarget"}], "reset": []}],
"candidates": [], "accepted_impacts": []}
validate("SemanticTransitionRequest", request)
one_sided = copy.deepcopy(request)
one_sided["reviews"][0]["resolutions"] = [{"facet": {"Literal": {"oa": "Only source"}}, "decision": "KeepTarget"}]
validate("SemanticTransitionRequest", one_sided)
admitted, _ = call("POST", path + "/semantic-transitions/preview", one_sided, etag)
validate("SemanticTransitionEnvelope", admitted)
assert admitted["plan"]["conflicts"] # Valid typed input, inapplicable to the integer/real fixture.
preview, _ = call("POST", path + "/semantic-transitions/preview", request, etag)
validate("SemanticTransitionEnvelope", preview)
assert not preview["plan"]["conflicts"], preview
result, updated_etag = call("POST", path + "/semantic-transitions/apply", preview, etag)
validate("MutationResult", result)
stored, _ = call("GET", path)
validate("StoredProject", stored)
validate("Project", stored["model"])
baseline = stored["model"]["traces"][trace]["baseline"]
validate("TraceBaseline", baseline)
validate("SemanticBaseline", baseline["semantic"])
query.pop("revision")
queried, _ = call("POST", path + "/semantic-transitions/query", query)
validate("SemanticQueryResult", queried)
problem, _ = call("POST", path + "/semantic-transitions/apply", preview, etag, expected=412)
validate("Problem", problem)
assert updated_etag != etag
invalid = [
("FacetSelector", {"path": ["definition", "minimum"]}),
("FacetSelector", {"Data": "Unsupported"}),
("FacetReview", {"Data": {"IntegerMinimum": {"oa": {"Value": "wrong"}, "sa": {"Value": None}}}}),
("FacetReview", {"Data": {"Unit": {"oa": {"Value": 42}, "sa": "NotApplicable"}}}),
("FacetSelector", {"Literal": {"oa": "literal", "sa": None, "index": 0}}),
("SemanticQueryRequest", {"traces": [trace, trace]}),
("SemanticQueryRequest", {"traces": []}),
("SemanticTransitionEnvelope", {**preview, "unknown": True}),
]
obsolete = copy.deepcopy(stored["model"])
obsolete["schema_version"] = 4
invalid.append(("Project", obsolete))
for name, value in invalid:
assert not validator(local, name).is_valid(value), (name, value)
assert not validator(served, name).is_valid(value), (name, value)
# NotApplicable is distinct from Value(null); each leaf fixes the optionality.
validate("FacetReview", {"Data": {"IntegerMinimum": {"oa": "NotApplicable", "sa": {"Value": None}}}})
validate("FacetSelector", {"Annotation": {"oa": {"path": [oa], "field": sa}, "sa": None}})
validate("FacetSelector", {"Annotation": {"oa": {"path": [oa], "field": sa}}})
validate("SemanticImpact", {"object": oa, "after": {"Element": stored["model"]["elements"][oa]}})
print(f"{len(local['components']['schemas'])} valid schemas; {checked} actual/typed values validated against served+local contracts; {len(invalid)} invalid shapes rejected")
finally:
process.terminate()
process.wait(timeout=15)
+205
View File
@@ -0,0 +1,205 @@
"""Refresh the closed semantic OpenAPI catalog from the frozen Rust leaf table.
Run from any directory with Python 3: semantic_schema.py [--check].
This generates documentation only, never runtime selectors or model mutations.
"""
import copy
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
PATH = ROOT / "docs/openapi.json"
spec = json.loads(PATH.read_text(encoding="utf-8"))
schemas = spec["components"]["schemas"]
types = (ROOT / "crates/oa-core/src/semantic/types.rs").read_text(encoding="utf-8")
model = (ROOT / "crates/oa-core/src/model.rs").read_text(encoding="utf-8")
def ref(name):
return {"$ref": f"#/components/schemas/{name}"}
def obj(properties, required=None):
return {"type": "object", "properties": properties,
"required": list(properties) if required is None else required,
"additionalProperties": False}
def array(item, maximum=None):
result = {"type": "array", "items": item}
if maximum is not None:
result["maxItems"] = maximum
return result
def optional(item):
return {"anyOf": [item, {"type": "null"}]}
def enum(names):
return {"type": "string", "enum": names}
def variant(name, value):
return obj({name: value})
def typed(name):
if name.startswith("Option<"):
return optional(typed(name[7:-1]))
if name.startswith("Vec<"):
return array(typed(name[4:-1]))
primitives = {"String": {"type": "string"}, "bool": {"type": "boolean"},
"u32": {"type": "integer", "minimum": 0, "maximum": 4294967295},
"i64": {"type": "integer", "minimum": -9223372036854775808,
"maximum": 9223372036854775807}, "f64": {"type": "number"}}
return primitives[name] if name in primitives else ref(name)
def reviewed(name):
key = "Reviewed" + re.sub(r"[^A-Za-z0-9]", "", name)
observation = {"oneOf": [enum(["NotApplicable"]), variant("Value", typed(name))]}
schemas[key] = obj({"oa": observation, "sa": copy.deepcopy(observation)})
return ref(key)
def keypair(name):
key = "KeyPair" + name
schemas[key] = obj({"oa": optional(typed(name)), "sa": optional(typed(name))}, [])
return ref(key)
for name in ["DataConstructor", "PortConstructor", "OccurrenceConstructor", "EventConstructor",
"MessageConstructor", "ReferenceCollection", "OrderedCollection", "IncidentEnd",
"ArtifactFacet", "ComponentPortKind", "ComponentPortOrientation", "PortDirection",
"FragmentOperator", "StateActionKind", "ArtifactRelation"]:
source = types if f"pub enum {name}" in types else model
body = re.search(r"pub enum " + name + r"\s*\{([^}]+)\}", source).group(1)
body = re.sub(r"//[^\n]*|#\[[^\]]*\]", "", body)
names = [value.strip() for value in body.split(",") if value.strip()]
assert all(re.fullmatch(r"[A-Za-z]+", value) for value in names), (name, names)
schemas[name] = enum(names)
catalog = types.split("macro_rules! scalar_families", 1)[1].split("pub(super) use", 1)[0]
families = re.findall(r"(\w+), (\w+), (\w+) \{([^}]+)\}", catalog)
assert len(families) == 13
selectors, reviews = [], []
leaf_count = 0
for family, selector, review, body in families:
leaves = re.findall(r"(\w+): ([\w<>]+)", body)
leaf_count += len(leaves)
schemas[selector] = enum([leaf for leaf, _ in leaves])
schemas[review] = {"oneOf": [variant(leaf, reviewed(value)) for leaf, value in leaves]}
selectors.append(variant(family, ref(selector)))
reviews.append(variant(family, ref(review)))
assert leaf_count == 84
schemas["AnnotationKey"] = obj({"path": array(ref("Id")), "field": ref("Id")})
id_keys, text_keys, annotation_keys = keypair("Id"), keypair("String"), keypair("AnnotationKey")
artifact_types = {"Presence": "bool", "Name": "String", "Description": "String", "Owner": "Id",
"Relation": "ArtifactRelation", "Uri": "String", "Revision": "Option<String>",
"Fragment": "Option<String>"}
schemas["ArtifactReview"] = {"oneOf": [variant(k, reviewed(v)) for k, v in artifact_types.items()]}
selectors += [variant("Member", obj({"collection": ref("ReferenceCollection"), "keys": id_keys})),
variant("Order", ref("OrderedCollection")), variant("Literal", text_keys),
variant("Annotation", annotation_keys), variant("Binding", id_keys), variant("Child", id_keys),
variant("Incident", obj({"keys": id_keys, "end": ref("IncidentEnd")})),
variant("Artifact", obj({"keys": id_keys, "facet": ref("ArtifactFacet")}))]
reviews += [variant("Member", obj({"collection": ref("ReferenceCollection"), "keys": id_keys, "review": reviewed("bool")})),
variant("Order", obj({"collection": ref("OrderedCollection"), "review": reviewed("Vec<Id>")})),
variant("Literal", obj({"keys": text_keys, "review": reviewed("bool")})),
variant("Annotation", obj({"keys": annotation_keys, "review": reviewed("Option<MessageValue>")})),
variant("Binding", obj({"keys": id_keys, "review": reviewed("Option<LifelineBinding>")})),
variant("Child", obj({"keys": id_keys, "review": reviewed("bool")})),
variant("Incident", obj({"keys": id_keys, "end": ref("IncidentEnd"), "review": reviewed("bool")})),
variant("Artifact", obj({"keys": id_keys, "review": ref("ArtifactReview")}))]
schemas["FacetSelector"] = {"oneOf": selectors}
schemas["FacetReview"] = {"oneOf": reviews}
schemas["SemanticBaseline"] = obj({"facets": array(ref("FacetReview"))})
schemas["TraceBaseline"]["properties"]["semantic"] = {**optional(ref("SemanticBaseline")), "default": None,
"description": "Absent/null means Unreviewed. Historical typed facts never become live graph references."}
schemas["Project"]["properties"]["schema_version"]["const"] = 5
schemas["MigrationReport"]["properties"]["from_schema"]["enum"] = [1, 2, 3, 4]
schemas["MigrationReport"]["properties"]["to_schema"]["const"] = 5
schemas["MigrationReport"]["description"] = schemas["MigrationReport"]["description"].replace("V1/V2/V3 to V4", "V1/V2/V3/V4 to V5")
spec["info"]["description"] = spec["info"]["description"].replace("schema-4", "schema-5")
spec["paths"]["/api/v1/projects/import"]["post"]["description"] = "Schema-5 native JSON is described below. Schema-1/2/3/4 JSON and source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Prior prose reviews remain intact; absent semantic review stays Unreviewed. Export emits schema 5 / arcadia 5. See docs/language.rst."
schemas["SourceBundle"]["description"] = schemas["SourceBundle"]["description"].replace("source4 workspace (source3 migrates explicitly)", "source5 workspace (source3/4 migrate explicitly)")
schemas["Drift"] = enum(["Unreviewed", "Unchanged", "OaChanged", "SaChanged", "BothChanged"])
schemas["FacetDrift"] = obj({"selector": ref("FacetSelector"), "current": ref("FacetReview"), "drift": ref("Drift")})
schemas["SemanticDrift"] = obj({"own": ref("Drift"), "linked": ref("Drift"),
"own_unreviewed": typed("bool"), "linked_unreviewed": typed("bool")})
schemas["LinkedSemanticImpact"] = obj({"objects": id_keys, "traces": array(ref("Id")),
"drift": ref("Drift"), "unreviewed": typed("bool")})
for name, members in [("SemanticRecord", ["Element", "Relation", "Artifact"]),
("ImpactRecord", ["Element", "Relation", "Artifact", "Diagram", "Trace"])]:
schemas[name] = {"oneOf": [variant(n, ref("ArtifactLink" if n == "Artifact" else n)) for n in members]}
schemas["SemanticImpact"] = obj({"object": ref("Id"), "before": optional(ref("ImpactRecord")), "after": optional(ref("ImpactRecord"))}, ["object"])
schemas["SemanticDecision"] = enum(["KeepTarget", "TakeSource", "Candidate"])
schemas["FacetResolution"] = obj({"facet": ref("FacetSelector"), "decision": ref("SemanticDecision")})
schemas["SemanticReviewRequest"] = obj({"source": ref("Id"), "resolutions": array(ref("FacetResolution"), 5000),
"reset": {**array(ref("FacetSelector"), 5000), "default": []}}, ["source", "resolutions"])
def mappings(maximum):
return {"type": "object", "propertyNames": {"format": "uuid"},
"additionalProperties": ref("Id"), "maxProperties": maximum, "default": {}}
schemas["SemanticTransitionRequest"] = obj({"transition": ref("TransitionRequest"), "traces": mappings(100),
"references": mappings(5000), "reviews": {**array(ref("SemanticReviewRequest"), 100), "default": []},
"candidates": {**array(ref("SemanticRecord"), 200), "default": []},
"accepted_impacts": {**array(ref("SemanticImpact"), 200), "default": []}}, ["transition"])
schemas["SemanticTransitionRequest"]["description"] = "Canonical OA-to-SA references include historical UUIDs. Traces map selected source IDs to trace IDs. Combined resolutions/reset limit: 5,000. Candidates must equal exactly the result of their selected facets; accepted_impacts explicitly acknowledge exact induced changes."
schemas["SemanticReviewPreview"] = obj({"source": ref("Id"), "target": ref("Id"), "facets": array(ref("FacetDrift")),
"acknowledged": array(ref("FacetSelector")), "retired": array(ref("FacetSelector")), "linked_impacts": array(ref("LinkedSemanticImpact"))})
schemas["SemanticTransitionPlan"] = obj({"base_fingerprint": typed("String"), "request": ref("SemanticTransitionRequest"),
"transition": ref("TransitionPlan"), "reviews": array(ref("SemanticReviewPreview")), "impacts": array(ref("SemanticImpact")),
"conflicts": array(typed("String")), "commands": array(ref("Command"), 200), "prepared_fingerprint": optional(typed("String"))})
schemas["SemanticTransitionPlan"]["required"].remove("prepared_fingerprint")
schemas["SemanticTransitionEnvelope"] = obj({"base_revision": ref("Revision"), "plan": ref("SemanticTransitionPlan")})
schemas["SemanticQueryRequest"] = obj({"traces": {**array(ref("Id"), 100), "minItems": 1, "uniqueItems": True},
"references": mappings(5000), "revision": optional(ref("Revision"))}, ["traces"])
schemas["SemanticQueryItem"] = obj({"trace": ref("Id"), "oa": ref("Id"), "sa": ref("Id"),
"facets": array(ref("FacetDrift")), "drift": ref("SemanticDrift"), "linked_impacts": array(ref("LinkedSemanticImpact"))})
schemas["SemanticQueryResult"] = obj({"revision": ref("Revision"), "items": array(ref("SemanticQueryItem"), 100)})
for operation, request, result in [("query", "SemanticQueryRequest", "SemanticQueryResult"),
("preview", "SemanticTransitionRequest", "SemanticTransitionEnvelope"),
("apply", "SemanticTransitionEnvelope", "MutationResult")]:
endpoint = copy.deepcopy(spec["paths"]["/api/v1/projects/{id}/transitions/" + ("apply" if operation == "apply" else "preview")])
post = endpoint["post"]
post["operationId"] = operation + "SemanticTransition"
post["summary"] = {"query": "Query typed trace drift and linked impacts without mutation",
"preview": "Preview and source-validate a typed semantic transition without writing",
"apply": "Conditionally apply a prepared semantic candidate in one model/source revision"}[operation]
post["requestBody"]["content"]["application/json"]["schema"] = ref(request)
post["responses"]["200"]["content"]["application/json"]["schema"] = ref(result)
post["description"] = "Uses the frozen core typed semantic contract. A supplied plan is a client-owned candidate, not a signed receipt; fingerprints check consistency, not original review authorship. Preview validates source writeback when conflict-free. Apply preserves preview-generated IDs and does not replan random identities."
if operation == "query":
post["parameters"] = [p for p in post["parameters"] if p["in"] != "header"]
post["responses"].pop("412", None)
post["responses"].pop("428", None)
post["responses"]["409"] = {"$ref": "#/components/responses/Problem"}
post["description"] = "Read one immutable snapshot. Optional revision mismatch returns409; missing trace returns404; duplicate/empty trace selection returns400. References are canonical OA-to-SA correspondence. No write, baseline refresh or external artifact fetch."
spec["paths"]["/api/v1/projects/{id}/semantic-transitions/" + operation] = endpoint
def check_refs(value):
if isinstance(value, dict):
if "$ref" in value and value["$ref"].startswith("#/components/schemas/"):
assert value["$ref"].split("/")[-1] in schemas, value
for child in value.values():
check_refs(child)
elif isinstance(value, list):
for child in value:
check_refs(child)
check_refs(spec)
rendered = json.dumps(spec, indent=2, ensure_ascii=False) + "\n"
if "--check" in sys.argv:
assert json.loads(PATH.read_text(encoding="utf-8")) == spec, "Run semantic_schema.py to refresh OpenAPI"
else:
PATH.write_text(rendered, encoding="utf-8")
print(f"Closed catalog: {len(families)} families, {leaf_count} scalar leaves; {len(schemas)} schemas")
+114 -6
View File
@@ -763,6 +763,18 @@ bearer token, if any. Non-loopback binding requires a configured token.</p>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/transitions/apply</span></span></p></td>
<td><p>Conditionally apply that client-held plan and base_revision.</p></td>
</tr>
<tr><td><p>POST</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/query</span></span></p></td>
<td><p>Read selected traces' typed facts, own drift and linked impacts.</p></td>
</tr>
<tr><td><p>POST</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/preview</span></span></p></td>
<td><p>Prepare typed semantic decisions and source writeback without writing.</p></td>
</tr>
<tr><td><p>POST</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/apply</span></span></p></td>
<td><p>Apply that prepared candidate under its original shared revision/ETag.</p></td>
</tr>
</tbody>
</table>
<p><span class="docutils literal"><span class="pre">Content-Type:</span> application/json</span> is required for JSON requests.
@@ -784,8 +796,8 @@ candidate validation reports the same diagnostic in its 200 response.
Parsing, validation and query processing use snapshot workers, so this work
does not hold the catalog lock or serialize unrelated model preparation.</p>
<p>Import accepts schema-1 JSON through the shared explicit migration adapter,
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
as well as schema-2, schema-3, schema-4 and current schema-5 JSON. Source import uses the shared language parser
and its supported migrations. Export uses schema 5 / arcadia 5. Native JSON
replacement accepts supported older schemas through the same explicit migration.
Only documented query parameters are accepted. In particular, appending
<span class="docutils literal"><span class="pre">?revision=...</span></span> to model/source export is an error, not historical retrieval.</p>
@@ -796,7 +808,7 @@ Only documented query parameters are accepted. In particular, appending
<pre class="literal-block">{
&quot;entry&quot;: &quot;rescue.arcadia&quot;,
&quot;files&quot;: {
&quot;rescue.arcadia&quot;: &quot;arcadia 4; project rescue \&quot;Rescue\&quot; { include \&quot;oa/work.arcadia\&quot;; }\n&quot;,
&quot;rescue.arcadia&quot;: &quot;arcadia 5; project rescue \&quot;Rescue\&quot; { include \&quot;oa/work.arcadia\&quot;; }\n&quot;,
&quot;oa/work.arcadia&quot;: &quot;// Engineering rationale\noa_activity assess \&quot;Assess assistance\&quot; {}\n&quot;
}
}</pre>
@@ -807,7 +819,7 @@ targets or another hosted resource. All submitted files must be reachable;
missing files, cycles, duplicate keys, case collisions and escaping paths are
rejected. The bundle permits at most 256 files, 32 include edges along a path,
and 1,024 include edges in total. Model and source-byte budgets apply across
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
the complete bundle, not separately per file. Current bundle syntax is source5; source3/4 bundles migrate explicitly while
preserving their files/includes/comments. V1/V2 single-file migration remains
available through interchange import.</p>
<p>Compilation inserts UUIDs into headers that omit them. Import returns 201 with
@@ -999,7 +1011,7 @@ contract in the same transaction when removing a used interface allocation.</p>
<p>Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
add semantic defaults, generated interface allocations and unresolved message
values. Old source3 bundles retain files/includes/comments during upgrade; all
current exports use schema4/source4. Reading an old envelope does not write it or
current exports use schema5/source5. Reading an old envelope does not write it or
advance its ETag. Canonical exports omit transient migration reports.</p>
</section>
<section id="validation">
@@ -1009,7 +1021,7 @@ advance its ETag. Canonical exports omit transient migration reports.</p>
<p>or:</p>
<pre class="literal-block">{&quot;format&quot;:&quot;arcadia&quot;, &quot;source&quot;:&quot;complete source document&quot;}</pre>
<p>or:</p>
<pre class="literal-block">{&quot;format&quot;:&quot;source-project&quot;, &quot;source_project&quot;:{&quot;entry&quot;:&quot;rescue.arcadia&quot;, &quot;files&quot;:{&quot;rescue.arcadia&quot;:&quot;arcadia 4; project rescue \&quot;Rescue\&quot; {}&quot;}}}</pre>
<pre class="literal-block">{&quot;format&quot;:&quot;source-project&quot;, &quot;source_project&quot;:{&quot;entry&quot;:&quot;rescue.arcadia&quot;, &quot;files&quot;:{&quot;rescue.arcadia&quot;:&quot;arcadia 5; project rescue \&quot;Rescue\&quot; {}&quot;}}}</pre>
<p>A well-formed validation request returns 200 with <span class="docutils literal">valid</span> and <span class="docutils literal">issues</span> even
if its candidate is invalid, including a model record that fails typed decoding.
Malformed request JSON or an invalid format/payload envelope is a 400.
@@ -1093,6 +1105,102 @@ authenticated as an untouched server preview; the same authenticated client
can submit ordinary typed commands. The core fingerprint is a change detector,
not a security digest. There is no plan token, expiration store or receipt.</p>
</section>
<section id="typed-semantic-transition-review">
<h2>Typed semantic transition review</h2>
<p>The sibling <span class="docutils literal"><span class="pre">semantic-transitions</span></span> routes use the <a class="reference external" href="language.html#typed-semantic-review-facts">typed review semantics</a> in <span class="docutils literal">language.rst</span> sections 5.1–5.5.
The original prose transition routes keep
their existing contract. All new routes use JSON and the existing authentication,
capacity, worker and error boundaries.</p>
<p>Query accepts <span class="docutils literal"><span class="pre">{&quot;traces&quot;:[&quot;trace-uuid&quot;],&quot;references&quot;:{},&quot;revision&quot;:1}</span></span>.
<span class="docutils literal">references</span> is an optional map of canonical OA UUIDs to SA UUIDs, including
historical deleted identities. It never guesses correspondences from names.
<span class="docutils literal">revision</span> is optional; a mismatch returns 409. Select 1–100 unique existing
trace IDs; an empty or duplicate selection returns 400 and a missing trace
returns 404. The response includes the current revision and items containing
<span class="docutils literal">trace</span>, <span class="docutils literal">oa</span>, <span class="docutils literal">sa</span>, typed <span class="docutils literal">facets</span>, <span class="docutils literal">drift</span> and <span class="docutils literal">linked_impacts</span>.
Own and linked drift and their unreviewed flags remain distinct. Query reads one
immutable snapshot, does not refresh baselines, and does not fetch artifact URIs.</p>
<p>Preview accepts <span class="docutils literal">SemanticTransitionRequest</span>:</p>
<pre class="literal-block">{
&quot;transition&quot;: {
&quot;direction&quot;: &quot;OaToSa&quot;,
&quot;sources&quot;: [&quot;40000000-0000-4000-8000-000000000002&quot;],
&quot;scope&quot;: &quot;SelectedOnly&quot;,
&quot;mappings&quot;: [{
&quot;source&quot;: &quot;40000000-0000-4000-8000-000000000002&quot;,
&quot;target&quot;: &quot;40000000-0000-4000-8000-000000000003&quot;,
&quot;target_kind&quot;: null
}],
&quot;fields&quot;: [], &quot;resolutions&quot;: [], &quot;destination_diagram&quot;: null
},
&quot;traces&quot;: {}, &quot;references&quot;: {},
&quot;reviews&quot;: [{
&quot;source&quot;: &quot;40000000-0000-4000-8000-000000000002&quot;,
&quot;resolutions&quot;: [
{&quot;facet&quot;:{&quot;Header&quot;:&quot;Name&quot;}, &quot;decision&quot;:&quot;TakeSource&quot;},
{&quot;facet&quot;:{&quot;Data&quot;:&quot;Unit&quot;}, &quot;decision&quot;:&quot;KeepTarget&quot;}
],
&quot;reset&quot;: []
}],
&quot;candidates&quot;: [], &quot;accepted_impacts&quot;: []
}</pre>
<p>The example maps an existing OA data type to its traced SA counterpart, takes
only its name and acknowledges intentionally different units. Other facets stay
unreviewed or retain their earlier drift. Select an explicit trace through the
<span class="docutils literal">traces</span> map (source UUID to trace UUID) when multiple trace kinds connect the
pair. <span class="docutils literal">reset</span> deliberately invalidates exact old selectors after changed
correspondence. The optional collections default to empty. Semantic header
decisions use typed facets; leave legacy <span class="docutils literal">transition.fields</span> and
<span class="docutils literal">transition.resolutions</span> empty.</p>
<p>Facet selectors are closed enums, for example <span class="docutils literal"><span class="pre">{&quot;Data&quot;:&quot;IntegerMinimum&quot;}</span></span>
or <span class="docutils literal"><span class="pre">{&quot;Member&quot;:{&quot;collection&quot;:&quot;SignalItems&quot;,&quot;keys&quot;:{&quot;oa&quot;:&quot;oa-uuid&quot;,&quot;sa&quot;:&quot;sa-uuid&quot;}}}</span></span>.
Annotation keys contain the entire path and field; enum literal keys are exact
strings. <span class="docutils literal">KeepTarget</span>, <span class="docutils literal">TakeSource</span> and <span class="docutils literal">Candidate</span> operate on independently
selected facts. Candidate records use existing Element/Relation/ArtifactLink
types, must preserve identity/alias, and must equal precisely the result of
the selected facets. Constructor review covers its coupled introduced/removed
fields. Membership review does not silently acknowledge order; target-only
members remain independent. OpenAPI defines every selector and typed payload.</p>
<p>Preview requires <span class="docutils literal"><span class="pre">If-Match</span></span> and returns <span class="docutils literal"><span class="pre">{&quot;base_revision&quot;:1,&quot;plan&quot;:{...}}</span></span>.
The plan contains the original typed request, mapping plan, review facts,
acknowledged/retired selectors, linked impacts, exact prepared impacts, conflicts,
commands, and base/prepared fingerprints. A conflict-free preview also prepares
the complete authored source bundle and checks its model equivalence and capacity.
No preview changes the model, source, revision or stored bytes.</p>
<p>If ordinary preparation induces additional cleanup, the plan reports exact typed
before/after impacts and a conflict. Review those impacts, place the accepted
records in <span class="docutils literal">accepted_impacts</span>, and request another preview. Acceptance cannot
override a contradictory selected facet. Missing mappings, ambiguous order,
duplicate decisions, invalid candidates or unresolved impacts cannot be applied.
This is the shared core planner and command preparation, with no server-specific
semantic mutation engine.</p>
<p>Apply accepts the entire preview envelope under the same <span class="docutils literal"><span class="pre">If-Match</span></span>. It checks
<span class="docutils literal">base_revision</span>, the model fingerprint, unresolved conflicts, the prepared
candidate fingerprint, ordinary model validation and coupled source preparation,
then atomically stores one accepted model/source revision. The final store check
also catches a concurrent edit during preparation. Source-only edits invalidate
the preview through the shared ETag even when the model fingerprint is unchanged.
Stale ETag/revision returns 412; semantic rejection returns 422 with code
<span class="docutils literal">semantic_transition_rejected</span>. Exact no-op candidates preserve revision.
Read and preview again after a successful changed apply before reviewing further.</p>
<p>A supplied plan remains a client-owned candidate. Fingerprints detect stale or
inconsistent proposals; they are not tamper-proof signatures, authentication
receipts or proof of original review authorship. Apply preserves preview-generated
identities and does not rerun the planner with fresh random IDs. Authenticated
clients already have ordinary model/source editing privileges. The server does
not persist plans, tokens or review sessions.</p>
<p>Requests allow 100 sources, trace choices and reviews; 200 mappings, candidates
and accepted impacts; 5,000 canonical reference mappings; and 5,000 facet
resolutions plus resets across all reviews. The legacy 400 prose-resolution
limit and 200-command transaction limit still apply. Query has the same 5,000
reference-map limit. Core bounded linked traversal errors remain observable as
semantic rejection; no truncated result silently claims complete review.</p>
<p>Schema/source 5 adds <span class="docutils literal">TraceBaseline.semantic</span>. Absence or null is Unreviewed;
migrating older prose baselines does not fabricate semantic history. The persisted
baseline contains only closed typed <span class="docutils literal">FacetReview</span> facts, not whole candidate
records or a generic JSON value tree. Historical UUIDs remain historical, including
after deletion, and source uses explicit <span class="docutils literal">uuid</span> tokens for them.</p>
</section>
<section id="error-contract">
<h2>Error contract</h2>
<p>All errors have <span class="docutils literal"><span class="pre">Content-Type:</span> application/problem+json</span>. Example:</p>
+124 -6
View File
@@ -124,6 +124,15 @@ bearer token, if any. Non-loopback binding requires a configured token.
* - POST
- ``/api/v1/projects/{id}/transitions/apply``
- Conditionally apply that client-held plan and base_revision.
* - POST
- ``/api/v1/projects/{id}/semantic-transitions/query``
- Read selected traces' typed facts, own drift and linked impacts.
* - POST
- ``/api/v1/projects/{id}/semantic-transitions/preview``
- Prepare typed semantic decisions and source writeback without writing.
* - POST
- ``/api/v1/projects/{id}/semantic-transitions/apply``
- Apply that prepared candidate under its original shared revision/ETag.
``Content-Type: application/json`` is required for JSON requests.
``text/plain; charset=utf-8`` is used for Arcadia source. The service does not
@@ -146,8 +155,8 @@ Parsing, validation and query processing use snapshot workers, so this work
does not hold the catalog lock or serialize unrelated model preparation.
Import accepts schema-1 JSON through the shared explicit migration adapter,
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
as well as schema-2, schema-3, schema-4 and current schema-5 JSON. Source import uses the shared language parser
and its supported migrations. Export uses schema 5 / arcadia 5. Native JSON
replacement accepts supported older schemas through the same explicit migration.
Only documented query parameters are accepted. In particular, appending
``?revision=...`` to model/source export is an error, not historical retrieval.
@@ -160,7 +169,7 @@ A source bundle contains ``entry`` and a map of ``files``. For example::
{
"entry": "rescue.arcadia",
"files": {
"rescue.arcadia": "arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
"rescue.arcadia": "arcadia 5; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
}
}
@@ -172,7 +181,7 @@ targets or another hosted resource. All submitted files must be reachable;
missing files, cycles, duplicate keys, case collisions and escaping paths are
rejected. The bundle permits at most 256 files, 32 include edges along a path,
and 1,024 include edges in total. Model and source-byte budgets apply across
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
the complete bundle, not separately per file. Current bundle syntax is source5; source3/4 bundles migrate explicitly while
preserving their files/includes/comments. V1/V2 single-file migration remains
available through interchange import.
@@ -394,7 +403,7 @@ contract in the same transaction when removing a used interface allocation.
Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
add semantic defaults, generated interface allocations and unresolved message
values. Old source3 bundles retain files/includes/comments during upgrade; all
current exports use schema4/source4. Reading an old envelope does not write it or
current exports use schema5/source5. Reading an old envelope does not write it or
advance its ETag. Canonical exports omit transient migration reports.
Validation
@@ -410,7 +419,7 @@ or::
or::
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 5; project rescue \"Rescue\" {}"}}}
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
if its candidate is invalid, including a model record that fails typed decoding.
@@ -506,6 +515,115 @@ authenticated as an untouched server preview; the same authenticated client
can submit ordinary typed commands. The core fingerprint is a change detector,
not a security digest. There is no plan token, expiration store or receipt.
Typed semantic transition review
--------------------------------
The sibling ``semantic-transitions`` routes use the `typed review semantics
<language.html#typed-semantic-review-facts>`_ in ``language.rst`` sections 5.1–5.5.
The original prose transition routes keep
their existing contract. All new routes use JSON and the existing authentication,
capacity, worker and error boundaries.
Query accepts ``{"traces":["trace-uuid"],"references":{},"revision":1}``.
``references`` is an optional map of canonical OA UUIDs to SA UUIDs, including
historical deleted identities. It never guesses correspondences from names.
``revision`` is optional; a mismatch returns 409. Select 1–100 unique existing
trace IDs; an empty or duplicate selection returns 400 and a missing trace
returns 404. The response includes the current revision and items containing
``trace``, ``oa``, ``sa``, typed ``facets``, ``drift`` and ``linked_impacts``.
Own and linked drift and their unreviewed flags remain distinct. Query reads one
immutable snapshot, does not refresh baselines, and does not fetch artifact URIs.
Preview accepts ``SemanticTransitionRequest``::
{
"transition": {
"direction": "OaToSa",
"sources": ["40000000-0000-4000-8000-000000000002"],
"scope": "SelectedOnly",
"mappings": [{
"source": "40000000-0000-4000-8000-000000000002",
"target": "40000000-0000-4000-8000-000000000003",
"target_kind": null
}],
"fields": [], "resolutions": [], "destination_diagram": null
},
"traces": {}, "references": {},
"reviews": [{
"source": "40000000-0000-4000-8000-000000000002",
"resolutions": [
{"facet":{"Header":"Name"}, "decision":"TakeSource"},
{"facet":{"Data":"Unit"}, "decision":"KeepTarget"}
],
"reset": []
}],
"candidates": [], "accepted_impacts": []
}
The example maps an existing OA data type to its traced SA counterpart, takes
only its name and acknowledges intentionally different units. Other facets stay
unreviewed or retain their earlier drift. Select an explicit trace through the
``traces`` map (source UUID to trace UUID) when multiple trace kinds connect the
pair. ``reset`` deliberately invalidates exact old selectors after changed
correspondence. The optional collections default to empty. Semantic header
decisions use typed facets; leave legacy ``transition.fields`` and
``transition.resolutions`` empty.
Facet selectors are closed enums, for example ``{"Data":"IntegerMinimum"}``
or ``{"Member":{"collection":"SignalItems","keys":{"oa":"oa-uuid","sa":"sa-uuid"}}}``.
Annotation keys contain the entire path and field; enum literal keys are exact
strings. ``KeepTarget``, ``TakeSource`` and ``Candidate`` operate on independently
selected facts. Candidate records use existing Element/Relation/ArtifactLink
types, must preserve identity/alias, and must equal precisely the result of
the selected facets. Constructor review covers its coupled introduced/removed
fields. Membership review does not silently acknowledge order; target-only
members remain independent. OpenAPI defines every selector and typed payload.
Preview requires ``If-Match`` and returns ``{"base_revision":1,"plan":{...}}``.
The plan contains the original typed request, mapping plan, review facts,
acknowledged/retired selectors, linked impacts, exact prepared impacts, conflicts,
commands, and base/prepared fingerprints. A conflict-free preview also prepares
the complete authored source bundle and checks its model equivalence and capacity.
No preview changes the model, source, revision or stored bytes.
If ordinary preparation induces additional cleanup, the plan reports exact typed
before/after impacts and a conflict. Review those impacts, place the accepted
records in ``accepted_impacts``, and request another preview. Acceptance cannot
override a contradictory selected facet. Missing mappings, ambiguous order,
duplicate decisions, invalid candidates or unresolved impacts cannot be applied.
This is the shared core planner and command preparation, with no server-specific
semantic mutation engine.
Apply accepts the entire preview envelope under the same ``If-Match``. It checks
``base_revision``, the model fingerprint, unresolved conflicts, the prepared
candidate fingerprint, ordinary model validation and coupled source preparation,
then atomically stores one accepted model/source revision. The final store check
also catches a concurrent edit during preparation. Source-only edits invalidate
the preview through the shared ETag even when the model fingerprint is unchanged.
Stale ETag/revision returns 412; semantic rejection returns 422 with code
``semantic_transition_rejected``. Exact no-op candidates preserve revision.
Read and preview again after a successful changed apply before reviewing further.
A supplied plan remains a client-owned candidate. Fingerprints detect stale or
inconsistent proposals; they are not tamper-proof signatures, authentication
receipts or proof of original review authorship. Apply preserves preview-generated
identities and does not rerun the planner with fresh random IDs. Authenticated
clients already have ordinary model/source editing privileges. The server does
not persist plans, tokens or review sessions.
Requests allow 100 sources, trace choices and reviews; 200 mappings, candidates
and accepted impacts; 5,000 canonical reference mappings; and 5,000 facet
resolutions plus resets across all reviews. The legacy 400 prose-resolution
limit and 200-command transaction limit still apply. Query has the same 5,000
reference-map limit. Core bounded linked traversal errors remain observable as
semantic rejection; no truncated result silently claims complete review.
Schema/source 5 adds ``TraceBaseline.semantic``. Absence or null is Unreviewed;
migrating older prose baselines does not fabricate semantic history. The persisted
baseline contains only closed typed ``FacetReview`` facts, not whole candidate
records or a generic JSON value tree. Historical UUIDs remain historical, including
after deletion, and source uses explicit ``uuid`` tokens for them.
Error contract
--------------
+5129 -7
View File
File diff suppressed because it is too large Load Diff