use meerkat_mob::store::MobStoreError;
use meerkat_mob::{
MobDefinition, MobDefinitionProjectionHealth, MobDefinitionProjectionMismatchKind, MobError,
MobStorage,
};
#[derive(Debug)]
#[non_exhaustive]
pub enum SpecUpdateError {
ReadFailed {
mob_id: String,
source: MobStoreError,
},
NothingPinned { mob_id: String },
AlreadyMatching { mob_id: String, revision: u64 },
RevisionMoved {
mob_id: String,
proposed_at: u64,
found: u64,
},
DefinitionProjectionDisagreement {
mob_id: String,
authority_epoch: u64,
projection_revision: u64,
kind: MobDefinitionProjectionMismatchKind,
},
UnrecognizedDefinitionHealth { mob_id: String },
UpdateFailed {
mob_id: String,
source: Box<MobError>,
},
MobIdMismatch {
declared_for: String,
definition_names: String,
},
}
impl std::fmt::Display for SpecUpdateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ReadFailed { mob_id, source } => write!(
f,
"the canonical definition for mob {mob_id} could not be read: {source}"
),
Self::NothingPinned { mob_id } => write!(
f,
"no canonical definition exists for mob {mob_id}, so nothing is pinned and there \
is nothing to declare; creating the mob records the definition normally"
),
Self::AlreadyMatching { mob_id, revision } => write!(
f,
"the canonical definition for mob {mob_id} at revision {revision} already matches the \
supplied definition, so a declaration would change nothing; if a boot was \
refused, the divergence is not in the mob definition"
),
Self::RevisionMoved {
mob_id,
proposed_at,
found,
} => write!(
f,
"the canonical definition for mob {mob_id} moved from revision {proposed_at} to \
{found} while the update was being declared, so the declaration refers to a \
definition that is no longer current; re-read and declare again"
),
Self::DefinitionProjectionDisagreement {
mob_id,
authority_epoch,
projection_revision,
kind,
} => write!(
f,
"mob {mob_id} canonical definition and spec projection disagree ({kind}): \
authority epoch {authority_epoch}, projection revision {projection_revision}"
),
Self::UnrecognizedDefinitionHealth { mob_id } => write!(
f,
"mob {mob_id} returned a definition health state this MobKit build cannot judge"
),
Self::UpdateFailed { mob_id, source } => write!(
f,
"declaring the updated definition for mob {mob_id} failed: {source}"
),
Self::MobIdMismatch {
declared_for,
definition_names,
} => write!(
f,
"the declaration is for mob {declared_for} but the definition it carries names \
{definition_names}; refusing rather than moving a pin the operator did not name"
),
}
}
}
impl std::error::Error for SpecUpdateError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::ReadFailed { source, .. } => Some(source),
Self::UpdateFailed { source, .. } => Some(source.as_ref()),
_ => None,
}
}
}
#[derive(Debug)]
pub struct SpecUpdateProposal {
mob_id: meerkat_mob::MobId,
observed_revision: u64,
declared: MobDefinition,
diverged_fields: Vec<String>,
}
impl SpecUpdateProposal {
#[must_use]
pub fn diverged_fields(&self) -> &[String] {
&self.diverged_fields
}
#[must_use]
pub fn observed_revision(&self) -> u64 {
self.observed_revision
}
#[must_use]
pub fn mob_id(&self) -> &str {
self.mob_id.as_str()
}
}
#[derive(Debug, Clone)]
pub struct SpecUpdateReceipt {
pub mob_id: String,
pub previous_revision: u64,
pub committed_revision: u64,
pub projection_revision: u64,
pub event_cursor: u64,
pub declared_fields: Vec<String>,
}
#[derive(Debug)]
struct CanonicalDefinitionWitness {
definition: MobDefinition,
revision: u64,
}
fn normalize_definition(definition: &MobDefinition) -> MobDefinition {
let mut normalized = definition.clone();
crate::mob_handle_runtime::auto_mark_declared_resume_overrides(&mut normalized);
normalized
}
fn authority_epoch(
mob_id: &str,
health: Option<MobDefinitionProjectionHealth>,
released_projection_ahead_expected_revision: Option<u64>,
) -> Result<u64, SpecUpdateError> {
match health {
Some(
MobDefinitionProjectionHealth::Healthy {
authority_epoch, ..
}
| MobDefinitionProjectionHealth::ProjectionMissing { authority_epoch }
| MobDefinitionProjectionHealth::ProjectionStale {
authority_epoch, ..
},
) => Ok(authority_epoch),
Some(MobDefinitionProjectionHealth::Diverged {
authority_epoch: 1,
projection_revision: 2,
kind: MobDefinitionProjectionMismatchKind::ProjectionAhead,
}) if released_projection_ahead_expected_revision == Some(1) => Ok(1),
Some(MobDefinitionProjectionHealth::Diverged {
authority_epoch,
projection_revision,
kind,
}) => Err(SpecUpdateError::DefinitionProjectionDisagreement {
mob_id: mob_id.to_string(),
authority_epoch,
projection_revision,
kind,
}),
None => Err(SpecUpdateError::NothingPinned {
mob_id: mob_id.to_string(),
}),
_ => Err(SpecUpdateError::UnrecognizedDefinitionHealth {
mob_id: mob_id.to_string(),
}),
}
}
async fn observe_canonical_definition(
storage: &MobStorage,
mob_id: &str,
released_projection_ahead_expected_revision: Option<u64>,
) -> Result<CanonicalDefinitionWitness, SpecUpdateError> {
let read_health = || async {
storage
.definition_projection_health()
.await
.map_err(|source| SpecUpdateError::ReadFailed {
mob_id: mob_id.to_string(),
source,
})
};
let before = authority_epoch(
mob_id,
read_health().await?,
released_projection_ahead_expected_revision,
)?;
let definition = storage
.created_definition()
.await
.map_err(|source| SpecUpdateError::ReadFailed {
mob_id: mob_id.to_string(),
source,
})?
.filter(|definition| definition.id.as_str() == mob_id)
.ok_or_else(|| SpecUpdateError::NothingPinned {
mob_id: mob_id.to_string(),
})?;
let after = authority_epoch(
mob_id,
read_health().await?,
released_projection_ahead_expected_revision,
)?;
if before != after {
return Err(SpecUpdateError::RevisionMoved {
mob_id: mob_id.to_string(),
proposed_at: before,
found: after,
});
}
Ok(CanonicalDefinitionWitness {
definition,
revision: after,
})
}
fn map_update_error(mob_id: &str, error: MobError) -> SpecUpdateError {
match error {
MobError::SpecRevisionConflict {
expected, actual, ..
} => SpecUpdateError::RevisionMoved {
mob_id: mob_id.to_string(),
proposed_at: expected.unwrap_or(actual),
found: actual,
},
MobError::MobDefinitionProjectionMismatch {
authority_epoch,
projection_revision,
kind,
..
} => SpecUpdateError::DefinitionProjectionDisagreement {
mob_id: mob_id.to_string(),
authority_epoch,
projection_revision,
kind,
},
source => SpecUpdateError::UpdateFailed {
mob_id: mob_id.to_string(),
source: Box::new(source),
},
}
}
pub async fn declare_spec_update(
storage: &MobStorage,
mob_id: &str,
declared: &MobDefinition,
expected_revision: u64,
) -> Result<SpecUpdateReceipt, SpecUpdateError> {
if declared.id.as_str() != mob_id {
return Err(SpecUpdateError::MobIdMismatch {
declared_for: mob_id.to_string(),
definition_names: declared.id.as_str().to_string(),
});
}
let declared = normalize_definition(declared);
let witness = observe_canonical_definition(storage, mob_id, Some(expected_revision)).await?;
let exact_replay = expected_revision
.checked_add(1)
.is_some_and(|next| next == witness.revision && witness.definition == declared);
if witness.revision != expected_revision && !exact_replay {
return Err(SpecUpdateError::RevisionMoved {
mob_id: mob_id.to_string(),
proposed_at: expected_revision,
found: witness.revision,
});
}
let diverged_fields =
crate::mob_composition_manifest::diverged_definition_fields(&witness.definition, &declared);
if diverged_fields.is_empty() && !exact_replay {
return Err(SpecUpdateError::AlreadyMatching {
mob_id: mob_id.to_string(),
revision: witness.revision,
});
}
let committed = storage
.update_definition(expected_revision, declared)
.await
.map_err(|error| map_update_error(mob_id, error))?;
Ok(SpecUpdateReceipt {
mob_id: mob_id.to_string(),
previous_revision: expected_revision,
committed_revision: committed.epoch,
projection_revision: committed.projection_revision,
event_cursor: committed.event_cursor,
declared_fields: diverged_fields,
})
}
pub async fn propose_spec_update(
storage: &MobStorage,
supplied: &MobDefinition,
) -> Result<SpecUpdateProposal, SpecUpdateError> {
let supplied = normalize_definition(supplied);
let mob_id = supplied.id.clone();
let witness = observe_canonical_definition(storage, mob_id.as_str(), None).await?;
let diverged_fields =
crate::mob_composition_manifest::diverged_definition_fields(&witness.definition, &supplied);
if diverged_fields.is_empty() {
return Err(SpecUpdateError::AlreadyMatching {
mob_id: mob_id.as_str().to_string(),
revision: witness.revision,
});
}
Ok(SpecUpdateProposal {
mob_id,
observed_revision: witness.revision,
declared: supplied,
diverged_fields,
})
}
pub async fn commit_spec_update(
storage: &MobStorage,
proposal: SpecUpdateProposal,
) -> Result<SpecUpdateReceipt, SpecUpdateError> {
let mob_id_text = proposal.mob_id.as_str().to_string();
let committed = storage
.update_definition(proposal.observed_revision, proposal.declared)
.await
.map_err(|error| map_update_error(&mob_id_text, error))?;
Ok(SpecUpdateReceipt {
mob_id: mob_id_text,
previous_revision: proposal.observed_revision,
committed_revision: committed.epoch,
projection_revision: committed.projection_revision,
event_cursor: committed.event_cursor,
declared_fields: proposal.diverged_fields,
})
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn definition(mob_id: &str, security_model: &str) -> MobDefinition {
MobDefinition::from_toml(&format!(
r#"
[mob]
id = "{mob_id}"
[profiles.general]
model = "gpt-5.5"
[profiles.security]
model = "{security_model}"
"#
))
.expect("definition parses")
}
#[test]
fn a_single_changed_model_pin_names_the_field_not_the_whole_profiles_table() {
let before = definition("ceremony-paths", "gpt-5.5");
let after = definition("ceremony-paths", "gpt-5.6");
let fields = crate::mob_composition_manifest::diverged_definition_fields(&before, &after);
assert_eq!(
fields.len(),
1,
"one changed pin must report one path, got {fields:?}"
);
let path = &fields[0];
assert!(
path.starts_with("profiles.security"),
"the path must name the profile that changed: {fields:?}"
);
assert_ne!(
path, "profiles",
"reporting the whole profiles table is the message that gets declared through unread"
);
assert!(
path.ends_with("model") || path.contains("model"),
"the path must reach the field that moved, not stop at the profile: {fields:?}"
);
}
#[test]
fn an_identical_definition_diverges_in_no_field() {
let a = definition("ceremony-same", "gpt-5.5");
let b = definition("ceremony-same", "gpt-5.5");
assert!(
crate::mob_composition_manifest::diverged_definition_fields(&a, &b).is_empty(),
"equal definitions must report no diverged fields"
);
}
#[tokio::test]
async fn a_declaration_naming_a_different_mob_than_its_definition_refuses() {
let temp = tempfile::tempdir().expect("temp dir");
let db = temp.path().join("mob.sqlite");
let storage = MobStorage::persistent(&db).expect("open storage");
let declared = definition("ceremony-real", "gpt-5.6");
let error = declare_spec_update(&storage, "ceremony-other", &declared, 1)
.await
.expect_err("a mismatched declaration must refuse");
match error {
SpecUpdateError::MobIdMismatch {
declared_for,
definition_names,
} => {
assert_eq!(declared_for, "ceremony-other");
assert_eq!(definition_names, "ceremony-real");
}
other => panic!("expected MobIdMismatch, got {other:?}"),
}
}
#[tokio::test]
async fn declaring_against_a_store_with_no_pinned_spec_says_so() {
let temp = tempfile::tempdir().expect("temp dir");
let db = temp.path().join("mob.sqlite");
let storage = MobStorage::persistent(&db).expect("stores open");
let declared = definition("ceremony-empty", "gpt-5.6");
let error = declare_spec_update(&storage, "ceremony-empty", &declared, 1)
.await
.expect_err("no pinned spec must not be silently declarable");
match error {
SpecUpdateError::NothingPinned { mob_id } => {
assert_eq!(mob_id, "ceremony-empty");
}
other => panic!("expected NothingPinned, got {other:?}"),
}
}
}