use std::path::{Path, PathBuf};
use meerkat_mob::MobDefinition;
use meerkat_mob::store::MobSpecStore;
#[derive(Debug)]
#[non_exhaustive]
pub enum SpecUpdateError {
StoreUnavailable { db: PathBuf, message: String },
NothingPinned { mob_id: String },
AlreadyMatching { mob_id: String, revision: u64 },
RevisionMoved {
mob_id: String,
proposed_at: u64,
found: u64,
},
WriteFailed { mob_id: String, message: String },
MobIdMismatch {
declared_for: String,
definition_names: String,
},
ManifestNotUpdated {
mob_id: String,
committed_revision: u64,
message: String,
},
}
impl std::fmt::Display for SpecUpdateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::StoreUnavailable { db, message } => write!(
f,
"the mob spec store at {} could not be opened, so the persisted spec cannot be \
read or declared: {message}",
db.display()
),
Self::NothingPinned { mob_id } => write!(
f,
"no persisted spec 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 persisted spec 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 persisted spec for mob {mob_id} moved from revision {proposed_at} to \
{found} while the update was being declared, so the declaration refers to a \
spec that is no longer current; re-read and declare again"
),
Self::WriteFailed { mob_id, message } => write!(
f,
"declaring the updated spec for mob {mob_id} failed, and the persisted spec is \
unchanged: {message}"
),
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"
),
Self::ManifestNotUpdated {
mob_id,
committed_revision,
message,
} => write!(
f,
"the persisted spec for mob {mob_id} advanced to revision \
{committed_revision}, but the composition manifest beside it could not be \
updated, so the two divergence checks now disagree and the next boot will be \
refused by the manifest instead: {message}"
),
}
}
}
impl std::error::Error for SpecUpdateError {}
#[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 declared_fields: Vec<String>,
}
fn open_spec_store(db: &Path) -> Result<impl MobSpecStore, SpecUpdateError> {
meerkat_mob::SqliteMobStores::open(db)
.map(|stores| stores.spec_store())
.map_err(|error| SpecUpdateError::StoreUnavailable {
db: db.to_path_buf(),
message: error.to_string(),
})
}
pub async fn declare_spec_update(
mob_storage_db: &Path,
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 proposal = propose_spec_update(mob_storage_db, declared).await?;
if proposal.observed_revision != expected_revision {
return Err(SpecUpdateError::RevisionMoved {
mob_id: mob_id.to_string(),
proposed_at: expected_revision,
found: proposal.observed_revision,
});
}
commit_spec_update(mob_storage_db, proposal).await
}
pub async fn propose_spec_update(
mob_storage_db: &Path,
supplied: &MobDefinition,
) -> Result<SpecUpdateProposal, SpecUpdateError> {
let specs = open_spec_store(mob_storage_db)?;
let mob_id = supplied.id.clone();
let found =
specs
.get_spec(&mob_id)
.await
.map_err(|error| SpecUpdateError::StoreUnavailable {
db: mob_storage_db.to_path_buf(),
message: error.to_string(),
})?;
let Some((stored, observed_revision)) = found else {
return Err(SpecUpdateError::NothingPinned {
mob_id: mob_id.as_str().to_string(),
});
};
let diverged_fields =
crate::mob_composition_manifest::diverged_definition_fields(&stored, supplied);
if diverged_fields.is_empty() {
return Err(SpecUpdateError::AlreadyMatching {
mob_id: mob_id.as_str().to_string(),
revision: observed_revision,
});
}
Ok(SpecUpdateProposal {
mob_id,
observed_revision,
declared: supplied.clone(),
diverged_fields,
})
}
pub async fn commit_spec_update(
mob_storage_db: &Path,
proposal: SpecUpdateProposal,
) -> Result<SpecUpdateReceipt, SpecUpdateError> {
let specs = open_spec_store(mob_storage_db)?;
let mob_id_text = proposal.mob_id.as_str().to_string();
let committed_revision = specs
.put_spec(
&proposal.mob_id,
&proposal.declared,
Some(proposal.observed_revision),
)
.await
.map_err(|error| {
let message = error.to_string();
SpecUpdateError::WriteFailed {
mob_id: mob_id_text.clone(),
message,
}
})?;
crate::mob_composition_manifest::record_declared_update(mob_storage_db, &proposal.declared)
.map_err(|error| SpecUpdateError::ManifestNotUpdated {
mob_id: mob_id_text.clone(),
committed_revision,
message: error.to_string(),
})?;
Ok(SpecUpdateReceipt {
mob_id: mob_id_text,
previous_revision: proposal.observed_revision,
committed_revision,
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 declared = definition("ceremony-real", "gpt-5.6");
let error = declare_spec_update(&db, "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 _stores = meerkat_mob::SqliteMobStores::open(&db).expect("stores open");
let declared = definition("ceremony-empty", "gpt-5.6");
let error = declare_spec_update(&db, "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:?}"),
}
}
}