Files
ARCADIA-Studio/crates/oa-desktop/src/semantic_review/recovery.rs
T
Codex e2ecfea69b
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Checkpoint source 7 workflows and in-progress diagram authoring redesign
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.
2026-09-10 10:06:55 -07:00

729 lines
28 KiB
Rust

//! Recover authored intent against the exact accepted model; recompute every plan.
use super::*;
use crate::forms::RecoveryDraft;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Saved {
version: u32,
base_identity: Id,
base_fingerprint: String,
anchor: Id,
trace: Option<Id>,
direction: TransitionDirection,
selected: Option<FacetSelector>,
decisions: Vec<(FacetSelector, SemanticDecision)>,
references: BTreeMap<Id, Id>,
resets: BTreeSet<FacetSelector>,
staged: BTreeSet<FacetSelector>,
presence: Vec<(FacetSelector, bool)>,
member_family: keyed::Family,
member_oa: Option<Id>,
member_sa: Option<Id>,
literal: String,
literal_oa: bool,
literal_sa: bool,
annotation_oa: Option<AnnotationKey>,
annotation_sa: Option<AnnotationKey>,
draft: Option<RecoveryDraft>,
group_drafts: Vec<(FacetSelector, RecoveryDraft)>,
rows: Vec<(FacetSelector, String, Option<Id>)>,
#[serde(default)]
orders: Vec<(OrderedCollection, Vec<Id>)>,
#[serde(default)]
attachments: attachments::Saved,
#[serde(default)]
artifacts: artifacts::Saved,
#[serde(default)]
operation_omission: Option<String>,
reference_oa: Option<Id>,
reference_sa: Option<Id>,
reference_search: String,
accepted_impacts: Vec<SemanticImpact>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RowPayload {
anchor: Id,
trace: Option<Id>,
direction: TransitionDirection,
selected: Option<FacetSelector>,
decisions: Vec<(FacetSelector, SemanticDecision)>,
resets: BTreeSet<FacetSelector>,
staged: BTreeSet<FacetSelector>,
presence: Vec<(FacetSelector, bool)>,
member_family: keyed::Family,
member_oa: Option<Id>,
member_sa: Option<Id>,
literal: String,
literal_oa: bool,
literal_sa: bool,
annotation_oa: Option<AnnotationKey>,
annotation_sa: Option<AnnotationKey>,
draft: Option<RecoveryDraft>,
group_drafts: Vec<(FacetSelector, RecoveryDraft)>,
rows: Vec<(FacetSelector, String, Option<Id>)>,
#[serde(default)]
orders: Vec<(OrderedCollection, Vec<Id>)>,
#[serde(default)]
attachments: attachments::Saved,
#[serde(default)]
artifacts: artifacts::Saved,
#[serde(default)]
operation_omission: Option<String>,
reference_oa: Option<Id>,
reference_sa: Option<Id>,
reference_search: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SavedRow {
address: RowAddress,
included: bool,
draft: RowPayload,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SavedBatch {
version: u32,
base_identity: Id,
base_fingerprint: String,
direction: TransitionDirection,
references: BTreeMap<Id, Id>,
accepted_impacts: Vec<SemanticImpact>,
active: Option<RowAddress>,
rows: Vec<SavedRow>,
pending: Option<RowPayload>,
add_trace: Option<Id>,
}
impl RowPayload {
fn from_row(row: &ReviewRow) -> Result<Self, String> {
let saved: Saved =
serde_json::from_value(row.recovery_snapshot()?).map_err(|e| e.to_string())?;
Ok(Self {
anchor: saved.anchor,
trace: saved.trace,
direction: saved.direction,
selected: saved.selected,
decisions: saved.decisions,
resets: saved.resets,
staged: saved.staged,
presence: saved.presence,
member_family: saved.member_family,
member_oa: saved.member_oa,
member_sa: saved.member_sa,
literal: saved.literal,
literal_oa: saved.literal_oa,
literal_sa: saved.literal_sa,
annotation_oa: saved.annotation_oa,
annotation_sa: saved.annotation_sa,
draft: saved.draft,
group_drafts: saved.group_drafts,
rows: saved.rows,
orders: saved.orders,
attachments: saved.attachments,
artifacts: saved.artifacts,
operation_omission: saved.operation_omission,
reference_oa: saved.reference_oa,
reference_sa: saved.reference_sa,
reference_search: saved.reference_search,
})
}
fn restore(
self,
project: &Project,
references: &BTreeMap<Id, Id>,
) -> Result<ReviewRow, String> {
ReviewRow::restore_saved(
project,
Saved {
version: 1,
base_identity: project.id,
base_fingerprint: String::new(),
references: references.clone(),
accepted_impacts: vec![],
anchor: self.anchor,
trace: self.trace,
direction: self.direction,
selected: self.selected,
decisions: self.decisions,
resets: self.resets,
staged: self.staged,
presence: self.presence,
member_family: self.member_family,
member_oa: self.member_oa,
member_sa: self.member_sa,
literal: self.literal,
literal_oa: self.literal_oa,
literal_sa: self.literal_sa,
annotation_oa: self.annotation_oa,
annotation_sa: self.annotation_sa,
draft: self.draft,
group_drafts: self.group_drafts,
rows: self.rows,
orders: self.orders,
attachments: self.attachments,
artifacts: self.artifacts,
operation_omission: self.operation_omission,
reference_oa: self.reference_oa,
reference_sa: self.reference_sa,
reference_search: self.reference_search,
},
)
}
}
fn unique<K: Ord, V>(values: Vec<(K, V)>) -> Result<BTreeMap<K, V>, String> {
let mut map = BTreeMap::new();
for (key, value) in values {
if map.insert(key, value).is_some() {
return Err("Recovery contains a duplicate semantic draft address.".into());
}
}
Ok(map)
}
impl ReviewRow {
pub fn recovery_snapshot(&self) -> Result<Value, String> {
let key = |address: &keyed_rows::AddressDraft| {
address.selected.as_ref().map(|k| AnnotationKey {
path: k.path.clone(),
field: k.field,
})
};
serde_json::to_value(Saved {
version: 1,
base_identity: self.base_identity,
base_fingerprint: self.base_fingerprint.clone(),
anchor: self.anchor,
trace: self.trace,
direction: self.direction,
selected: self.selected.clone(),
decisions: self
.decisions
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect(),
references: self.references.clone(),
resets: self.resets.clone(),
staged: self.staged.clone(),
presence: self
.presence
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect(),
member_family: self.member_family,
member_oa: self.member_oa.as_ref().map(|c| c.id),
member_sa: self.member_sa.as_ref().map(|c| c.id),
literal: self.literal.clone(),
literal_oa: self.literal_oa,
literal_sa: self.literal_sa,
annotation_oa: key(&self.annotation_oa),
annotation_sa: key(&self.annotation_sa),
draft: self.draft.as_ref().map(Draft::recovery_snapshot),
group_drafts: self
.group_drafts
.iter()
.map(|(key, draft)| (key.clone(), draft.recovery_snapshot()))
.collect(),
rows: self
.row_drafts
.iter()
.map(|(key, draft)| {
(
key.clone(),
draft.expression.clone(),
draft.local.as_ref().map(|c| c.id),
)
})
.collect(),
attachments: self.attachments.recovery_snapshot(),
artifacts: self.artifacts.recovery_snapshot(),
operation_omission: self.operation_omission.clone(),
reference_oa: self.reference_oa.as_ref().map(|c| c.id),
orders: self
.order_drafts
.iter()
.map(|(key, value)| (*key, value.clone()))
.collect(),
reference_sa: self.reference_sa.as_ref().map(|c| c.id),
reference_search: self.reference_search.clone(),
accepted_impacts: self.accepted_impacts.clone(),
})
.map_err(|e| e.to_string())
}
pub fn from_recovery(project: &Project, value: Value) -> Result<Self, String> {
let saved: Saved = serde_json::from_value(value).map_err(|e| e.to_string())?;
if saved.version != 1
|| saved.base_identity != project.id
|| saved.base_fingerprint != project_fingerprint(project)
{
return Err(
"Semantic review recovery does not match the accepted model identity and revision."
.into(),
);
}
Self::restore_saved(project, saved)
}
fn restore_saved(project: &Project, saved: Saved) -> Result<Self, String> {
let count = [
saved.decisions.len(),
saved.references.len(),
saved.resets.len(),
saved.staged.len(),
saved.presence.len(),
saved.rows.len(),
saved
.orders
.iter()
.fold(0usize, |total, (_, rows)| total.saturating_add(rows.len())),
saved.accepted_impacts.len(),
]
.into_iter()
.fold(0usize, usize::saturating_add);
if count > crate::source_workspace::LIMITS.model.max_records
|| saved.group_drafts.len() > constructors::selectors().len()
{
return Err("Semantic recovery exceeds the supported draft limits.".into());
}
let mut state = Self::new(project, saved.anchor)?;
if saved.trace.is_some_and(|id| {
!project
.traces
.get(&id)
.is_some_and(|trace| trace.oa == saved.anchor || trace.sa == saved.anchor)
}) {
return Err("Semantic recovery trace and selected object disagree.".into());
}
state.base_fingerprint = project_fingerprint(project);
state.trace = saved.trace;
state.direction = saved.direction;
state.load_trace(project);
state.selected = saved.selected;
state.decisions = unique(saved.decisions)?;
state.references = saved.references;
state.resets = saved.resets;
state.staged = saved.staged;
state.presence = unique(saved.presence)?;
state.member_family = saved.member_family;
state.member_oa = saved.member_oa.map(|id| view::choice(project, id));
state.member_sa = saved.member_sa.map(|id| view::choice(project, id));
state.literal = saved.literal;
state.literal_oa = saved.literal_oa;
state.literal_sa = saved.literal_sa;
let address = |key: Option<AnnotationKey>| keyed_rows::AddressDraft {
selected: key.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
}),
..Default::default()
};
state.annotation_oa = address(saved.annotation_oa);
state.annotation_sa = address(saved.annotation_sa);
let target = state.target(project);
state.draft = saved
.draft
.map(|draft| Draft::from_recovery(project, draft))
.transpose()?;
if state
.draft
.as_ref()
.is_some_and(|draft| Some(draft.original) != target)
{
return Err("Recovered semantic draft belongs to another target.".into());
}
state.group_drafts = unique(
saved
.group_drafts
.into_iter()
.map(|(facet, draft)| {
if !constructors::selectors().contains(&facet) {
return Err("Recovered constructor draft has an unknown scope.".to_owned());
}
let mut draft = Draft::from_recovery(project, draft)?;
if Some(draft.original) != target {
return Err("Recovered constructor draft belongs to another target.".into());
}
draft.semantic_groups = true;
Ok((facet, draft))
})
.collect::<Result<Vec<_>, String>>()?,
)?;
state.row_drafts = unique(
saved
.rows
.into_iter()
.map(|(facet, expression, local)| {
if !keyed_rows::supported(&facet) {
return Err("Recovered row draft has an unknown scope.".to_owned());
}
Ok((
facet,
keyed_rows::RowDraft {
editor: text_editor::Content::with_text(&expression),
expression,
local: local.map(|id| view::choice(project, id)),
},
))
})
.collect::<Result<Vec<_>, String>>()?,
)?;
state.reference_oa = saved.reference_oa.map(|id| view::choice(project, id));
state.order_drafts = unique(saved.orders)?;
state.attachments = attachments::Editor::from_recovery(
project,
state.direction.target_layer(),
saved.attachments,
)?;
if let Some(trace) = state.trace.and_then(|id| project.traces.get(&id)) {
state.artifacts =
artifacts::Editor::from_recovery(project, trace, state.direction, saved.artifacts)?;
} else if !saved.artifacts.is_empty() {
return Err("Recovered artifact drafts need an exact owner trace.".into());
}
state.reference_sa = saved.reference_sa.map(|id| view::choice(project, id));
state.operation_omission = saved.operation_omission;
state.reference_search = saved.reference_search;
state.accepted_impacts = saved.accepted_impacts;
// Invalid numbers and incomplete mappings are recoverable authored input.
// Refresh reports their errors instead of dropping them or applying a plan.
state.refresh(project);
Ok(state)
}
}
fn batch_limits(value: &Value) -> Result<(), String> {
let mut pending = vec![value];
let mut count = 0usize;
let mut bytes = 0usize;
while let Some(value) = pending.pop() {
count = count.saturating_add(1);
match value {
Value::String(text) => bytes = bytes.saturating_add(text.len()),
Value::Array(values) => pending.extend(values),
Value::Object(values) => {
bytes = values
.keys()
.fold(bytes, |total, key| total.saturating_add(key.len()));
pending.extend(values.values());
}
_ => {}
}
if count > crate::source_workspace::LIMITS.model.max_records
|| bytes > crate::source_workspace::LIMITS.model.max_source_bytes
{
return Err("Semantic recovery exceeds the aggregate draft limits.".into());
}
}
Ok(())
}
impl State {
/// A source-5/6 upgrade may reconstruct the same accepted records with a new
/// schema fingerprint. The caller supplies the independently verified legacy
/// base; every saved row is still decoded and replanned through strict restore.
pub(crate) fn from_recovery_after_source5_upgrade(
project: &Project,
legacy_base: &str,
value: Value,
) -> Result<Self, String> {
batch_limits(&value)?;
crate::recovery::validate_pre7_intent(&value)?;
let value = match value.get("version").and_then(Value::as_u64) {
Some(1) => {
let mut saved: Saved = serde_json::from_value(value).map_err(|e| e.to_string())?;
if saved.base_identity != project.id || saved.base_fingerprint != legacy_base {
return Err(
"Semantic legacy recovery does not match the verified legacy base.".into(),
);
}
saved.base_fingerprint = project_fingerprint(project);
saved.accepted_impacts.clear();
serde_json::to_value(saved).map_err(|e| e.to_string())?
}
Some(2) => {
let mut saved: SavedBatch =
serde_json::from_value(value).map_err(|e| e.to_string())?;
if saved.base_identity != project.id || saved.base_fingerprint != legacy_base {
return Err(
"Semantic legacy recovery does not match the verified legacy base.".into(),
);
}
saved.base_fingerprint = project_fingerprint(project);
saved.accepted_impacts.clear();
serde_json::to_value(saved).map_err(|e| e.to_string())?
}
_ => return Err("Unsupported semantic recovery version.".into()),
};
Self::from_recovery(project, value)
}
pub fn recovery_snapshot(&self) -> Result<Value, String> {
let rows = self
.order
.iter()
.map(|address| {
Ok(SavedRow {
address: *address,
included: self.included.contains(address),
draft: RowPayload::from_row(
self.row(*address)
.ok_or("Retained review row is missing.")?,
)?,
})
})
.collect::<Result<Vec<_>, String>>()?;
if rows.len() > batch::MAX_REVIEW_ROWS {
return Err("Semantic recovery exceeds the supported row limit.".into());
}
let saved = SavedBatch {
version: 2,
base_identity: self.base_identity,
base_fingerprint: self.base_fingerprint.clone(),
direction: self.direction,
references: self.references.clone(),
accepted_impacts: self.accepted_impacts.clone(),
active: self.active_address,
rows,
pending: if self.active_address.is_none() {
Some(RowPayload::from_row(&self.active)?)
} else {
None
},
add_trace: self.add_trace.as_ref().map(|choice| choice.id),
};
let value = serde_json::to_value(saved).map_err(|error| error.to_string())?;
batch_limits(&value)?;
Ok(value)
}
pub fn from_recovery(project: &Project, value: Value) -> Result<Self, String> {
batch_limits(&value)?;
if value.get("version").and_then(Value::as_u64) == Some(1) {
let active = ReviewRow::from_recovery(project, value)?;
let address = active
.trace
.and_then(|id| project.traces.get(&id))
.map(|trace| RowAddress::from_trace(trace, active.direction));
let mut state = Self {
base: std::sync::Arc::new(project.clone()),
base_identity: project.id,
base_fingerprint: project_fingerprint(project),
direction: active.direction,
references: active.references.clone(),
accepted_impacts: active.accepted_impacts.clone(),
plan: None,
proposed: None,
error: None,
active,
active_address: address,
bank: BTreeMap::new(),
order: address.into_iter().collect(),
included: address.into_iter().collect(),
add_trace: None,
notice: None,
};
state.refresh(project);
return Ok(state);
}
let saved: SavedBatch = serde_json::from_value(value).map_err(|error| error.to_string())?;
if saved.version != 2
|| saved.base_identity != project.id
|| saved.base_fingerprint != project_fingerprint(project)
{
return Err(
"Semantic review recovery does not match the accepted model identity and revision."
.into(),
);
}
if saved.rows.len() > batch::MAX_REVIEW_ROWS {
return Err("Semantic recovery exceeds the supported row limit.".into());
}
let mut bank = BTreeMap::new();
let mut order = vec![];
let mut included = BTreeSet::new();
let mut sources = BTreeSet::new();
for saved_row in saved.rows {
let address = saved_row.address;
let direction = address.direction(project)?;
let row = saved_row.draft.restore(project, &saved.references)?;
if row.direction != direction
|| row.trace != Some(address.trace)
|| row.target(project) != Some(address.target)
{
return Err(
"Recovered row draft does not match its stable source/target/trace address."
.into(),
);
}
if bank.insert(address, row).is_some() {
return Err("Recovery contains a duplicate semantic row address.".into());
}
order.push(address);
if saved_row.included {
if !sources.insert(address.source) {
return Err(
"Recovery contains multiple included histories for one source.".into(),
);
}
included.insert(address);
}
}
let active = match (saved.active, saved.pending) {
(Some(address), None) => {
if address.direction(project)? != saved.direction {
return Err("Recovery active row has a different direction.".into());
}
bank.remove(&address)
.ok_or("Recovery active review row is absent.")?
}
(None, Some(pending)) if bank.is_empty() => {
let row = pending.restore(project, &saved.references)?;
if row.trace.is_some() || row.direction != saved.direction {
return Err("Recovery pending row is not an unresolved trace selection.".into());
}
row
}
_ => return Err("Recovery has an invalid active/pending review row.".into()),
};
if saved
.add_trace
.is_some_and(|id| !project.traces.contains_key(&id))
{
return Err("Recovery add-row selection is not an existing trace.".into());
}
let mut state = Self {
base: std::sync::Arc::new(project.clone()),
base_identity: saved.base_identity,
base_fingerprint: saved.base_fingerprint,
direction: saved.direction,
references: saved.references,
accepted_impacts: saved.accepted_impacts,
plan: None,
proposed: None,
error: None,
active,
active_address: saved.active,
bank,
order,
included,
add_trace: saved.add_trace.map(|id| view::choice(project, id)),
notice: None,
};
// Recompose all included rows once. Excluded invalid raw drafts remain recoverable.
state.refresh(project);
Ok(state)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_one_without_orders_restores_raw_intent_and_replans_without_execution() {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Operational", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "System", None)
.unwrap();
for id in [oa, sa] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(0),
maximum: Some(100),
},
default: None,
unit: String::new(),
};
}
super::super::tests::add_trace(&mut p, oa, sa, "Version one review");
let accepted = p.clone();
let facet = FacetSelector::Data(DataFacet::IntegerMinimum);
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
for (raw, valid) in [("7", true), ("-", false)] {
let mut state = State::new(&p, oa).unwrap();
state.update(&p, RowEvent::Select(facet.clone()));
state.update(
&p,
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
);
state.update(&p, RowEvent::Input(path.into(), raw.into()));
assert_eq!(state.can_apply(), valid);
let current = state.active.recovery_snapshot().unwrap();
let mut old = current.clone();
assert_eq!(old["version"], 1);
assert_eq!(
old.as_object_mut().unwrap().remove("orders"),
Some(serde_json::json!([]))
);
let restored = State::from_recovery(&p, old.clone()).unwrap();
assert!(restored.order_drafts.is_empty());
assert_eq!(restored.draft.as_ref().unwrap().inputs[path].raw, raw);
assert_eq!(restored.can_apply(), valid);
assert_eq!(restored.active.recovery_snapshot().unwrap(), current);
assert_eq!(
p, accepted,
"Recovery prepares a preview, never an accepted edit"
);
let mut changed = p.clone();
changed.name.push_str(" changed accepted model");
assert!(State::from_recovery(&changed, old).is_err());
}
}
#[test]
fn raw_scalar_and_closed_constructor_drafts_recover_against_exact_model_only() {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Operational", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "System", None)
.unwrap();
for id in [oa, sa] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(0),
maximum: Some(100),
},
default: None,
unit: String::new(),
};
}
super::super::tests::add_trace(&mut p, oa, sa, "Recover review");
let mut state = State::new(&p, oa).unwrap();
let facet = FacetSelector::Data(DataFacet::IntegerMinimum);
state.update(&p, RowEvent::Select(facet.clone()));
state.update(
&p,
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
);
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
state.update(&p, RowEvent::Input(path.into(), "-".into()));
assert!(!state.can_apply());
let saved = state.recovery_snapshot().unwrap();
let restored = State::from_recovery(&p, saved.clone()).unwrap();
assert_eq!(restored.draft.as_ref().unwrap().inputs[path].raw, "-");
assert_eq!(restored.decisions, state.decisions);
assert!(!restored.can_apply());
assert!(restored.plan.is_none());
assert!(
restored
.group_drafts
.values()
.all(|draft| draft.semantic_groups)
);
p.name.push_str(" changed");
assert!(
State::from_recovery(&p, saved)
.err()
.unwrap()
.contains("identity and revision")
);
}
}