//! Content identity and deployment seed three-way reconciliation (P13, CONTRACTS.md Section 10). //! //! Enforces: //! - Deployment seed does not overwrite live learning on restart or upgrade. //! - Selected vs applied revisions remain distinct. //! - Three-way reconciliation (base_seed, live_selection, new_seed) reports conflicts //! without overwriting either side. //! - Broken candidate start preserves the last good worker/revision. use serde::{Deserialize, Serialize}; /// Deployment seed specifying a baseline configuration or revision. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DeploymentSeed { pub seed_id: String, pub revision: String, pub source: String, pub created_at_ms: i64, } impl DeploymentSeed { /// A seed stamped with the crate's own clock. /// /// `created_at_ms` is derived, not carried, which is fine for a live seed but /// useless to a gate that has to reason about a specific deployment: use [`Self::at`] /// when the timestamp is part of what is being asserted. #[must_use] pub fn new( seed_id: impl Into, revision: impl Into, source: impl Into, ) -> Self { Self::at( seed_id, revision, source, crate::store::now_ms().unwrap_or_default(), ) } /// A seed with an explicit timestamp. Reconciliation is about revisions, so a /// caller that asserts on an ordering or a replay supplies the instant rather /// than reading the wall clock. #[must_use] pub fn at( seed_id: impl Into, revision: impl Into, source: impl Into, created_at_ms: i64, ) -> Self { Self { seed_id: seed_id.into(), revision: revision.into(), source: source.into(), created_at_ms, } } } /// Explicit conflict during three-way seed reconciliation. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ReconciliationConflict { pub base_seed: String, pub live_selection: String, pub new_seed: String, pub message: String, } /// Outcome of three-way reconciliation. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum ReconciliationOutcome { /// Same seed: live selection is preserved untouched. Preserved { effective_revision: String }, /// Clean fast-forward: live state had not diverged from base seed. FastForward { effective_revision: String }, /// Both sides converged to the same revision. Converged { effective_revision: String }, /// Explicit conflict: neither side is overwritten. Conflict { effective_revision: String, conflict: ReconciliationConflict, }, } impl ReconciliationOutcome { #[must_use] pub fn effective_revision(&self) -> &str { match self { Self::Preserved { effective_revision } | Self::FastForward { effective_revision } | Self::Converged { effective_revision } | Self::Conflict { effective_revision, .. } => effective_revision.as_str(), } } #[must_use] pub fn is_conflict(&self) -> bool { matches!(self, Self::Conflict { .. }) } } /// Reconciles an explicit deployment seed change against live selection using 3-way reconciliation. #[must_use] pub fn reconcile_seed( base_seed: &str, live_selection: &str, new_seed: &str, ) -> ReconciliationOutcome { if new_seed == base_seed { return ReconciliationOutcome::Preserved { effective_revision: live_selection.to_string(), }; } if live_selection == base_seed { return ReconciliationOutcome::FastForward { effective_revision: new_seed.to_string(), }; } if new_seed == live_selection { return ReconciliationOutcome::Converged { effective_revision: live_selection.to_string(), }; } let conflict = ReconciliationConflict { base_seed: base_seed.to_string(), live_selection: live_selection.to_string(), new_seed: new_seed.to_string(), message: format!( "three-way reconciliation conflict: base seed '{base_seed}', live selection '{live_selection}', new seed '{new_seed}'" ), }; ReconciliationOutcome::Conflict { effective_revision: live_selection.to_string(), conflict, } } /// Explicit tracking of selected vs actually applied revisions. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RevisionStatus { pub selected_revision: String, pub applied_revision: String, pub last_good_revision: String, } impl RevisionStatus { #[must_use] pub fn new(initial: impl Into) -> Self { let rev = initial.into(); Self { selected_revision: rev.clone(), applied_revision: rev.clone(), last_good_revision: rev, } } /// Update the selected revision (desired state). pub fn select(&mut self, new_revision: impl Into) { self.selected_revision = new_revision.into(); } /// Commit successful application of the candidate. pub fn apply_success(&mut self, applied: impl Into) { let rev = applied.into(); self.applied_revision = rev.clone(); self.last_good_revision = rev; } /// Handle failed candidate start (negative/control): preserves last good revision! pub fn apply_failure(&mut self, _failed_revision: &str) { // Does NOT update applied_revision to the broken candidate! // Preserves the last good worker/revision. self.applied_revision = self.last_good_revision.clone(); } #[must_use] pub fn is_in_sync(&self) -> bool { self.selected_revision == self.applied_revision } } #[cfg(test)] mod tests { use super::*; #[test] fn test_same_seed_preserves_live_state() { let base_seed = "rev-seed-v1"; let live_selection = "rev-live-learning-v2"; let outcome = reconcile_seed(base_seed, live_selection, base_seed); assert_eq!( outcome, ReconciliationOutcome::Preserved { effective_revision: live_selection.to_string(), } ); assert_eq!(outcome.effective_revision(), live_selection); assert!(!outcome.is_conflict()); } #[test] fn test_fast_forward_when_no_live_learning() { let base_seed = "rev-seed-v1"; let live_selection = "rev-seed-v1"; // no divergence let new_seed = "rev-seed-v2"; let outcome = reconcile_seed(base_seed, live_selection, new_seed); assert_eq!( outcome, ReconciliationOutcome::FastForward { effective_revision: new_seed.to_string(), } ); assert_eq!(outcome.effective_revision(), new_seed); assert!(!outcome.is_conflict()); } #[test] fn test_conflicting_seed_reports_conflict_without_overwriting() { let base_seed = "rev-seed-v1"; let live_selection = "rev-live-learning-v2"; let new_seed = "rev-seed-v3"; let outcome = reconcile_seed(base_seed, live_selection, new_seed); assert!(outcome.is_conflict()); assert_eq!(outcome.effective_revision(), live_selection); // Live selection preserved! if let ReconciliationOutcome::Conflict { effective_revision, conflict, } = outcome { assert_eq!(effective_revision, live_selection); assert_eq!(conflict.base_seed, base_seed); assert_eq!(conflict.live_selection, live_selection); assert_eq!(conflict.new_seed, new_seed); assert!(conflict .message .contains("three-way reconciliation conflict")); } else { panic!("expected conflict"); } } #[test] fn test_converged_seed_and_live_selection() { let base_seed = "rev-seed-v1"; let live_selection = "rev-seed-v2"; let new_seed = "rev-seed-v2"; let outcome = reconcile_seed(base_seed, live_selection, new_seed); assert_eq!( outcome, ReconciliationOutcome::Converged { effective_revision: live_selection.to_string(), } ); } #[test] fn test_selected_and_applied_revisions_remain_distinct_and_broken_start_preserves_last_good() { let mut status = RevisionStatus::new("rev-initial"); assert!(status.is_in_sync()); assert_eq!(status.selected_revision, "rev-initial"); assert_eq!(status.applied_revision, "rev-initial"); // Adopt a new revision: selected changes immediately, applied remains initial status.select("rev-adopted"); assert!(!status.is_in_sync()); assert_eq!(status.selected_revision, "rev-adopted"); assert_eq!(status.applied_revision, "rev-initial"); // Negative/control: Broken candidate start fails status.apply_failure("rev-adopted-broken"); assert!(!status.is_in_sync()); assert_eq!(status.selected_revision, "rev-adopted"); assert_eq!(status.applied_revision, "rev-initial"); // preserved last good! // Successful candidate application status.apply_success("rev-adopted"); assert!(status.is_in_sync()); assert_eq!(status.selected_revision, "rev-adopted"); assert_eq!(status.applied_revision, "rev-adopted"); assert_eq!(status.last_good_revision, "rev-adopted"); } }