//! 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, direction: TransitionDirection, selected: Option, decisions: Vec<(FacetSelector, SemanticDecision)>, references: BTreeMap, resets: BTreeSet, staged: BTreeSet, presence: Vec<(FacetSelector, bool)>, member_family: keyed::Family, member_oa: Option, member_sa: Option, literal: String, literal_oa: bool, literal_sa: bool, annotation_oa: Option, annotation_sa: Option, draft: Option, group_drafts: Vec<(FacetSelector, RecoveryDraft)>, rows: Vec<(FacetSelector, String, Option)>, #[serde(default)] orders: Vec<(OrderedCollection, Vec)>, #[serde(default)] attachments: attachments::Saved, #[serde(default)] artifacts: artifacts::Saved, #[serde(default)] operation_omission: Option, reference_oa: Option, reference_sa: Option, reference_search: String, accepted_impacts: Vec, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct RowPayload { anchor: Id, trace: Option, direction: TransitionDirection, selected: Option, decisions: Vec<(FacetSelector, SemanticDecision)>, resets: BTreeSet, staged: BTreeSet, presence: Vec<(FacetSelector, bool)>, member_family: keyed::Family, member_oa: Option, member_sa: Option, literal: String, literal_oa: bool, literal_sa: bool, annotation_oa: Option, annotation_sa: Option, draft: Option, group_drafts: Vec<(FacetSelector, RecoveryDraft)>, rows: Vec<(FacetSelector, String, Option)>, #[serde(default)] orders: Vec<(OrderedCollection, Vec)>, #[serde(default)] attachments: attachments::Saved, #[serde(default)] artifacts: artifacts::Saved, #[serde(default)] operation_omission: Option, reference_oa: Option, reference_sa: Option, 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, accepted_impacts: Vec, active: Option, rows: Vec, pending: Option, add_trace: Option, } impl RowPayload { fn from_row(row: &ReviewRow) -> Result { 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, ) -> Result { 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(values: Vec<(K, V)>) -> Result, 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 { 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 { 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 { 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| 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::, 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::, 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 { 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 { 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::, 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 { 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") ); } }