1201 lines
50 KiB
Rust
1201 lines
50 KiB
Rust
use super::catalog::{capture_observation, collection, is_constructor, replace_observation, sides};
|
|
use super::historical::{keyed_slots_overlap, selector_slots};
|
|
use super::mutate::*;
|
|
use super::*;
|
|
use crate::model::*;
|
|
use crate::transitions::{self, TransitionDirection, TransitionPlan, TransitionRequest};
|
|
use crate::{Command, ModelError, PreparedCommand, prepare_command};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum SemanticDecision {
|
|
KeepTarget,
|
|
TakeSource,
|
|
Candidate,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct FacetResolution {
|
|
pub facet: FacetSelector,
|
|
pub decision: SemanticDecision,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SemanticReviewRequest {
|
|
pub source: Id,
|
|
pub resolutions: Vec<FacetResolution>,
|
|
/// Exact previous selectors deliberately invalidated after correspondence changes.
|
|
#[serde(default)]
|
|
pub reset: Vec<FacetSelector>,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub enum ImpactRecord {
|
|
Element(Element),
|
|
Relation(Relation),
|
|
Artifact(ArtifactLink),
|
|
Diagram(Diagram),
|
|
Trace(Trace),
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SemanticImpact {
|
|
pub object: Id,
|
|
pub before: Option<ImpactRecord>,
|
|
pub after: Option<ImpactRecord>,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SemanticTransitionRequest {
|
|
pub transition: TransitionRequest,
|
|
/// Source object → chosen existing trace, required when the pair has several traces.
|
|
#[serde(default)]
|
|
pub traces: BTreeMap<Id, Id>,
|
|
/// Canonical OA→SA reference correspondence, including historical deleted IDs.
|
|
#[serde(default)]
|
|
pub references: BTreeMap<Id, Id>,
|
|
#[serde(default)]
|
|
pub reviews: Vec<SemanticReviewRequest>,
|
|
/// Final typed candidate records. Identity and aliases cannot change here.
|
|
#[serde(default)]
|
|
pub candidates: Vec<SemanticRecord>,
|
|
#[serde(default)]
|
|
pub accepted_impacts: Vec<SemanticImpact>,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SemanticReviewPreview {
|
|
pub source: Id,
|
|
pub target: Id,
|
|
pub facets: Vec<FacetDrift>,
|
|
pub acknowledged: Vec<FacetSelector>,
|
|
pub retired: Vec<FacetSelector>,
|
|
pub linked_impacts: Vec<LinkedSemanticImpact>,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SemanticTransitionPlan {
|
|
pub base_fingerprint: String,
|
|
pub request: SemanticTransitionRequest,
|
|
pub transition: TransitionPlan,
|
|
pub reviews: Vec<SemanticReviewPreview>,
|
|
pub impacts: Vec<SemanticImpact>,
|
|
pub conflicts: Vec<String>,
|
|
pub commands: Vec<Command>,
|
|
pub prepared_fingerprint: Option<String>,
|
|
}
|
|
impl SemanticTransitionRequest {
|
|
pub fn new(mut transition: TransitionRequest) -> Self {
|
|
// Semantic headers have their own explicit facets. Existing prose clients
|
|
// continue to use the original request and planner unchanged.
|
|
transition.fields.clear();
|
|
transition.resolutions.clear();
|
|
Self {
|
|
transition,
|
|
traces: BTreeMap::new(),
|
|
references: BTreeMap::new(),
|
|
reviews: vec![],
|
|
candidates: vec![],
|
|
accepted_impacts: vec![],
|
|
}
|
|
}
|
|
}
|
|
|
|
fn canonical(direction: TransitionDirection, source: Id, target: Id) -> (Id, Id) {
|
|
match direction {
|
|
TransitionDirection::OaToSa => (source, target),
|
|
TransitionDirection::SaToOa => (target, source),
|
|
}
|
|
}
|
|
fn trace_for(p: &Project, oa: Id, sa: Id, selected: Option<Id>) -> Result<Trace, String> {
|
|
if let Some(id) = selected {
|
|
return p
|
|
.traces
|
|
.get(&id)
|
|
.filter(|t| t.oa == oa && t.sa == sa)
|
|
.cloned()
|
|
.ok_or(
|
|
"Selected trace does not match the explicit source/target correspondence".into(),
|
|
);
|
|
}
|
|
let traces: Vec<_> = p
|
|
.traces
|
|
.values()
|
|
.filter(|t| t.oa == oa && t.sa == sa)
|
|
.collect();
|
|
if traces.len() != 1 {
|
|
return Err(
|
|
"Semantic review requires exactly one explicit trace for this correspondence".into(),
|
|
);
|
|
}
|
|
Ok(traces[0].clone())
|
|
}
|
|
fn old_facts(t: &Trace) -> SemanticBaseline {
|
|
t.baseline
|
|
.as_ref()
|
|
.and_then(|b| b.semantic.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
fn side(fact: &FacetReview, layer: Layer) -> serde_json::Value {
|
|
let (oa, sa) = sides(fact);
|
|
match layer {
|
|
Layer::Oa => oa,
|
|
Layer::Sa => sa,
|
|
}
|
|
}
|
|
fn target_slot(
|
|
target: Id,
|
|
selector: &FacetSelector,
|
|
layer: Layer,
|
|
) -> Result<Option<(Id, FacetSelector)>, String> {
|
|
let has_target = match selector {
|
|
FacetSelector::Member { keys, .. }
|
|
| FacetSelector::Binding(keys)
|
|
| FacetSelector::Child(keys)
|
|
| FacetSelector::Incident { keys, .. }
|
|
| FacetSelector::Artifact { keys, .. } => chosen(keys, layer).is_some(),
|
|
FacetSelector::Annotation(keys) => chosen(keys, layer).is_some(),
|
|
FacetSelector::Literal(keys) => chosen(keys, layer).is_some(),
|
|
_ => true,
|
|
};
|
|
if !has_target {
|
|
return Ok(None);
|
|
}
|
|
fn single<T: Clone>(key: &KeyPair<T>, layer: Layer) -> KeyPair<T> {
|
|
KeyPair {
|
|
oa: None,
|
|
sa: chosen(key, layer).cloned(),
|
|
}
|
|
}
|
|
let selector = match selector {
|
|
FacetSelector::Member { collection, keys } => FacetSelector::Member {
|
|
collection: *collection,
|
|
keys: single(keys, layer),
|
|
},
|
|
FacetSelector::Literal(keys) => FacetSelector::Literal(single(keys, layer)),
|
|
FacetSelector::Annotation(keys) => FacetSelector::Annotation(single(keys, layer)),
|
|
FacetSelector::Binding(keys) => FacetSelector::Binding(single(keys, layer)),
|
|
FacetSelector::Child(keys) => {
|
|
return Ok(Some((
|
|
*chosen(keys, layer).ok_or("Target child mapping missing")?,
|
|
FacetSelector::Header(HeaderFacet::Parent),
|
|
)));
|
|
}
|
|
FacetSelector::Incident { keys, end } => {
|
|
return Ok(Some((
|
|
*chosen(keys, layer).ok_or("Target incident mapping missing")?,
|
|
FacetSelector::Relation(match end {
|
|
IncidentEnd::Source => RelationFacet::Source,
|
|
IncidentEnd::Target => RelationFacet::Target,
|
|
}),
|
|
)));
|
|
}
|
|
FacetSelector::Artifact { keys, facet } => {
|
|
return Ok(Some((
|
|
*chosen(keys, layer).ok_or("Missing target artifact")?,
|
|
FacetSelector::Artifact {
|
|
keys: single(keys, layer),
|
|
facet: if *facet == ArtifactFacet::Presence {
|
|
ArtifactFacet::Owner
|
|
} else {
|
|
*facet
|
|
},
|
|
},
|
|
)));
|
|
}
|
|
v => v.clone(),
|
|
};
|
|
Ok(Some((target, selector)))
|
|
}
|
|
fn agree(
|
|
slots: &mut BTreeMap<(Id, FacetSelector), serde_json::Value>,
|
|
slot: (Id, FacetSelector),
|
|
value: serde_json::Value,
|
|
) -> Result<(), String> {
|
|
if slots.get(&slot).is_some_and(|old| *old != value) {
|
|
return Err(format!(
|
|
"Contradictory resolutions for canonical target slot {slot:?}"
|
|
));
|
|
}
|
|
slots.insert(slot, value);
|
|
Ok(())
|
|
}
|
|
fn slot_observation(
|
|
project: &Project,
|
|
slot: &(Id, FacetSelector),
|
|
) -> Result<serde_json::Value, String> {
|
|
if let FacetSelector::Artifact { facet, .. } = &slot.1 {
|
|
let artifact = project.artifact_links.get(&slot.0);
|
|
if *facet == ArtifactFacet::Presence {
|
|
return Ok(serde_json::json!({"Value":artifact.is_some()}));
|
|
}
|
|
let Some(artifact) = artifact else {
|
|
return Ok(serde_json::Value::String("NotApplicable".into()));
|
|
};
|
|
let record = serde_json::to_value(artifact).map_err(|e| e.to_string())?;
|
|
let field = match facet {
|
|
ArtifactFacet::Presence => unreachable!(),
|
|
ArtifactFacet::Name => "name",
|
|
ArtifactFacet::Description => "description",
|
|
ArtifactFacet::Owner => "owner",
|
|
ArtifactFacet::Relation => "relation",
|
|
ArtifactFacet::Uri => "uri",
|
|
ArtifactFacet::Revision => "revision",
|
|
ArtifactFacet::Fragment => "fragment",
|
|
};
|
|
return Ok(serde_json::json!({"Value":record[field]}));
|
|
}
|
|
Ok(side(
|
|
&capture_observation(project, slot.0, slot.0, &slot.1)?,
|
|
Layer::Sa,
|
|
))
|
|
}
|
|
fn audit_slots(
|
|
project: &Project,
|
|
slots: &BTreeMap<(Id, FacetSelector), serde_json::Value>,
|
|
) -> Result<(), String> {
|
|
for (slot, desired) in slots {
|
|
if slot_observation(project, slot)? != *desired {
|
|
return Err(format!(
|
|
"Contradictory final value for selected canonical slot {slot:?}"
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn apply_canonical_slot(
|
|
project: &mut Project,
|
|
slot: &(Id, FacetSelector),
|
|
desired: &serde_json::Value,
|
|
) -> Result<(), String> {
|
|
if is_constructor(&slot.1) {
|
|
return Ok(());
|
|
}
|
|
if desired.as_str() == Some("NotApplicable") {
|
|
if matches!(
|
|
slot.1,
|
|
FacetSelector::Artifact {
|
|
facet: ArtifactFacet::Owner,
|
|
..
|
|
}
|
|
) {
|
|
project.artifact_links.remove(&slot.0);
|
|
}
|
|
return Ok(());
|
|
}
|
|
let owner = if matches!(slot.1, FacetSelector::Artifact { .. }) {
|
|
project
|
|
.artifact_links
|
|
.get(&slot.0)
|
|
.ok_or("Missing agreed artifact candidate")?
|
|
.owner
|
|
} else {
|
|
slot.0
|
|
};
|
|
let fact = capture_observation(project, owner, owner, &slot.1)?;
|
|
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
|
|
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
|
|
}
|
|
fn member_present(fact: &FacetReview, layer: Layer) -> bool {
|
|
fn observed<T>(review: &Reviewed<T>, layer: Layer) -> &Observed<T> {
|
|
match layer {
|
|
Layer::Oa => &review.oa,
|
|
Layer::Sa => &review.sa,
|
|
}
|
|
}
|
|
match fact {
|
|
FacetReview::Member { review, .. } | FacetReview::Literal { review, .. } => {
|
|
matches!(observed(review, layer), Observed::Value(true))
|
|
}
|
|
FacetReview::Annotation { review, .. } => {
|
|
matches!(observed(review, layer), Observed::Value(Some(_)))
|
|
}
|
|
FacetReview::Binding { review, .. } => {
|
|
matches!(observed(review, layer), Observed::Value(Some(_)))
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
fn preserve_constructor_members(
|
|
project: &mut Project,
|
|
target: Id,
|
|
group: &FacetSelector,
|
|
initial_facts: &[FacetDrift],
|
|
baseline: &SemanticBaseline,
|
|
source_layer: Layer,
|
|
target_layer: Layer,
|
|
) -> Result<(), String> {
|
|
// A correspondence key can exist without membership on that side (for
|
|
// example an OA literal automatically projects the same SA literal key).
|
|
// Preserve actual target-only values, except previously reviewed removals.
|
|
for fact in initial_facts {
|
|
if !constructor_contains(group, &fact.selector)
|
|
|| member_present(&fact.current, source_layer)
|
|
|| !member_present(&fact.current, target_layer)
|
|
{
|
|
continue;
|
|
}
|
|
let slot = target_slot(target, &fact.selector, target_layer)?;
|
|
let was_corresponding = baseline.facets.iter().any(|old| {
|
|
member_present(old, source_layer)
|
|
&& target_slot(target, &old.selector(), target_layer).ok() == Some(slot.clone())
|
|
});
|
|
if was_corresponding {
|
|
continue;
|
|
}
|
|
write_fact(
|
|
project,
|
|
target,
|
|
&fact.current,
|
|
target_layer,
|
|
target_layer,
|
|
false,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
fn install_candidate(p: &mut Project, record: &SemanticRecord) -> Result<(), String> {
|
|
match record {
|
|
SemanticRecord::Element(e) => {
|
|
let old = p
|
|
.elements
|
|
.get(&e.id)
|
|
.ok_or("Candidate element is not mapped")?;
|
|
if e.alias != old.alias || e.layer() != old.layer() {
|
|
return Err("Candidate cannot change alias or layer".into());
|
|
}
|
|
p.elements.insert(e.id, e.clone());
|
|
}
|
|
SemanticRecord::Relation(r) => {
|
|
let old = p
|
|
.relations
|
|
.get(&r.id)
|
|
.ok_or("Candidate relation is not mapped")?;
|
|
if r.alias != old.alias
|
|
|| std::mem::discriminant(&r.body) != std::mem::discriminant(&old.body)
|
|
{
|
|
return Err("Relation identity/kind changes require explicit mapping".into());
|
|
}
|
|
p.relations.insert(r.id, r.clone());
|
|
}
|
|
SemanticRecord::Artifact(a) => {
|
|
if p.artifact_links
|
|
.get(&a.id)
|
|
.is_some_and(|old| old.alias != a.alias)
|
|
{
|
|
return Err("Artifact candidate cannot change identity alias".into());
|
|
}
|
|
p.artifact_links.insert(a.id, a.clone());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn edits(before: &Project, after: &Project) -> Vec<Command> {
|
|
let mut commands = vec![];
|
|
for (id, e) in &after.elements {
|
|
if before.elements.get(id) != Some(e) {
|
|
commands.push(if before.elements.contains_key(id) {
|
|
Command::EditElement(*id, e.clone())
|
|
} else {
|
|
Command::CreateElement(e.clone())
|
|
});
|
|
}
|
|
}
|
|
for (id, r) in &after.relations {
|
|
if before.relations.get(id) != Some(r) {
|
|
commands.push(if before.relations.contains_key(id) {
|
|
Command::EditRelation(*id, r.clone())
|
|
} else {
|
|
Command::CreateRelation(r.clone())
|
|
});
|
|
}
|
|
}
|
|
for (id, a) in &after.artifact_links {
|
|
if before.artifact_links.get(id) != Some(a) {
|
|
commands.push(if before.artifact_links.contains_key(id) {
|
|
Command::EditArtifact(*id, a.clone())
|
|
} else {
|
|
Command::CreateArtifact(a.clone())
|
|
});
|
|
}
|
|
}
|
|
for id in before.artifact_links.keys() {
|
|
if !after.artifact_links.contains_key(id) {
|
|
commands.push(Command::Delete(*id));
|
|
}
|
|
}
|
|
for (id, t) in &after.traces {
|
|
if before.traces.get(id) != Some(t) {
|
|
commands.push(if before.traces.contains_key(id) {
|
|
Command::EditTrace(*id, t.clone())
|
|
} else {
|
|
Command::CreateTrace(t.clone())
|
|
});
|
|
}
|
|
}
|
|
commands
|
|
}
|
|
fn impacts(before: &Project, after: &Project) -> Vec<SemanticImpact> {
|
|
let mut result = vec![];
|
|
macro_rules! records {
|
|
($field:ident,$variant:ident) => {
|
|
for id in before
|
|
.$field
|
|
.keys()
|
|
.chain(after.$field.keys())
|
|
.copied()
|
|
.collect::<BTreeSet<_>>()
|
|
{
|
|
let a = before.$field.get(&id);
|
|
let b = after.$field.get(&id);
|
|
if a != b {
|
|
result.push(SemanticImpact {
|
|
object: id,
|
|
before: a.cloned().map(ImpactRecord::$variant),
|
|
after: b.cloned().map(ImpactRecord::$variant),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
}
|
|
records!(elements, Element);
|
|
records!(relations, Relation);
|
|
records!(artifact_links, Artifact);
|
|
records!(diagrams, Diagram);
|
|
records!(traces, Trace);
|
|
result
|
|
}
|
|
|
|
/// Preview typed semantic decisions on top of the existing mapping/dependency
|
|
/// planner. All mutation is eventually one ordinary Command::Batch preparation.
|
|
pub fn plan_semantic_transition(
|
|
project: &Project,
|
|
request: SemanticTransitionRequest,
|
|
) -> Result<SemanticTransitionPlan, ModelError> {
|
|
let transition = transitions::plan_transition(project, &request.transition)?;
|
|
let mut plan = SemanticTransitionPlan {
|
|
base_fingerprint: transitions::project_fingerprint(project),
|
|
request,
|
|
commands: transition.commands.clone(),
|
|
transition,
|
|
reviews: vec![],
|
|
impacts: vec![],
|
|
conflicts: vec![],
|
|
prepared_fingerprint: None,
|
|
};
|
|
plan.conflicts
|
|
.extend(plan.transition.conflicts.iter().map(|c| c.message.clone()));
|
|
if !plan.conflicts.is_empty() {
|
|
return Ok(plan);
|
|
}
|
|
let initial = prepare_command(project, &Command::Batch(plan.commands.clone()))?.project;
|
|
if let Err(error) = build_review(project, &initial, &mut plan) {
|
|
plan.conflicts.push(error);
|
|
}
|
|
Ok(plan)
|
|
}
|
|
fn build_review(
|
|
original: &Project,
|
|
initial: &Project,
|
|
plan: &mut SemanticTransitionPlan,
|
|
) -> Result<(), String> {
|
|
let direction = plan.request.transition.direction;
|
|
let source_layer = direction.source_layer();
|
|
let target_layer = direction.target_layer();
|
|
let mut references = plan.request.references.clone();
|
|
for c in &plan.transition.changes {
|
|
let (oa, sa) = canonical(direction, c.source, c.target);
|
|
if references.insert(oa, sa).is_some_and(|old| old != sa) {
|
|
return Err("Transition mapping disagrees with frozen correspondence".into());
|
|
}
|
|
}
|
|
for (oa, sa) in &references {
|
|
if initial.layer_of(*oa).is_some_and(|v| v != Layer::Oa)
|
|
|| initial.layer_of(*sa).is_some_and(|v| v != Layer::Sa)
|
|
{
|
|
return Err("Reference correspondence must use canonical OA/SA sides".into());
|
|
}
|
|
}
|
|
let map = directional_map(&references, direction)?;
|
|
let mut candidates = initial.clone();
|
|
let mut candidate_ids = BTreeSet::new();
|
|
for candidate in &plan.request.candidates {
|
|
if !candidate_ids.insert(candidate.id()) {
|
|
return Err("Duplicate candidate record".into());
|
|
}
|
|
install_candidate(&mut candidates, candidate)?;
|
|
}
|
|
let mut slots = BTreeMap::new();
|
|
let mut constructor_proposals: Vec<(Id, FacetSelector, SemanticRecord)> = vec![];
|
|
let mut constructor_actions: Vec<(Id, FacetSelector, SemanticRecord)> = vec![];
|
|
let mut created_artifacts = BTreeMap::new();
|
|
let mut implicit_orders: Vec<(Id, OrderedCollection, Vec<Id>)> = vec![];
|
|
let mut seen_sources = BTreeSet::new();
|
|
let mut acknowledgements: Vec<(Id, Id, Id, SemanticBaseline, Vec<FacetSelector>)> = vec![];
|
|
for request in &plan.request.reviews {
|
|
// Every source's desired values are derived from the same frozen project.
|
|
// No source observes writes proposed by an earlier source.
|
|
let mut expected = initial.clone();
|
|
if !seen_sources.insert(request.source) {
|
|
return Err("Duplicate semantic review source".into());
|
|
}
|
|
let target = plan
|
|
.transition
|
|
.changes
|
|
.iter()
|
|
.find(|c| c.source == request.source)
|
|
.map(|c| c.target)
|
|
.ok_or("Review source is outside selected transition mappings")?;
|
|
let (oa, sa) = canonical(direction, request.source, target);
|
|
let mut trace = trace_for(
|
|
initial,
|
|
oa,
|
|
sa,
|
|
plan.request.traces.get(&request.source).copied(),
|
|
)?;
|
|
let mut baseline = old_facts(&trace);
|
|
let mut reset_set = BTreeSet::new();
|
|
for reset in &request.reset {
|
|
if !reset_set.insert(reset.clone())
|
|
|| !baseline.facets.iter().any(|v| v.selector() == *reset)
|
|
{
|
|
return Err("Reset must name an existing historical selector exactly once".into());
|
|
}
|
|
}
|
|
baseline
|
|
.facets
|
|
.retain(|v| !reset_set.contains(&v.selector()));
|
|
if let Some(outer) = trace.baseline.as_mut() {
|
|
outer.semantic = Some(baseline.clone());
|
|
}
|
|
let all = semantic_facets(initial, &trace, &references)?;
|
|
let mut acknowledged = vec![];
|
|
let mut retired = vec![];
|
|
let mut resolutions = request
|
|
.resolutions
|
|
.iter()
|
|
.map(|resolution| {
|
|
let current = capture_facet(initial, oa, sa, &resolution.facet)?;
|
|
// A first equal discriminator observation has only its scalar
|
|
// scope. Existing history and actual proposals retain the full
|
|
// constructor group, including its no-switch rejection.
|
|
let first_equal_constructor = is_constructor(&resolution.facet)
|
|
&& !baseline
|
|
.facets
|
|
.iter()
|
|
.any(|old| old.selector() == resolution.facet)
|
|
&& side(¤t, source_layer) == side(¤t, target_layer)
|
|
&& (resolution.decision != SemanticDecision::Candidate
|
|
|| side(
|
|
&capture_facet(&candidates, oa, sa, &resolution.facet)?,
|
|
target_layer,
|
|
) == side(¤t, target_layer));
|
|
Ok((
|
|
resolution,
|
|
is_constructor(&resolution.facet) && !first_equal_constructor,
|
|
))
|
|
})
|
|
.collect::<Result<Vec<_>, String>>()?;
|
|
for (i, (resolution, constructor_group)) in resolutions.iter().enumerate() {
|
|
check_keys(&resolution.facet, &references)?;
|
|
if let FacetSelector::Artifact { keys, facet } = &resolution.facet
|
|
&& *facet != ArtifactFacet::Presence
|
|
{
|
|
for (owner, key) in [(oa, keys.oa), (sa, keys.sa)] {
|
|
if key
|
|
.and_then(|id| initial.artifact_links.get(&id))
|
|
.is_some_and(|artifact| artifact.owner != owner)
|
|
{
|
|
return Err("Artifact metadata can only be reviewed on its initially attached owner trace".into());
|
|
}
|
|
}
|
|
}
|
|
if resolutions[..i].iter().any(|(r, previous_group)| {
|
|
keyed_slots_overlap(&r.facet, &resolution.facet)
|
|
|| (*previous_group && constructor_contains(&r.facet, &resolution.facet))
|
|
|| (*constructor_group && constructor_contains(&resolution.facet, &r.facet))
|
|
}) {
|
|
return Err("Duplicate, colliding or overlapping resolution scopes".into());
|
|
}
|
|
if baseline.facets.iter().any(|old| {
|
|
old.selector() != resolution.facet
|
|
&& keyed_slots_overlap(&old.selector(), &resolution.facet)
|
|
}) {
|
|
return Err(
|
|
"Reference pairing changed; explicitly reset the affected historical selector"
|
|
.into(),
|
|
);
|
|
}
|
|
for group in [
|
|
FacetSelector::Data(DataFacet::Constructor),
|
|
FacetSelector::Port(PortFacet::Constructor),
|
|
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
|
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
|
|
FacetSelector::Event(EventFacet::Constructor),
|
|
] {
|
|
if group != resolution.facet
|
|
&& constructor_contains(&group, &resolution.facet)
|
|
&& let Some(old) = baseline.facets.iter().find(|f| f.selector() == group)
|
|
&& let Ok(current) = capture_facet(initial, oa, sa, &group)
|
|
&& sides(old) != sides(¤t)
|
|
{
|
|
return Err("Constructor changed since review; resolve its explicit group before descendant facets".into());
|
|
}
|
|
}
|
|
}
|
|
resolutions.sort_by_key(|(r, constructor_group)| {
|
|
if *constructor_group {
|
|
0
|
|
} else if matches!(r.facet, FacetSelector::Order(_)) {
|
|
2
|
|
} else {
|
|
1
|
|
}
|
|
});
|
|
for (resolution, constructor_group) in resolutions {
|
|
let selected = &resolution.facet;
|
|
let current = capture_facet(initial, oa, sa, selected)?;
|
|
if is_constructor(selected) && !constructor_group {
|
|
if let Some(slot) = target_slot(target, selected, target_layer)? {
|
|
agree(&mut slots, slot, side(¤t, target_layer))?;
|
|
}
|
|
acknowledged.push(selected.clone());
|
|
} else if constructor_group {
|
|
let old = baseline.facets.iter().find(|v| v.selector() == *selected);
|
|
let current_source = side(¤t, source_layer);
|
|
let current_target = side(¤t, target_layer);
|
|
let candidate_fact = capture_facet(&candidates, oa, sa, selected)?;
|
|
let proposed = match resolution.decision {
|
|
SemanticDecision::KeepTarget => current_target.clone(),
|
|
SemanticDecision::TakeSource => current_source.clone(),
|
|
SemanticDecision::Candidate => side(&candidate_fact, target_layer),
|
|
};
|
|
let historical_switch = old.is_some_and(|old| sides(old) != sides(¤t));
|
|
if proposed == current_target
|
|
&& !historical_switch
|
|
&& (old.is_some() || current_source == current_target)
|
|
{
|
|
return Err(
|
|
"No constructor switch; review descendant facets individually".into(),
|
|
);
|
|
}
|
|
if resolution.decision != SemanticDecision::KeepTarget {
|
|
let mut supplied = semantic_record(
|
|
if resolution.decision == SemanticDecision::TakeSource {
|
|
initial
|
|
} else {
|
|
&candidates
|
|
},
|
|
if resolution.decision == SemanticDecision::TakeSource {
|
|
request.source
|
|
} else {
|
|
target
|
|
},
|
|
)
|
|
.ok_or("Missing constructor candidate")?;
|
|
if resolution.decision == SemanticDecision::TakeSource
|
|
&& !matches!(
|
|
selected,
|
|
FacetSelector::Occurrence(OccurrenceFacet::MessageKind)
|
|
| FacetSelector::State(StateFacet::Kind)
|
|
)
|
|
{
|
|
if let SemanticRecord::Element(e) = &mut supplied {
|
|
e.parent = None;
|
|
}
|
|
remap_record(&mut supplied, &map)?;
|
|
}
|
|
copy_constructor(&mut expected, target, &supplied, selected)?;
|
|
if resolution.decision == SemanticDecision::TakeSource
|
|
&& proposed == current_target
|
|
{
|
|
preserve_constructor_members(
|
|
&mut expected,
|
|
target,
|
|
selected,
|
|
&all,
|
|
&baseline,
|
|
source_layer,
|
|
target_layer,
|
|
)?;
|
|
}
|
|
}
|
|
let observed = if resolution.decision == SemanticDecision::KeepTarget {
|
|
initial
|
|
} else {
|
|
&expected
|
|
};
|
|
let local_record =
|
|
semantic_record(observed, target).ok_or("Missing constructor proposal")?;
|
|
constructor_proposals.push((target, selected.clone(), local_record.clone()));
|
|
if resolution.decision != SemanticDecision::KeepTarget {
|
|
constructor_actions.push((target, selected.clone(), local_record));
|
|
}
|
|
let refreshed = semantic_facets(observed, &trace, &references)?;
|
|
let mut group_selectors: BTreeSet<_> = refreshed
|
|
.iter()
|
|
.chain(all.iter())
|
|
.map(|f| f.selector.clone())
|
|
.filter(|f| constructor_contains(selected, f))
|
|
.collect();
|
|
// A constructor copy can turn a live one-sided member into a
|
|
// pair. Its initial projection is not another reviewed fact.
|
|
// Established one-sided history still requires an explicit reset.
|
|
let paired_sides: BTreeSet<_> = group_selectors
|
|
.iter()
|
|
.map(selector_slots)
|
|
.filter(|slots| slots.len() == 2)
|
|
.flatten()
|
|
.collect();
|
|
group_selectors.retain(|selector| {
|
|
!paired_sides.contains(selector)
|
|
|| baseline
|
|
.facets
|
|
.iter()
|
|
.any(|old| old.selector() == *selector)
|
|
});
|
|
let mut group = vec![];
|
|
for selector in group_selectors {
|
|
match capture_facet(observed, oa, sa, &selector) {
|
|
Ok(current) => group.push(FacetDrift {
|
|
selector,
|
|
current,
|
|
drift: transitions::Drift::Unreviewed,
|
|
}),
|
|
Err(_) => {
|
|
if let Some(slot) = target_slot(target, &selector, target_layer)? {
|
|
agree(
|
|
&mut slots,
|
|
slot,
|
|
serde_json::Value::String("NotApplicable".into()),
|
|
)?;
|
|
}
|
|
retired.push(selector);
|
|
}
|
|
}
|
|
}
|
|
for fact in &group {
|
|
if let Some(slot) = target_slot(target, &fact.selector, target_layer)? {
|
|
agree(&mut slots, slot, side(&fact.current, target_layer))?;
|
|
}
|
|
}
|
|
baseline
|
|
.facets
|
|
.retain(|f| !constructor_contains(selected, &f.selector()));
|
|
acknowledged.extend(group.into_iter().map(|f| f.selector));
|
|
} else {
|
|
let from = match resolution.decision {
|
|
SemanticDecision::TakeSource => source_layer,
|
|
_ => target_layer,
|
|
};
|
|
let mut fact = if resolution.decision == SemanticDecision::Candidate {
|
|
capture_facet(&candidates, oa, sa, selected)?
|
|
} else {
|
|
current.clone()
|
|
};
|
|
if resolution.decision == SemanticDecision::TakeSource {
|
|
fact = if let Some(adapted) = transitions::review_endpoint_fact(
|
|
initial,
|
|
&plan.request.transition,
|
|
&map,
|
|
request.source,
|
|
target,
|
|
&fact,
|
|
)? {
|
|
adapted
|
|
} else {
|
|
remap_fact(&fact, &map, source_layer)?
|
|
};
|
|
}
|
|
// A Keep reads the frozen target, never a value written by an earlier source.
|
|
if resolution.decision != SemanticDecision::KeepTarget {
|
|
if let FacetSelector::Member {
|
|
collection: which,
|
|
keys,
|
|
} = selected
|
|
&& matches!(
|
|
which,
|
|
ReferenceCollection::Steps | ReferenceCollection::FragmentBranches
|
|
)
|
|
&& resolution.decision == SemanticDecision::TakeSource
|
|
&& let (Some(source_key), Some(target_key)) =
|
|
(chosen(keys, source_layer), chosen(keys, target_layer))
|
|
&& let Some(source_order) = collection(initial, request.source, *which)
|
|
&& source_order.contains(source_key)
|
|
{
|
|
let mut target_order =
|
|
collection(&expected, target, *which).ok_or("Missing target order")?;
|
|
let order_facet = FacetSelector::Order(match which {
|
|
ReferenceCollection::Steps => OrderedCollection::Steps,
|
|
_ => OrderedCollection::FragmentBranches,
|
|
});
|
|
if !target_order.contains(target_key)
|
|
&& !request.resolutions.iter().any(|r| {
|
|
r.facet == order_facet && r.decision != SemanticDecision::KeepTarget
|
|
})
|
|
{
|
|
let position =
|
|
source_order.iter().position(|id| id == source_key).unwrap();
|
|
let anchor = |id: &Id| {
|
|
map.get(id).and_then(|mapped| {
|
|
target_order.iter().position(|id| id == mapped)
|
|
})
|
|
};
|
|
let left = source_order[..position].iter().rev().find_map(anchor);
|
|
let right = source_order[position + 1..].iter().find_map(anchor);
|
|
let at=match (left,right) {
|
|
(Some(a),Some(b)) if a+1==b=>b,
|
|
(Some(a),None) if a+1==target_order.len()=>a+1,
|
|
(None,Some(0))=>0,
|
|
(None,None) if target_order.is_empty()=>0,
|
|
_=>return Err("Ordered member insertion has no unique unchanged anchor; review an explicit final target order".into()),
|
|
};
|
|
target_order.insert(at, *target_key);
|
|
set_collection(&mut expected, target, *which, target_order)?;
|
|
}
|
|
}
|
|
if let FacetSelector::Artifact {
|
|
keys,
|
|
facet: ArtifactFacet::Presence,
|
|
} = selected
|
|
&& let Some(id) = chosen(keys, target_layer)
|
|
&& !expected.artifact_links.contains_key(id)
|
|
&& let Some(candidate) = candidates.artifact_links.get(id)
|
|
{
|
|
for field in [
|
|
ArtifactFacet::Name,
|
|
ArtifactFacet::Description,
|
|
ArtifactFacet::Owner,
|
|
ArtifactFacet::Relation,
|
|
ArtifactFacet::Uri,
|
|
ArtifactFacet::Revision,
|
|
ArtifactFacet::Fragment,
|
|
] {
|
|
if !request.resolutions.iter().any(|r| {
|
|
r.facet
|
|
== (FacetSelector::Artifact {
|
|
keys: keys.clone(),
|
|
facet: field,
|
|
})
|
|
}) {
|
|
return Err("Creating an artifact requires explicit review of each introduced metadata facet".into());
|
|
}
|
|
}
|
|
expected.artifact_links.insert(*id, candidate.clone());
|
|
if let Some(old) = created_artifacts.insert(*id, candidate.clone())
|
|
&& old != *candidate
|
|
{
|
|
return Err("Conflicting complete artifact creation candidates".into());
|
|
}
|
|
}
|
|
if resolution.decision == SemanticDecision::Candidate
|
|
&& matches!(
|
|
selected,
|
|
FacetSelector::Child(_)
|
|
| FacetSelector::Incident { .. }
|
|
| FacetSelector::Artifact {
|
|
facet: ArtifactFacet::Owner,
|
|
..
|
|
}
|
|
)
|
|
{
|
|
match selected {
|
|
FacetSelector::Child(keys) => {
|
|
let key =
|
|
chosen(keys, target_layer).ok_or("Missing target child")?;
|
|
expected
|
|
.elements
|
|
.get_mut(key)
|
|
.ok_or("Missing target child")?
|
|
.parent = candidates
|
|
.elements
|
|
.get(key)
|
|
.ok_or("Missing child candidate")?
|
|
.parent;
|
|
}
|
|
FacetSelector::Incident { keys, end } => {
|
|
let key =
|
|
chosen(keys, target_layer).ok_or("Missing target relation")?;
|
|
let proposed = candidates
|
|
.relations
|
|
.get(key)
|
|
.ok_or("Missing endpoint candidate")?
|
|
.body
|
|
.endpoints();
|
|
let current = expected
|
|
.relations
|
|
.get_mut(key)
|
|
.ok_or("Missing target relation")?;
|
|
let (a, b) = current.body.endpoints();
|
|
match end {
|
|
IncidentEnd::Source => {
|
|
current.body.set_endpoints(proposed.0, b)
|
|
}
|
|
IncidentEnd::Target => {
|
|
current.body.set_endpoints(a, proposed.1)
|
|
}
|
|
}
|
|
}
|
|
FacetSelector::Artifact {
|
|
keys,
|
|
facet: ArtifactFacet::Owner,
|
|
} => {
|
|
let key =
|
|
chosen(keys, target_layer).ok_or("Missing target artifact")?;
|
|
let current = expected
|
|
.artifact_links
|
|
.get_mut(key)
|
|
.ok_or("Missing target artifact")?;
|
|
if current.owner != target {
|
|
return Err(
|
|
"Move the artifact from its currently attached owner trace"
|
|
.into(),
|
|
);
|
|
}
|
|
current.owner = candidates
|
|
.artifact_links
|
|
.get(key)
|
|
.ok_or("Missing artifact owner candidate")?
|
|
.owner;
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
} else {
|
|
// Attachment was authorized on the immutable initial
|
|
// snapshot. Another selected facet may have moved this
|
|
// same artifact within the local proposal.
|
|
let write_target = match selected {
|
|
FacetSelector::Artifact { keys, facet }
|
|
if *facet != ArtifactFacet::Presence =>
|
|
{
|
|
chosen(keys, target_layer)
|
|
.and_then(|id| expected.artifact_links.get(id))
|
|
.map_or(target, |artifact| artifact.owner)
|
|
}
|
|
_ => target,
|
|
};
|
|
write_fact(
|
|
&mut expected,
|
|
write_target,
|
|
&fact,
|
|
from,
|
|
target_layer,
|
|
resolution.decision == SemanticDecision::TakeSource,
|
|
)?;
|
|
}
|
|
if let FacetSelector::Artifact {
|
|
keys,
|
|
facet: ArtifactFacet::Presence,
|
|
} = selected
|
|
&& let Some(id) = chosen(keys, target_layer)
|
|
&& initial.artifact_links.contains_key(id)
|
|
&& !expected.artifact_links.contains_key(id)
|
|
{
|
|
for field in [
|
|
ArtifactFacet::Name,
|
|
ArtifactFacet::Description,
|
|
ArtifactFacet::Owner,
|
|
ArtifactFacet::Relation,
|
|
ArtifactFacet::Uri,
|
|
ArtifactFacet::Revision,
|
|
ArtifactFacet::Fragment,
|
|
] {
|
|
let removed = FacetSelector::Artifact {
|
|
keys: keys.clone(),
|
|
facet: field,
|
|
};
|
|
if let Some(slot) = target_slot(target, &removed, target_layer)? {
|
|
agree(
|
|
&mut slots,
|
|
slot,
|
|
serde_json::Value::String("NotApplicable".into()),
|
|
)?;
|
|
}
|
|
if capture_facet(&expected, oa, sa, &removed).is_err() {
|
|
baseline.facets.retain(|v| v.selector() != removed);
|
|
retired.push(removed);
|
|
} else {
|
|
acknowledged.push(removed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(slot) = target_slot(target, selected, target_layer)? {
|
|
let observed_project = if resolution.decision == SemanticDecision::KeepTarget {
|
|
initial
|
|
} else {
|
|
&expected
|
|
};
|
|
let observed = slot_observation(observed_project, &slot)?;
|
|
agree(&mut slots, slot, observed)?;
|
|
}
|
|
acknowledged.push(selected.clone());
|
|
}
|
|
}
|
|
for ordered in [
|
|
OrderedCollection::Steps,
|
|
OrderedCollection::FragmentBranches,
|
|
] {
|
|
let which = super::catalog::order_collection(ordered);
|
|
if let Some(order) = collection(&expected, target, which)
|
|
&& collection(initial, target, which).as_ref() != Some(&order)
|
|
&& !request
|
|
.resolutions
|
|
.iter()
|
|
.any(|r| r.facet == FacetSelector::Order(ordered))
|
|
{
|
|
implicit_orders.push((target, ordered, order));
|
|
}
|
|
}
|
|
plan.reviews.push(SemanticReviewPreview {
|
|
source: request.source,
|
|
target,
|
|
facets: all,
|
|
acknowledged: acknowledged.clone(),
|
|
retired,
|
|
linked_impacts: linked_semantic_impacts(initial, &trace, &references)?,
|
|
});
|
|
acknowledgements.push((oa, sa, trace.id, baseline, acknowledged));
|
|
}
|
|
// A constructor owns every canonical branch slot mentioned by any proposal,
|
|
// including a member introduced by another source. Compare all of these
|
|
// against its independently derived record before any mutation is applied.
|
|
let selected_slots = slots.keys().cloned().collect::<Vec<_>>();
|
|
for (target, group, record) in &constructor_proposals {
|
|
let mut proposal = initial.clone();
|
|
install_candidate(&mut proposal, record)?;
|
|
for slot in &selected_slots {
|
|
if slot.0 == *target && constructor_contains(group, &slot.1) {
|
|
agree(&mut slots, slot.clone(), slot_observation(&proposal, slot)?)?;
|
|
}
|
|
}
|
|
}
|
|
let mut expected = initial.clone();
|
|
constructor_actions.sort_by_key(|(_, group, _)| {
|
|
matches!(
|
|
group,
|
|
FacetSelector::Occurrence(OccurrenceFacet::MessageKind)
|
|
)
|
|
});
|
|
for (target, group, record) in &constructor_actions {
|
|
copy_constructor(&mut expected, *target, record, group)?;
|
|
}
|
|
for (id, artifact) in created_artifacts {
|
|
expected.artifact_links.insert(id, artifact);
|
|
}
|
|
let mut merged = slots.iter().collect::<Vec<_>>();
|
|
merged.sort_by_key(|((_, facet), _)| matches!(facet, FacetSelector::Order(_)));
|
|
for (slot, desired) in merged {
|
|
apply_canonical_slot(&mut expected, slot, desired)?;
|
|
}
|
|
let mut merged_orders = BTreeMap::new();
|
|
for (target, ordered, mut order) in implicit_orders {
|
|
if slots.contains_key(&(target, FacetSelector::Order(ordered))) {
|
|
continue;
|
|
}
|
|
let which = super::catalog::order_collection(ordered);
|
|
let actual =
|
|
collection(&expected, target, which).ok_or("Missing merged ordered collection")?;
|
|
let surviving = actual.iter().copied().collect::<BTreeSet<_>>();
|
|
order.retain(|id| surviving.contains(id));
|
|
if surviving != order.iter().copied().collect::<BTreeSet<_>>() {
|
|
return Err("Combined ordered membership needs an explicit final target order".into());
|
|
}
|
|
if let Some(previous) = merged_orders.insert((target, ordered), order.clone())
|
|
&& previous != order
|
|
{
|
|
return Err("Contradictory anchored orders need an explicit final target order".into());
|
|
}
|
|
}
|
|
for ((target, ordered), order) in merged_orders {
|
|
set_collection(
|
|
&mut expected,
|
|
target,
|
|
super::catalog::order_collection(ordered),
|
|
order,
|
|
)?;
|
|
}
|
|
audit_slots(&expected, &slots)?;
|
|
for candidate in &plan.request.candidates {
|
|
if semantic_record(&expected, candidate.id()).as_ref() != Some(candidate) {
|
|
return Err(format!(
|
|
"Candidate {} changes an unselected facet",
|
|
candidate.id()
|
|
));
|
|
}
|
|
}
|
|
let mut commands = plan.commands.clone();
|
|
commands.extend(edits(initial, &expected));
|
|
let prepared =
|
|
prepare_command(original, &Command::Batch(commands.clone())).map_err(|e| e.to_string())?;
|
|
plan.impacts = impacts(&expected, &prepared.project);
|
|
if plan.impacts.len() != plan.request.accepted_impacts.len()
|
|
|| plan
|
|
.impacts
|
|
.iter()
|
|
.any(|impact| !plan.request.accepted_impacts.contains(impact))
|
|
{
|
|
return Err("Prepared candidate induces additional changes; explicitly review every exact before/after impact".into());
|
|
}
|
|
audit_slots(&prepared.project, &slots)?;
|
|
let mut final_project = prepared.project.clone();
|
|
for (oa, sa, trace_id, mut baseline, acknowledged) in acknowledgements {
|
|
for selector in acknowledged {
|
|
let fact = capture_facet(&final_project, oa, sa, &selector)?;
|
|
baseline.facets.retain(|v| v.selector() != selector);
|
|
baseline.facets.push(fact);
|
|
}
|
|
baseline.facets.sort_by_key(FacetReview::selector);
|
|
validate_semantic_baseline(&baseline).map_err(|e| e.join("; "))?;
|
|
let mut trace = trace_for(&final_project, oa, sa, Some(trace_id))?;
|
|
let mut outer = trace.baseline.clone().unwrap_or(TraceBaseline {
|
|
oa: final_project
|
|
.properties(oa)
|
|
.ok_or("Missing OA properties")?,
|
|
sa: final_project
|
|
.properties(sa)
|
|
.ok_or("Missing SA properties")?,
|
|
semantic: None,
|
|
});
|
|
outer.semantic = Some(baseline);
|
|
trace.baseline = Some(outer);
|
|
final_project.traces.insert(trace.id, trace);
|
|
}
|
|
commands.extend(edits(&prepared.project, &final_project));
|
|
let result =
|
|
prepare_command(original, &Command::Batch(commands.clone())).map_err(|e| e.to_string())?;
|
|
if result.project != final_project {
|
|
return Err("Final preparation changed the reviewed candidate".into());
|
|
}
|
|
plan.prepared_fingerprint = Some(transitions::project_fingerprint(&result.project));
|
|
plan.commands = commands;
|
|
Ok(())
|
|
}
|
|
pub fn prepare_semantic_transition(
|
|
project: &Project,
|
|
plan: &SemanticTransitionPlan,
|
|
) -> Result<PreparedCommand, ModelError> {
|
|
if transitions::project_fingerprint(project) != plan.base_fingerprint {
|
|
return Err(ModelError("Semantic transition preview is stale".into()));
|
|
}
|
|
if !plan.conflicts.is_empty() {
|
|
return Err(ModelError(
|
|
"Resolve semantic transition conflicts before applying".into(),
|
|
));
|
|
}
|
|
let result = prepare_command(project, &Command::Batch(plan.commands.clone()))?;
|
|
if Some(transitions::project_fingerprint(&result.project)) != plan.prepared_fingerprint {
|
|
return Err(ModelError(
|
|
"Prepared semantic candidate differs from reviewed result".into(),
|
|
));
|
|
}
|
|
Ok(result)
|
|
}
|
|
pub fn apply_semantic_transition(
|
|
project: &Project,
|
|
plan: &SemanticTransitionPlan,
|
|
) -> Result<Project, ModelError> {
|
|
prepare_semantic_transition(project, plan).map(|p| p.project)
|
|
}
|