feat(server): expose typed semantic transition review
This commit is contained in:
@@ -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(¤t, 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(¤t.model, trace, &request.references)
|
||||
.map_err(semantic_rejected)?;
|
||||
let drift = oa_core::semantic::trace_semantic_drift(¤t.model, trace, &request.references)
|
||||
.map_err(semantic_rejected)?;
|
||||
let linked_impacts = oa_core::semantic::linked_semantic_impacts(¤t.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(¤t, 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(¤t.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(¤t.model, &plan)
|
||||
.map_err(semantic_rejected)?;
|
||||
PreparedProject::from_command(¤t, candidate)?;
|
||||
}
|
||||
Ok(response(
|
||||
¤t,
|
||||
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(¤t.model, &request.plan)
|
||||
.map_err(semantic_rejected)?;
|
||||
let prepared = PreparedProject::from_command(¤t, 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,
|
||||
|
||||
@@ -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, ¤t.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 = ¤t.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(¤t);
|
||||
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(¤t);
|
||||
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)
|
||||
@@ -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
@@ -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">{
|
||||
"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"
|
||||
}
|
||||
}</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">{"format":"arcadia", "source":"complete source document"}</pre>
|
||||
<p>or:</p>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}</pre>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 5; project rescue \"Rescue\" {}"}}}</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">{"traces":["trace-uuid"],"references":{},"revision":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">{
|
||||
"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": []
|
||||
}</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">{"Data":"IntegerMinimum"}</span></span>
|
||||
or <span class="docutils literal"><span class="pre">{"Member":{"collection":"SignalItems","keys":{"oa":"oa-uuid","sa":"sa-uuid"}}}</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">{"base_revision":1,"plan":{...}}</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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user