//! 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<String>,
revision: impl Into<String>,
source: impl Into<String>,
) -> 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<String>,
revision: impl Into<String>,
source: impl Into<String>,
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<String>) -> 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<String>) {
self.selected_revision = new_revision.into();
}
/// Commit successful application of the candidate.
pub fn apply_success(&mut self, applied: impl Into<String>) {
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");
}
}