Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
This commit is contained in:
@@ -3,6 +3,9 @@ pub mod error;
|
||||
mod routes;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(test)]
|
||||
mod preparation_http_tests;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{DefaultBodyLimit, Request, State},
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
use crate::{
|
||||
app,
|
||||
store::{Store, StoredProject},
|
||||
};
|
||||
use oa_core::{Id, Project};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::{sync::oneshot, time::timeout};
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_source_http_rechecks_revision_without_holding_catalog() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().to_owned();
|
||||
let store = Arc::new(
|
||||
tokio::task::spawn_blocking(move || Store::open(path))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}/api/v1", listener.local_addr().unwrap());
|
||||
let (stop, done) = oneshot::channel();
|
||||
let router = app(store.clone(), None);
|
||||
let task = tokio::spawn(async move {
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = done.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let client = Client::new();
|
||||
let created = client
|
||||
.post(format!("{base}/projects"))
|
||||
.json(&json!({"name":"Initial review"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let etag = created.headers()["etag"].to_str().unwrap().to_owned();
|
||||
let summary: Value = created.json().await.unwrap();
|
||||
let id: Id = summary["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
let project_url = format!("{base}/projects/{id}");
|
||||
let initial: StoredProject = client
|
||||
.get(&project_url)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let (entered, release) = store.pause_next_replacement(id, etag.clone());
|
||||
// Real parsing, legacy admission and source preparation all run. Runtime
|
||||
// cost is irrelevant: the gate proves the exact pre-commit phase instead.
|
||||
let source = "arcadia 2; project prepared \"Prepared stale head\" { oa_activity root \"Root\" {} oa_activity child \"Child\" { parent @root; } }";
|
||||
let proposed_client = client.clone();
|
||||
let proposed_url = format!("{project_url}/source");
|
||||
let proposed_etag = etag.clone();
|
||||
let proposed = tokio::spawn(async move {
|
||||
proposed_client
|
||||
.put(proposed_url)
|
||||
.header("if-match", proposed_etag)
|
||||
.header("content-type", "text/plain")
|
||||
.body(source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
let evidence = timeout(Duration::from_secs(10), entered)
|
||||
.await
|
||||
.expect("source did not reach validated pre-commit phase")
|
||||
.unwrap();
|
||||
assert_eq!(evidence.model.name, "Prepared stale head");
|
||||
assert_eq!(evidence.model.elements.len(), 2);
|
||||
let compiled =
|
||||
oa_core::source_project::compile_bundle(&evidence.bundle, crate::store::SOURCE_LIMITS)
|
||||
.unwrap();
|
||||
assert_eq!(compiled.decoded.project, evidence.model);
|
||||
assert!(
|
||||
!proposed.is_finished(),
|
||||
"prepared request must still overlap the winner"
|
||||
);
|
||||
let catalog = timeout(
|
||||
Duration::from_secs(2),
|
||||
client.get(format!("{base}/projects")).send(),
|
||||
)
|
||||
.await
|
||||
.expect("catalog locked during admitted candidate lifetime")
|
||||
.unwrap();
|
||||
assert_eq!(catalog.status(), StatusCode::OK);
|
||||
let winner = timeout(
|
||||
Duration::from_secs(2),
|
||||
client
|
||||
.patch(&project_url)
|
||||
.header("if-match", &etag)
|
||||
.json(&json!({"name":"Concurrent accepted review"}))
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.expect("prepared worker held the commit lock")
|
||||
.unwrap();
|
||||
assert_eq!(winner.status(), StatusCode::OK);
|
||||
let winner_etag = winner.headers()["etag"].to_str().unwrap().to_owned();
|
||||
assert!(
|
||||
!proposed.is_finished(),
|
||||
"stale candidate must await explicit release"
|
||||
);
|
||||
release.release();
|
||||
let loser = timeout(Duration::from_secs(10), proposed)
|
||||
.await
|
||||
.expect("released candidate did not finish")
|
||||
.unwrap();
|
||||
assert_eq!(loser.status(), StatusCode::PRECONDITION_FAILED);
|
||||
let failure: Value = loser.json().await.unwrap();
|
||||
assert_eq!(failure["code"], "revision_conflict");
|
||||
assert_eq!(failure["current_revision"], 2);
|
||||
let committed_response = client.get(&project_url).send().await.unwrap();
|
||||
assert_eq!(
|
||||
committed_response.headers()["etag"].to_str().unwrap(),
|
||||
winner_etag
|
||||
);
|
||||
let committed: StoredProject = committed_response
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut expected_model: Project = initial.model;
|
||||
expected_model.name = "Concurrent accepted review".into();
|
||||
assert_eq!(committed.model, expected_model);
|
||||
assert_eq!(committed.revision, 2);
|
||||
assert!(committed.model.elements.is_empty());
|
||||
let installed: StoredProject = serde_json::from_slice(
|
||||
&std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(installed.model, committed.model);
|
||||
assert_eq!(installed.source_project, committed.source_project);
|
||||
assert_eq!(installed.revision, 2);
|
||||
assert_eq!(installed.resource_id, committed.resource_id);
|
||||
assert_eq!(installed.migration, committed.migration);
|
||||
let _ = stop.send(());
|
||||
drop(client);
|
||||
timeout(Duration::from_secs(5), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
@@ -1069,10 +1069,11 @@ fn check_transition_request(request: &oa_core::transitions::TransitionRequest) -
|
||||
if request.sources.len() > 100
|
||||
|| request.mappings.len() > 200
|
||||
|| request.communication_endpoints.len() > 200
|
||||
|| request.operation_omissions.len() > 200
|
||||
|| request.resolutions.len() > 400
|
||||
{
|
||||
return Err(ApiError::bad_request(
|
||||
"Transition requests allow at most 100 sources, 200 mappings or communication endpoint decisions and 400 field resolutions.",
|
||||
"Transition requests allow at most 100 sources, 200 mappings, communication endpoint decisions or operation omissions and 400 field resolutions.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -1195,6 +1196,7 @@ fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest
|
||||
if transition.sources.len() > 100
|
||||
|| transition.mappings.len() > 200
|
||||
|| transition.communication_endpoints.len() > 200
|
||||
|| transition.operation_omissions.len() > 200
|
||||
|| transition.resolutions.len() > 400
|
||||
|| request.traces.len() > 100
|
||||
|| request.references.len() > 5_000
|
||||
@@ -1204,7 +1206,7 @@ fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest
|
||||
|| request.accepted_impacts.len() > 200
|
||||
{
|
||||
return Err(ApiError::bad_request(
|
||||
"Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/communication endpoint decisions/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.",
|
||||
"Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/communication endpoint decisions/operation omissions/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -13,6 +13,10 @@ use std::{
|
||||
sync::{Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "store_preparation_gate.rs"]
|
||||
pub(crate) mod preparation_gate;
|
||||
|
||||
pub const MAX_MODEL_BYTES: usize = 8 * 1024 * 1024;
|
||||
pub const MAX_SOURCE_PROJECT_BYTES: usize = 8 * 1024 * 1024;
|
||||
pub const MAX_OBJECTS: usize = 5_000;
|
||||
@@ -89,6 +93,8 @@ pub enum StoreError {
|
||||
|
||||
pub struct Store {
|
||||
inner: Mutex<StoreInner>,
|
||||
#[cfg(test)]
|
||||
preparation_gate: Mutex<Option<preparation_gate::Armed>>,
|
||||
// Keep the OS lock alive for the store's entire lifetime, including requests.
|
||||
_directory_lock: File,
|
||||
}
|
||||
@@ -242,6 +248,8 @@ impl Store {
|
||||
) -> ApiResult<(StoredProject, bool)> {
|
||||
let current = self.checked(id, expected)?;
|
||||
let prepared = PreparedProject::replacement(¤t, decoded)?;
|
||||
#[cfg(test)]
|
||||
self.wait_after_preparation(id, expected, &prepared)?;
|
||||
self.lock()?.replace(id, expected, prepared)
|
||||
}
|
||||
|
||||
@@ -381,6 +389,8 @@ impl Store {
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
#[cfg(test)]
|
||||
preparation_gate: Mutex::new(None),
|
||||
inner: Mutex::new(StoreInner {
|
||||
directory,
|
||||
projects,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Private synchronization for real-HTTP tests; absent from production builds.
|
||||
use super::{ApiError, ApiResult, Id, PreparedProject, Project, SourceBundle, Store};
|
||||
use std::{sync::mpsc, time::Duration};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
pub(crate) struct PreparedEvidence {
|
||||
pub model: Project,
|
||||
pub bundle: SourceBundle,
|
||||
}
|
||||
|
||||
pub(crate) struct Armed {
|
||||
id: Id,
|
||||
expected: String,
|
||||
entered: oneshot::Sender<PreparedEvidence>,
|
||||
resume: mpsc::Receiver<bool>,
|
||||
}
|
||||
|
||||
/// Dropping an unfinished test cancels the worker instead of stranding it.
|
||||
pub(crate) struct ReleaseOnDrop(Option<mpsc::Sender<bool>>);
|
||||
|
||||
impl ReleaseOnDrop {
|
||||
pub(crate) fn release(mut self) {
|
||||
self.0.take().unwrap().send(true).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReleaseOnDrop {
|
||||
fn drop(&mut self) {
|
||||
if let Some(sender) = self.0.take() {
|
||||
let _ = sender.send(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub(crate) fn pause_next_replacement(
|
||||
&self,
|
||||
id: Id,
|
||||
expected: String,
|
||||
) -> (oneshot::Receiver<PreparedEvidence>, ReleaseOnDrop) {
|
||||
let (entered, receiver) = oneshot::channel();
|
||||
let (resume, wait) = mpsc::channel();
|
||||
let mut slot = self.preparation_gate.lock().unwrap();
|
||||
assert!(slot.is_none(), "one gate per store instance");
|
||||
*slot = Some(Armed {
|
||||
id,
|
||||
expected,
|
||||
entered,
|
||||
resume: wait,
|
||||
});
|
||||
(receiver, ReleaseOnDrop(Some(resume)))
|
||||
}
|
||||
|
||||
pub(super) fn wait_after_preparation(
|
||||
&self,
|
||||
id: Id,
|
||||
expected: &str,
|
||||
prepared: &PreparedProject,
|
||||
) -> ApiResult<()> {
|
||||
let armed = {
|
||||
let mut slot = self.preparation_gate.lock().unwrap();
|
||||
if slot
|
||||
.as_ref()
|
||||
.is_some_and(|gate| gate.id == id && gate.expected == expected)
|
||||
{
|
||||
slot.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let Some(armed) = armed else {
|
||||
return Ok(());
|
||||
};
|
||||
// Neither the catalog nor the gate mutex is held while the real model
|
||||
// worker waits. Evidence comes from the already validated candidate.
|
||||
armed
|
||||
.entered
|
||||
.send(PreparedEvidence {
|
||||
model: prepared.model().clone(),
|
||||
bundle: prepared.bundle().clone(),
|
||||
})
|
||||
.map_err(|_| ApiError::internal("Preparation test observer disappeared."))?;
|
||||
match armed.resume.recv_timeout(Duration::from_secs(15)) {
|
||||
Ok(true) => Ok(()),
|
||||
_ => Err(ApiError::internal(
|
||||
"Preparation test cancelled or timed out.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preparation_gate_drop_cancels_without_installing_candidate() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().to_owned();
|
||||
let (store, initial) = tokio::task::spawn_blocking(move || {
|
||||
let store = std::sync::Arc::new(Store::open(path).unwrap());
|
||||
let initial = store.create(Project::empty()).unwrap();
|
||||
(store, initial)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let original_bytes = std::fs::read(
|
||||
directory
|
||||
.path()
|
||||
.join(format!("{}.json", initial.resource_id)),
|
||||
)
|
||||
.unwrap();
|
||||
let (entered, release) = store.pause_next_replacement(initial.resource_id, initial.etag());
|
||||
let mut model = initial.model.clone();
|
||||
model.name = "Must not be installed".into();
|
||||
let worker_store = store.clone();
|
||||
let id = initial.resource_id;
|
||||
let etag = initial.etag();
|
||||
let worker = tokio::task::spawn_blocking(move || {
|
||||
worker_store.replace_decoded(
|
||||
id,
|
||||
&etag,
|
||||
oa_core::persistence::DecodeResult {
|
||||
project: model,
|
||||
migration: None,
|
||||
},
|
||||
)
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), entered)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
drop(release);
|
||||
let error = tokio::time::timeout(Duration::from_secs(2), worker)
|
||||
.await
|
||||
.expect("dropping the test guard must release its worker")
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert_eq!(error.detail, "Preparation test cancelled or timed out.");
|
||||
assert_eq!(store.get(id).unwrap().model, initial.model);
|
||||
assert_eq!(
|
||||
std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
|
||||
original_bytes
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ mod port_anchors;
|
||||
mod semantic_transitions;
|
||||
#[path = "http/source6_views.rs"]
|
||||
mod source6_views;
|
||||
#[path = "http/source7_contracts.rs"]
|
||||
mod source7_contracts;
|
||||
#[path = "http/source_projects.rs"]
|
||||
mod source_projects;
|
||||
|
||||
@@ -567,89 +569,6 @@ async fn oversized_source_and_json_are_rejected_without_stalling_the_catalog() {
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepted_slow_source_does_not_lock_catalog_and_loses_to_a_newer_http_revision() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (id, etag) = server.create().await;
|
||||
let mut source = String::from(
|
||||
"arcadia 2; project prepared \"Slow proposed head\" { oa_activity a0 \"Root\" {}\n",
|
||||
);
|
||||
// This is within all admission budgets. A nested engineering graph forces
|
||||
// real validation work after the source route's initial revision snapshot.
|
||||
// Keep this concurrency fixture below the service's 30-second deadline:
|
||||
// 2,000 nested activities returned a legitimate 504 on the two-core runner.
|
||||
// The overlap assertion below prevents shrinking it into a sequential test.
|
||||
for i in 1..1_000 {
|
||||
source.push_str(&format!(
|
||||
"oa_activity a{i} \"Activity {i}\" {{ parent @a{}; }}\n",
|
||||
i - 1
|
||||
));
|
||||
}
|
||||
source.push('}');
|
||||
let client = server.client.clone();
|
||||
let url = server.url(&format!("/projects/{id}/source"));
|
||||
let stale_etag = etag.clone();
|
||||
let proposed = tokio::spawn(async move {
|
||||
client
|
||||
.put(url)
|
||||
.header("if-match", stale_etag)
|
||||
.header("content-type", "text/plain")
|
||||
.body(source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(
|
||||
!proposed.is_finished(),
|
||||
"fixture must overlap actual candidate preparation"
|
||||
);
|
||||
let catalog = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
server.client.get(server.url("/projects")).send(),
|
||||
)
|
||||
.await
|
||||
.expect("catalog locked during valid candidate preparation")
|
||||
.unwrap();
|
||||
assert_eq!(catalog.status(), StatusCode::OK);
|
||||
let winner = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
server
|
||||
.client
|
||||
.patch(server.url(&format!("/projects/{id}")))
|
||||
.header("if-match", &etag)
|
||||
.json(&json!({"name":"Concurrent accepted review"}))
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.expect("unrelated preparation held the commit lock")
|
||||
.unwrap();
|
||||
assert_eq!(winner.status(), StatusCode::OK);
|
||||
// The two-second read/PATCH bounds above test responsiveness and lock
|
||||
// ownership. Finishing the deliberately expensive admitted candidate is
|
||||
// a separate watchdog, not a latency promise under parallel CPU contention.
|
||||
let loser = tokio::time::timeout(std::time::Duration::from_secs(45), proposed)
|
||||
.await
|
||||
.expect("admitted candidate did not complete within the test watchdog")
|
||||
.unwrap();
|
||||
assert_eq!(loser.status(), StatusCode::PRECONDITION_FAILED);
|
||||
let failure: Value = loser.json().await.unwrap();
|
||||
assert_eq!(failure["code"], "revision_conflict");
|
||||
assert_eq!(failure["current_revision"], 2);
|
||||
let committed = server.get(id).await;
|
||||
assert_eq!(committed.revision, 2);
|
||||
assert_eq!(committed.model.name, "Concurrent accepted review");
|
||||
assert!(committed.model.elements.is_empty());
|
||||
let installed: StoredProject = serde_json::from_slice(
|
||||
&std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(installed.model, committed.model);
|
||||
assert_eq!(installed.revision, 2);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mission_and_contextual_chain_records_round_trip_and_edit_through_http() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
@@ -2019,7 +1938,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 6;"));
|
||||
assert!(source.starts_with("arcadia 7;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
|
||||
let no_op = server
|
||||
.client
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! HTTP admission limits must reject endpoint decisions before transition planning.
|
||||
//! HTTP admission limits reject bounded explicit decisions before planning.
|
||||
use super::*;
|
||||
use oa_core::{semantic::SemanticTransitionRequest, transitions::*};
|
||||
|
||||
@@ -28,7 +28,7 @@ async fn unchanged(server: &TestServer, before: &StoredProject, disk: &Path, byt
|
||||
assert_eq!(std::fs::read(disk).unwrap(), bytes);
|
||||
}
|
||||
|
||||
async fn endpoint_limit(semantic: bool) {
|
||||
async fn decision_limit(semantic: bool, operations: bool) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let mut model = Project::empty();
|
||||
@@ -40,19 +40,24 @@ async fn endpoint_limit(semantic: bool) {
|
||||
let disk = directory.path().join(format!("{id}.json"));
|
||||
let bytes = std::fs::read(&disk).unwrap();
|
||||
let transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
|
||||
let (route, mut request, decision_path, applied_decision_path) = if semantic {
|
||||
let decision_name = if operations {
|
||||
"operation_omissions"
|
||||
} else {
|
||||
"communication_endpoints"
|
||||
};
|
||||
let (route, mut request, decision_parent, applied_decision_parent) = if semantic {
|
||||
(
|
||||
"semantic-transitions",
|
||||
serde_json::to_value(SemanticTransitionRequest::new(transition)).unwrap(),
|
||||
"/transition/communication_endpoints",
|
||||
"/plan/request/transition/communication_endpoints",
|
||||
"/transition",
|
||||
"/plan/request/transition",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"transitions",
|
||||
serde_json::to_value(transition).unwrap(),
|
||||
"/communication_endpoints",
|
||||
"/plan/request/communication_endpoints",
|
||||
"",
|
||||
"/plan/request",
|
||||
)
|
||||
};
|
||||
// Obtain a real prepared envelope first: apply must enforce the same bound,
|
||||
@@ -67,12 +72,24 @@ async fn endpoint_limit(semantic: bool) {
|
||||
// Syntactically typed but semantically inapplicable decisions deliberately
|
||||
// distinguish capacity admission (400) from core semantic rejection (422 or
|
||||
// a preview containing conflicts). The bound counts entries, not unique IDs.
|
||||
let decision = CommunicationEndpointDecision {
|
||||
source,
|
||||
source_port: CommunicationPortChoice::Reuse { port: source },
|
||||
target_port: CommunicationPortChoice::Reuse { port: source },
|
||||
let decision = if operations {
|
||||
json!(OperationOmission {
|
||||
source,
|
||||
rationale: "Explicitly abstract technical operation".into()
|
||||
})
|
||||
} else {
|
||||
json!(CommunicationEndpointDecision {
|
||||
source,
|
||||
source_port: CommunicationPortChoice::Reuse { port: source },
|
||||
target_port: CommunicationPortChoice::Reuse { port: source },
|
||||
})
|
||||
};
|
||||
*request.pointer_mut(decision_path).unwrap() = json!(vec![decision.clone(); 200]);
|
||||
request
|
||||
.pointer_mut(decision_parent)
|
||||
.unwrap()
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert(decision_name.into(), json!(vec![decision.clone(); 200]));
|
||||
let response = post(&server, id, &format!("{route}/preview"), &etag, &request).await;
|
||||
let status = response.status();
|
||||
let body: Value = response.json().await.unwrap();
|
||||
@@ -83,8 +100,17 @@ async fn endpoint_limit(semantic: bool) {
|
||||
unchanged(&server, &before, &disk, &bytes).await;
|
||||
|
||||
let over_limit = json!(vec![decision; 201]);
|
||||
*request.pointer_mut(decision_path).unwrap() = over_limit.clone();
|
||||
*plan.pointer_mut(applied_decision_path).unwrap() = over_limit;
|
||||
request
|
||||
.pointer_mut(decision_parent)
|
||||
.unwrap()
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert(decision_name.into(), over_limit.clone());
|
||||
plan.pointer_mut(applied_decision_parent)
|
||||
.unwrap()
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert(decision_name.into(), over_limit);
|
||||
for (operation, body) in [("preview", request), ("apply", plan)] {
|
||||
let response = post(&server, id, &format!("{route}/{operation}"), &etag, &body).await;
|
||||
let status = response.status();
|
||||
@@ -97,8 +123,9 @@ async fn endpoint_limit(semantic: bool) {
|
||||
assert_eq!(error["code"], "invalid_request", "{error}");
|
||||
let detail = error["detail"].as_str().unwrap().to_lowercase();
|
||||
assert!(
|
||||
detail.contains("200") && detail.contains("endpoint"),
|
||||
"Capacity rejection must identify endpoint decisions: {error}"
|
||||
detail.contains("200")
|
||||
&& detail.contains(if operations { "omission" } else { "endpoint" }),
|
||||
"Capacity rejection must identify the bounded decisions: {error}"
|
||||
);
|
||||
unchanged(&server, &before, &disk, &bytes).await;
|
||||
}
|
||||
@@ -110,10 +137,20 @@ async fn endpoint_limit(semantic: bool) {
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_transition_endpoint_decision_limit_rejects_201_without_mutation() {
|
||||
endpoint_limit(false).await;
|
||||
decision_limit(false, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_transition_endpoint_decision_limit_rejects_201_without_mutation() {
|
||||
endpoint_limit(true).await;
|
||||
decision_limit(true, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_transition_operation_omission_limit_rejects_201_without_mutation() {
|
||||
decision_limit(false, true).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_transition_operation_omission_limit_rejects_201_without_mutation() {
|
||||
decision_limit(true, true).await;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ async fn published_data_contract_queries_atomic_edits_identity_and_restart() {
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 6;"));
|
||||
assert!(source.starts_with("arcadia 7;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), removed.model);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
@@ -244,7 +244,7 @@ async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_fil
|
||||
1
|
||||
);
|
||||
assert_eq!(stored.source_project.files.len(), 2);
|
||||
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 6;"));
|
||||
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 7;"));
|
||||
for text in [
|
||||
"// Event rationale",
|
||||
"// Interface rationale",
|
||||
@@ -288,7 +288,7 @@ async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_sav
|
||||
assert_eq!(opened.revision, 17);
|
||||
assert_eq!(opened.model.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(opened.migration.as_ref().unwrap().from_schema, 3);
|
||||
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 6;"));
|
||||
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 7;"));
|
||||
assert_pair(&opened);
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), original);
|
||||
assert_eq!(
|
||||
@@ -475,6 +475,7 @@ async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_s
|
||||
receiver: Some(to),
|
||||
message_kind,
|
||||
reply_to,
|
||||
operation: None,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
@@ -720,7 +721,7 @@ async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_s
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 6;"));
|
||||
assert!(source.starts_with("arcadia 7;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model);
|
||||
let saved = std::fs::read(&filename).unwrap();
|
||||
server.stop().await;
|
||||
|
||||
@@ -576,6 +576,7 @@ fn cleanup_fixture() -> (Project, Id, Id, Id) {
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
operation: None,
|
||||
values: vec![MessageValue {
|
||||
path: vec![field],
|
||||
field: leaf,
|
||||
|
||||
@@ -113,7 +113,7 @@ fn assert_pair(stored: &StoredProject) {
|
||||
let compiled = compile_bundle(&stored.source_project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle, stored.source_project);
|
||||
assert_eq!(compiled.decoded.project, stored.model);
|
||||
assert_eq!(stored.model.schema_version, 6);
|
||||
assert_eq!(stored.model.schema_version, 7);
|
||||
}
|
||||
|
||||
async fn transact(
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Real HTTP checks for caller-instance identity and operation allocation identity.
|
||||
use super::*;
|
||||
use oa_core::{
|
||||
ContextualLinkPurpose, Element, OccurrenceBody, OccurrenceEndpoint, OperationUse, Relation,
|
||||
RelationBody, SharedBody, language, persistence,
|
||||
source_project::{SourceBundle, compile_bundle},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn operation_id(n: u128) -> Id {
|
||||
Id::from_u128(0x52060906000040008000000000000000 + n)
|
||||
}
|
||||
|
||||
fn occurrence(model: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let mut element = model
|
||||
.make_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
|
||||
.unwrap();
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
let id = element.id;
|
||||
model.elements.insert(id, element);
|
||||
model
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn select_operation(model: &Project, allocation: Id) -> Element {
|
||||
let mut element = model.elements[&operation_id(43)].clone();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = element.shared_mut()
|
||||
else {
|
||||
panic!("fixture Call");
|
||||
};
|
||||
*operation = Some(OperationUse {
|
||||
allocation: Some(allocation),
|
||||
});
|
||||
element
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
model: Project,
|
||||
bundle: SourceBundle,
|
||||
definition: Id,
|
||||
r1: Id,
|
||||
r2: Id,
|
||||
link: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
// Frozen source6 absence probe contains two parameterless operations on one
|
||||
// concrete boundary. Its old bytes remain compatibility inputs.
|
||||
let mut model = persistence::decode(include_bytes!(
|
||||
"../../../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
|
||||
))
|
||||
.unwrap();
|
||||
let selected = select_operation(&model, operation_id(30));
|
||||
model.elements.insert(selected.id, selected);
|
||||
let caller = model
|
||||
.add_element(Kind::Process, Layer::Oa, "Caller", None)
|
||||
.unwrap();
|
||||
let definition = model
|
||||
.add_element(Kind::Process, Layer::Oa, "Reusable assessment", None)
|
||||
.unwrap();
|
||||
let work = model
|
||||
.add_element(Kind::Activity, Layer::Oa, "Assess", None)
|
||||
.unwrap();
|
||||
let leaf = occurrence(
|
||||
&mut model,
|
||||
definition,
|
||||
"Assessment contribution",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let x = occurrence(
|
||||
&mut model,
|
||||
caller,
|
||||
"Caller check",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let r1 = occurrence(
|
||||
&mut model,
|
||||
caller,
|
||||
"First assessment",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(definition),
|
||||
},
|
||||
);
|
||||
let r2 = occurrence(
|
||||
&mut model,
|
||||
caller,
|
||||
"Second assessment",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(definition),
|
||||
},
|
||||
);
|
||||
let link = Id::new_v4();
|
||||
model.relations.insert(
|
||||
link,
|
||||
Relation {
|
||||
id: link,
|
||||
alias: "qualified_assessment".into(),
|
||||
name: "Check the first assessment".into(),
|
||||
description: "Caller intent survives source and REST editing.".into(),
|
||||
body: RelationBody::ContextualLink {
|
||||
owner: caller,
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: x,
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![r1],
|
||||
occurrence: leaf,
|
||||
},
|
||||
purpose: ContextualLinkPurpose::Precedence {
|
||||
guard: "check accepted".into(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
let source =
|
||||
language::format(&model).replacen("{\n", "{\n include \"rationale.arcadia\";\n", 1);
|
||||
let bundle = SourceBundle {
|
||||
entry: "assembly.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("assembly.arcadia".into(), source),
|
||||
(
|
||||
"rationale.arcadia".into(),
|
||||
"// Authored rationale: retain 雨 and CRLF.\r\n".into(),
|
||||
),
|
||||
]),
|
||||
};
|
||||
assert_eq!(
|
||||
compile_bundle(&bundle, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
model
|
||||
);
|
||||
Fixture {
|
||||
model,
|
||||
bundle,
|
||||
definition,
|
||||
r1,
|
||||
r2,
|
||||
link,
|
||||
}
|
||||
}
|
||||
|
||||
async fn transact(
|
||||
server: &TestServer,
|
||||
stored: &StoredProject,
|
||||
commands: Vec<Command>,
|
||||
) -> reqwest::Response {
|
||||
server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{}/transactions", stored.resource_id)))
|
||||
.header("if-match", stored.etag())
|
||||
.json(&json!({"commands": commands}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn unchanged(server: &TestServer, before: &StoredProject, disk: &Path, bytes: &[u8]) {
|
||||
let after = server.get(before.resource_id).await;
|
||||
assert_eq!(after.model, before.model);
|
||||
assert_eq!(after.source_project, before.source_project);
|
||||
assert_eq!(after.revision, before.revision);
|
||||
assert_eq!(after.etag(), before.etag());
|
||||
assert_eq!(std::fs::read(disk).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn source7_http_instance_and_operation_edits_preserve_identity_source_and_restart() {
|
||||
let f = fixture();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let response = server
|
||||
.client
|
||||
.post(server.url("/projects/import-source-project"))
|
||||
.json(&f.bundle)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body: Value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED, "{body}");
|
||||
let id: Id = body["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
let before = server.get(id).await;
|
||||
assert_eq!(before.model, f.model);
|
||||
assert_eq!(before.source_project, f.bundle);
|
||||
let queried: Value = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{id}/relations?kind=ContextualLink")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(queried["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(queried["items"][0]["id"], f.link.to_string());
|
||||
|
||||
// Only two explicit records change: caller-instance address and allocation.
|
||||
// The shared procedure, both reference instances and operation items persist.
|
||||
let mut relation = before.model.relations[&f.link].clone();
|
||||
if let RelationBody::ContextualLink { target, .. } = &mut relation.body {
|
||||
target.references = vec![f.r2];
|
||||
}
|
||||
let selected = select_operation(&before.model, operation_id(31));
|
||||
let response = transact(
|
||||
&server,
|
||||
&before,
|
||||
vec![
|
||||
Command::EditRelation(f.link, relation.clone()),
|
||||
Command::EditElement(selected.id, selected.clone()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let status = response.status();
|
||||
let body: Value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let after = server.get(id).await;
|
||||
let mut expected = before.model.clone();
|
||||
expected.relations.insert(f.link, relation);
|
||||
expected.elements.insert(selected.id, selected);
|
||||
assert_eq!(after.model, expected);
|
||||
assert_eq!(after.revision, before.revision + 1);
|
||||
assert_eq!(
|
||||
after.source_project.files["rationale.arcadia"],
|
||||
f.bundle.files["rationale.arcadia"]
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&after.source_project, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
after.model.elements[&f.definition],
|
||||
before.model.elements[&f.definition]
|
||||
);
|
||||
assert_eq!(after.model.elements[&f.r1], before.model.elements[&f.r1]);
|
||||
|
||||
let disk = directory.path().join(format!("{id}.json"));
|
||||
let bytes = std::fs::read(&disk).unwrap();
|
||||
let mut bad_link = after.model.relations[&f.link].clone();
|
||||
if let RelationBody::ContextualLink { target, .. } = &mut bad_link.body {
|
||||
target.references = vec![f.definition]; // Definition is not an instance.
|
||||
}
|
||||
let wrong_operation = select_operation(&after.model, operation_id(20)); // Item is not an allocation.
|
||||
for commands in [
|
||||
vec![Command::EditRelation(f.link, bad_link)],
|
||||
vec![Command::EditElement(wrong_operation.id, wrong_operation)],
|
||||
] {
|
||||
let response = transact(&server, &after, commands).await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
unchanged(&server, &after, &disk, &bytes).await;
|
||||
}
|
||||
let mut old_json = serde_json::to_value(&after.model).unwrap();
|
||||
old_json["schema_version"] = json!(6);
|
||||
// Isolate admission gates: one new feature must not mask another.
|
||||
let mut context_only = old_json.clone();
|
||||
for element in context_only["elements"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.values_mut()
|
||||
{
|
||||
if let Some(body) = element.pointer_mut("/body/Sa/Shared/Occurrence/body/Exchange") {
|
||||
body.as_object_mut().unwrap().remove("operation");
|
||||
}
|
||||
}
|
||||
let mut operation_null_only: Value = serde_json::from_slice(include_bytes!(
|
||||
"../../../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
|
||||
))
|
||||
.unwrap();
|
||||
operation_null_only["elements"][operation_id(43).to_string()]["body"]["Sa"]["Shared"]["Occurrence"]
|
||||
["body"]["Exchange"]["operation"] = Value::Null;
|
||||
for candidate in [old_json, context_only, operation_null_only] {
|
||||
let response = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{id}/model")))
|
||||
.header("if-match", after.etag())
|
||||
.json(&candidate)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
unchanged(&server, &after, &disk, &bytes).await;
|
||||
}
|
||||
let mut old_source = after.source_project.clone();
|
||||
let entry = old_source.files.get_mut(&old_source.entry).unwrap();
|
||||
*entry = entry.replacen("arcadia 7;", "arcadia 6;", 1);
|
||||
let response = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{id}/source-project")))
|
||||
.header("if-match", after.etag())
|
||||
.json(&old_source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
unchanged(&server, &after, &disk, &bytes).await;
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
unchanged(&server, &after, &disk, &bytes).await;
|
||||
server.stop().await;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Refresh the closed semantic OpenAPI catalog from the frozen Rust leaf table.
|
||||
|
||||
Run from any directory with Python 3: semantic_schema.py [--check] [--validate].
|
||||
--validate additionally runs source-6 structural fixtures with jsonschema.
|
||||
--validate additionally runs source-7 structural fixtures with jsonschema.
|
||||
This generates documentation only, never runtime selectors or model mutations.
|
||||
"""
|
||||
import copy
|
||||
@@ -99,7 +99,7 @@ for family, selector, review, body in families:
|
||||
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 == 85
|
||||
assert leaf_count == 90
|
||||
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",
|
||||
@@ -124,13 +124,13 @@ 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"] = 6
|
||||
schemas["MigrationReport"]["properties"]["from_schema"]["enum"] = [1, 2, 3, 4, 5]
|
||||
schemas["MigrationReport"]["properties"]["to_schema"]["const"] = 6
|
||||
schemas["MigrationReport"]["description"] = re.sub(r"V1(?:/V[2-5])+ to V[4-6]", "V1/V2/V3/V4/V5 to V6", schemas["MigrationReport"]["description"])
|
||||
spec["info"]["description"] = re.sub(r"schema-[45]", "schema-6", spec["info"]["description"])
|
||||
spec["paths"]["/api/v1/projects/import"]["post"]["description"] = "Schema-6 native JSON is described below. Schema-1/2/3/4/5 JSON and source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Earlier versions reject source-6 constructors and saved view fields. Prior prose and typed reviews remain intact; absent semantic review stays Unreviewed. Export emits schema 6 / arcadia 6. See docs/language.rst."
|
||||
schemas["SourceBundle"]["description"] = re.sub(r"source[45] workspace \(source[^)]+\)", "source6 workspace (source3/4/5 migrate explicitly)", schemas["SourceBundle"]["description"])
|
||||
schemas["Project"]["properties"]["schema_version"]["const"] = 7
|
||||
schemas["MigrationReport"]["properties"]["from_schema"]["enum"] = [1, 2, 3, 4, 5, 6]
|
||||
schemas["MigrationReport"]["properties"]["to_schema"]["const"] = 7
|
||||
schemas["MigrationReport"]["description"] = re.sub(r"V1(?:/V[2-6])+ to V[4-7]", "V1/V2/V3/V4/V5/V6 to V7", schemas["MigrationReport"]["description"])
|
||||
spec["info"]["description"] = re.sub(r"schema-[4-6]", "schema-7", spec["info"]["description"])
|
||||
spec["paths"]["/api/v1/projects/import"]["post"]["description"] = "Schema-7 native JSON is described below. Schema-1/2/3/4/5/6 JSON and source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Earlier versions reject later constructors, fields and semantic facets, including explicit null. Prior prose and typed reviews remain intact; absent semantic review stays Unreviewed. Export emits schema 7 / arcadia 7. See docs/language.rst."
|
||||
schemas["SourceBundle"]["description"] = re.sub(r"source[4-6] workspace \(source[^)]+\)", "source7 workspace (source3/4/5/6 migrate explicitly)", schemas["SourceBundle"]["description"])
|
||||
|
||||
# Source-6 additions are closed serde records; selectors with an empty payload
|
||||
# remain explicit selections rather than reverting to their omitted defaults.
|
||||
@@ -155,6 +155,28 @@ def upsert_variant(variants, name, value):
|
||||
|
||||
upsert_variant(schemas["RelationBody"]["oneOf"], "CommunicationMean", obj({
|
||||
"source": ref("Id"), "target": ref("Id"), "oriented": typed("bool"), "items": array(ref("Id"))}))
|
||||
|
||||
# Source-7 adds exact caller-instance addresses and explicit operation intent.
|
||||
# Structural schemas do not replace core owner/path/port/signature validation.
|
||||
schemas["OccurrenceEndpoint"] = obj({"references": array(ref("Id"), 64), "occurrence": ref("Id")})
|
||||
schemas["OccurrenceEndpoint"]["description"] = "Ordered reference-occurrence IDs in an explicit caller owner, followed by the leaf contribution ID. Empty references means a direct contribution; sequence definition IDs are not path components."
|
||||
schemas["ContextualLinkPurpose"] = {"oneOf": [
|
||||
variant("Precedence", obj({"guard": typed("String")})),
|
||||
variant("Exchange", obj({"exchange": ref("Id")})),
|
||||
variant("CommonWork", obj({"work": ref("Id")}))]}
|
||||
upsert_variant(schemas["RelationBody"]["oneOf"], "ContextualLink", obj({
|
||||
"owner": ref("Id"), "source": ref("OccurrenceEndpoint"),
|
||||
"target": ref("OccurrenceEndpoint"), "purpose": ref("ContextualLinkPurpose")}))
|
||||
schemas["OperationUse"] = obj({"allocation": optional(ref("Id"))}, [])
|
||||
schemas["OperationUse"]["description"] = "Present with absent/null allocation retains unresolved operation intent. A selected UUID identifies exactly an InterfaceItemAllocation, never an item, port, or name. Reply derives operation from its exact correlated Call."
|
||||
exchange_body = next(v["properties"]["Exchange"] for v in schemas["OccurrenceBody"]["oneOf"]
|
||||
if "Exchange" in v.get("properties", {}))
|
||||
exchange_body["properties"]["operation"] = {**optional(ref("OperationUse")), "default": None}
|
||||
exchange_body["description"] = "Absent/null operation retains ordinary channel semantics and is omitted on output. Present operation is allowed only on SA BoundaryInterface Call/Asynchronous messages. Reply stores no operation and derives its exact Call's selection. Existing message defaults remain unchanged."
|
||||
schemas["OperationOmission"] = obj({"source": ref("Id"), "rationale": typed("String")})
|
||||
schemas["TransitionRequest"]["properties"]["operation_omissions"] = {
|
||||
**array(ref("OperationOmission"), 200), "default": [],
|
||||
"description": "Explicit SA-to-OA abstraction per selected invocation with operation intent. Nonempty rationale required by core; duplicates, irrelevant sources, and other directions are refused. Replies derive the Call abstraction. Source operation identities and values are preserved."}
|
||||
upsert_variant(schemas["SharedBody"]["oneOf"][1]["oneOf"], "ExchangeCategory", obj({
|
||||
"family": ref("ExchangeFamily"), "exchanges": array(ref("Id"))}))
|
||||
schemas["CommunicationPortChoice"] = {"oneOf": [
|
||||
@@ -298,9 +320,42 @@ if "--validate" in sys.argv:
|
||||
accepts("FacetSelector", {"Category": "Unknown"}, False)
|
||||
accepts("FacetReview", {"Category": {"Family": {"oa": {"Value": "Work"}, "sa": {"Value": "Participant"}}}})
|
||||
accepts("FacetSelector", {"Member": {"collection": "CategoryExchanges", "keys": {"oa": a, "sa": b}}})
|
||||
assert schemas["Project"]["properties"]["schema_version"]["const"] == 6
|
||||
assert schemas["MigrationReport"]["properties"]["from_schema"]["enum"] == [1, 2, 3, 4, 5]
|
||||
print(f"Source-6 schema fixtures: {fixture_count} passed; all {len(schemas)} component schemas well-formed")
|
||||
endpoint = {"references": [a], "occurrence": b}
|
||||
accepts("OccurrenceEndpoint", endpoint)
|
||||
accepts("OccurrenceEndpoint", {**endpoint, "references": []})
|
||||
accepts("OccurrenceEndpoint", {**endpoint, "references": [a] * 64})
|
||||
accepts("OccurrenceEndpoint", {**endpoint, "references": [a] * 65}, False)
|
||||
accepts("OccurrenceEndpoint", {**endpoint, "typo": a}, False)
|
||||
accepts("OccurrenceEndpoint", {"occurrence": a}, False)
|
||||
for purpose in [{"Precedence": {"guard": "ready"}}, {"Exchange": {"exchange": a}},
|
||||
{"CommonWork": {"work": b}}]:
|
||||
link = {"owner": a, "source": endpoint, "target": {"references": [], "occurrence": a},
|
||||
"purpose": purpose}
|
||||
accepts("RelationBody", {"ContextualLink": link})
|
||||
accepts("RelationBody", {"ContextualLink": {**link, "extra": None}}, False)
|
||||
accepts("ContextualLinkPurpose", {"CommonWork": {"work": a, "guard": ""}}, False)
|
||||
accepts("OperationUse", {})
|
||||
accepts("OperationUse", {"allocation": None})
|
||||
accepts("OperationUse", {"allocation": a})
|
||||
accepts("OperationUse", {"allocation": a, "item": b}, False)
|
||||
accepts("OperationUse", {"allocation": "wrong"}, False)
|
||||
message = {"exchange": a, "sender": a, "receiver": b, "message_kind": "Call"}
|
||||
for operation in [None, {}, {"allocation": None}, {"allocation": a}]:
|
||||
accepts("OccurrenceBody", {"Exchange": {**message, "operation": operation}})
|
||||
accepts("OccurrenceBody", {"Exchange": {**message, "operation": a}}, False)
|
||||
omission = {"source": a, "rationale": "Operational abstraction retains the request intent."}
|
||||
accepts("OperationOmission", omission)
|
||||
accepts("OperationOmission", {"source": a}, False)
|
||||
accepts("TransitionRequest", {**request, "operation_omissions": [omission] * 200})
|
||||
accepts("TransitionRequest", {**request, "operation_omissions": [omission] * 201}, False)
|
||||
accepts("FacetSelector", {"Occurrence": "Operation"})
|
||||
for facet in ["ContextOwner", "ContextSource", "ContextTarget", "ContextPurpose"]:
|
||||
accepts("FacetSelector", {"Relation": facet})
|
||||
accepts("FacetReview", {"Occurrence": {"Operation": {
|
||||
"oa": "NotApplicable", "sa": {"Value": {"allocation": a}}}}})
|
||||
assert schemas["Project"]["properties"]["schema_version"]["const"] == 7
|
||||
assert schemas["MigrationReport"]["properties"]["from_schema"]["enum"] == [1, 2, 3, 4, 5, 6]
|
||||
print(f"Source-7 schema fixtures: {fixture_count} passed; all {len(schemas)} component schemas well-formed")
|
||||
|
||||
rendered = json.dumps(spec, indent=2, ensure_ascii=False) + "\n"
|
||||
if "--check" in sys.argv:
|
||||
|
||||
Reference in New Issue
Block a user