use std::path::{Path, PathBuf};
use meerkat_mob::MobDefinition;
use serde::{Deserialize, Serialize};
pub const MANIFEST_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MobCompositionManifest {
pub manifest_version: u32,
pub created_by_mobkit: String,
pub definition: MobDefinition,
#[serde(default)]
pub created_by_authority: CompositionAuthority,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub legacy_synthesized_profile_normalization: Option<bool>,
}
pub fn manifest_path(mob_storage_path: &Path) -> PathBuf {
let mut file_name = mob_storage_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "mob".to_string());
file_name.push_str(".composition.json");
mob_storage_path.with_file_name(file_name)
}
#[derive(Debug)]
pub enum MobCompositionProvenanceError {
Missing { manifest: PathBuf, storage: PathBuf },
Unreadable { manifest: PathBuf, message: String },
Malformed { manifest: PathBuf, message: String },
UnsupportedVersion {
manifest: PathBuf,
found: u32,
supported: u32,
},
Divergent {
manifest: PathBuf,
fields: Vec<String>,
},
NotRecorded { manifest: PathBuf, message: String },
CreatedByRehearsal { manifest: PathBuf, storage: PathBuf },
CandidateDivergent { fields: Vec<String> },
UnprovenStorage,
}
impl std::fmt::Display for MobCompositionProvenanceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Missing { manifest, storage } => write!(
f,
"the mob storage at {} already holds events but has no composition \
provenance at {}, so this build cannot tell whether resuming it would \
boot the composition you supplied or an older one (refusing before the \
mob actuates; if the storage is known-good and matches your current \
config, remove the storage to recreate it, or restore the manifest \
written beside it)",
storage.display(),
manifest.display()
),
Self::Unreadable { manifest, message } => write!(
f,
"failed to read the mob composition provenance at {}: {} (resuming \
blind could boot a stale composition that looks healthy; fix the file \
permissions or restore the file)",
manifest.display(),
message
),
Self::Malformed { manifest, message } => write!(
f,
"the mob composition provenance at {} is not a readable manifest: {} \
(resuming blind could boot a stale composition that looks healthy; \
restore the file, or remove the mob storage to recreate it)",
manifest.display(),
message
),
Self::UnsupportedVersion {
manifest,
found,
supported,
} => write!(
f,
"the mob composition provenance at {} is schema version {found}, but \
this build understands version {supported} and cannot judge whether \
the stored composition matches the one supplied (upgrade the gateway \
to a build that understands version {found}, or remove the mob storage \
to recreate it)",
manifest.display()
),
Self::Divergent { manifest, fields } => write!(
f,
"the supplied mob definition diverges from the canonical definition in this \
storage, in: {} (booting would silently run the event-log definition rather \
than the supplied composition; revert the change or advance it through the \
declared definition-update ceremony; storage provenance is recorded at {})",
fields.join(", "),
manifest.display()
),
Self::UnprovenStorage => write!(
f,
"a mob storage supplied to bootstrap already holds events but nothing \
was declared about what that storage is, so this build cannot verify \
that resuming it would boot the composition supplied rather than an \
older one; if it is durable, compose it through \
mob_composition_manifest::persistent_mob_storage and pass the returned \
provenance to MobBootstrapSpec::with_mob_storage_provenance, and if it \
is in-process only, declare that with \
MobBootstrapSpec::with_declared_ephemeral_mob_storage"
),
Self::NotRecorded { manifest, message } => write!(
f,
"failed to record mob composition provenance at {}: {} (without it the \
next restart cannot prove the stored composition matches your config, \
so refusing now rather than leaving an unjudgeable storage path behind)",
manifest.display(),
message
),
Self::CandidateDivergent { fields } => write!(
f,
"this candidate launch supplied a mob definition that differs from the one \
stored in its mob storage, in: {}. A candidate resume cannot apply the \
supplied definition, so booting would silently run the stored composition \
(for example without a tool deny the supplied config adds) while presenting \
yours. Make the candidate config match the stored definition, or run the \
candidate on its own storage path, or set \
runtime_options.mob_composition.candidate_definition = \"stored\" to boot \
the stored definition knowingly (logged and reported as degraded health)",
fields.join(", ")
),
Self::CreatedByRehearsal { manifest, storage } => write!(
f,
"the mob storage at {} was created by a launch that declared it does \
not speak for the durable composition (a candidate or certification \
pass), recorded at {}, so the composition you supplied can never take \
effect on it: a resume cannot apply a new definition, and the event \
log's MobCreated definition is the rehearsal one. Create the durable \
store from an authoritative launch and run the candidate against a \
separate rehearsal path",
storage.display(),
manifest.display()
),
}
}
}
impl std::error::Error for MobCompositionProvenanceError {}
impl MobCompositionProvenanceError {
pub fn refusal(&self) -> CompositionProvenanceRefusal {
let mut refusal = CompositionProvenanceRefusal::of(self.refusal_kind());
match self {
Self::Missing { manifest, storage }
| Self::CreatedByRehearsal { manifest, storage } => {
refusal.manifest = Some(manifest.display().to_string());
refusal.storage = Some(storage.display().to_string());
}
Self::Unreadable { manifest, .. }
| Self::Malformed { manifest, .. }
| Self::NotRecorded { manifest, .. } => {
refusal.manifest = Some(manifest.display().to_string());
}
Self::UnsupportedVersion {
manifest,
found,
supported,
} => {
refusal.manifest = Some(manifest.display().to_string());
refusal.found_version = Some(*found);
refusal.supported_version = Some(*supported);
}
Self::Divergent { manifest, fields } => {
refusal.manifest = Some(manifest.display().to_string());
refusal.fields.clone_from(fields);
}
Self::CandidateDivergent { fields } => refusal.fields.clone_from(fields),
Self::UnprovenStorage => {}
}
refusal
}
pub const fn refusal_kind(&self) -> CompositionProvenanceRefusalKind {
match self {
Self::Missing { .. } => CompositionProvenanceRefusalKind::Missing,
Self::Unreadable { .. } => CompositionProvenanceRefusalKind::Unreadable,
Self::Malformed { .. } => CompositionProvenanceRefusalKind::Malformed,
Self::UnsupportedVersion { .. } => CompositionProvenanceRefusalKind::UnsupportedVersion,
Self::Divergent { .. } => CompositionProvenanceRefusalKind::Divergent,
Self::NotRecorded { .. } => CompositionProvenanceRefusalKind::NotRecorded,
Self::CreatedByRehearsal { .. } => CompositionProvenanceRefusalKind::CreatedByRehearsal,
Self::CandidateDivergent { .. } => CompositionProvenanceRefusalKind::CandidateDivergent,
Self::UnprovenStorage => CompositionProvenanceRefusalKind::UnprovenStorage,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CompositionProvenanceRefusalKind {
Missing,
Unreadable,
Malformed,
UnsupportedVersion,
Divergent,
NotRecorded,
CreatedByRehearsal,
CandidateDivergent,
UnprovenStorage,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CompositionProvenanceRefusal {
pub kind: CompositionProvenanceRefusalKind,
#[serde(default)]
pub fields: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub found_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supported_version: Option<u32>,
}
impl CompositionProvenanceRefusal {
const fn of(kind: CompositionProvenanceRefusalKind) -> Self {
Self {
kind,
fields: Vec::new(),
manifest: None,
storage: None,
found_version: None,
supported_version: None,
}
}
}
pub(crate) fn record_on_create(
mob_storage_path: &Path,
definition: &MobDefinition,
created_by_authority: CompositionAuthority,
) -> Result<(), MobCompositionProvenanceError> {
let manifest = manifest_path(mob_storage_path);
let record = MobCompositionManifest {
manifest_version: MANIFEST_VERSION,
created_by_mobkit: env!("CARGO_PKG_VERSION").to_string(),
definition: definition.clone(),
created_by_authority,
legacy_synthesized_profile_normalization: Some(false),
};
let bytes = serde_json::to_vec_pretty(&record).map_err(|err| {
MobCompositionProvenanceError::NotRecorded {
manifest: manifest.clone(),
message: err.to_string(),
}
})?;
if let Some(parent) = manifest.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
MobCompositionProvenanceError::NotRecorded {
manifest: manifest.clone(),
message: err.to_string(),
}
})?;
}
std::fs::write(&manifest, bytes).map_err(|err| MobCompositionProvenanceError::NotRecorded {
manifest: manifest.clone(),
message: err.to_string(),
})
}
pub(crate) enum LegacyDefinitionResume {
Recorded,
}
pub(crate) fn verify_legacy_synthesized_definition_before_resume(
mob_storage_path: &Path,
authority_epoch: u64,
authoritative: &MobDefinition,
supplied: &MobDefinition,
legacy_supplied: &MobDefinition,
) -> Result<LegacyDefinitionResume, MobCompositionProvenanceError> {
let manifest = manifest_path(mob_storage_path);
if authority_epoch != 1 {
return verify_authoritative_definition(&manifest, authoritative, supplied)
.map(|()| LegacyDefinitionResume::Recorded);
}
let bytes = match std::fs::read(&manifest) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return verify_authoritative_definition(&manifest, authoritative, supplied)
.map(|()| LegacyDefinitionResume::Recorded);
}
Err(err) => {
return Err(MobCompositionProvenanceError::Unreadable {
manifest,
message: err.to_string(),
});
}
};
let version = serde_json::from_slice::<ManifestVersionProbe>(&bytes)
.map(|probe| probe.manifest_version)
.map_err(|err| MobCompositionProvenanceError::Malformed {
manifest: manifest.clone(),
message: err.to_string(),
})?;
if version != MANIFEST_VERSION {
return Err(MobCompositionProvenanceError::UnsupportedVersion {
manifest,
found: version,
supported: MANIFEST_VERSION,
});
}
let record = serde_json::from_slice::<MobCompositionManifest>(&bytes).map_err(|err| {
MobCompositionProvenanceError::Malformed {
manifest: manifest.clone(),
message: err.to_string(),
}
})?;
if !record.created_by_authority.speaks_for_composition() {
return Err(MobCompositionProvenanceError::CreatedByRehearsal {
manifest,
storage: mob_storage_path.to_path_buf(),
});
}
let released_writer = match record.legacy_synthesized_profile_normalization {
Some(value) => value,
None => is_synthesizing_release(&record.created_by_mobkit),
};
if !released_writer {
return verify_authoritative_definition(&manifest, authoritative, supplied)
.map(|()| LegacyDefinitionResume::Recorded);
}
verify_authoritative_definition(&manifest, authoritative, legacy_supplied)?;
Ok(LegacyDefinitionResume::Recorded)
}
fn is_synthesizing_release(version: &str) -> bool {
let mut parts = version.split('.');
let Some(major) = parts.next().and_then(|part| part.parse::<u64>().ok()) else {
return false;
};
let Some(minor) = parts.next().and_then(|part| part.parse::<u64>().ok()) else {
return false;
};
let Some(patch) = parts.next().and_then(|part| part.parse::<u64>().ok()) else {
return false;
};
parts.next().is_none() && major == 0 && minor == 8 && (9..29).contains(&patch)
}
pub fn verify_before_resume(
mob_storage_path: &Path,
authoritative: &MobDefinition,
supplied: &MobDefinition,
) -> Result<(), MobCompositionProvenanceError> {
let manifest = manifest_path(mob_storage_path);
let bytes = match std::fs::read(&manifest) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
verify_authoritative_definition(&manifest, authoritative, supplied)?;
return Err(MobCompositionProvenanceError::Missing {
manifest,
storage: mob_storage_path.to_path_buf(),
});
}
Err(err) => {
return Err(MobCompositionProvenanceError::Unreadable {
manifest,
message: err.to_string(),
});
}
};
let version = serde_json::from_slice::<ManifestVersionProbe>(&bytes)
.map(|probe| probe.manifest_version)
.map_err(|err| MobCompositionProvenanceError::Malformed {
manifest: manifest.clone(),
message: err.to_string(),
})?;
if version != MANIFEST_VERSION {
return Err(MobCompositionProvenanceError::UnsupportedVersion {
manifest,
found: version,
supported: MANIFEST_VERSION,
});
}
let record = serde_json::from_slice::<MobCompositionManifest>(&bytes).map_err(|err| {
MobCompositionProvenanceError::Malformed {
manifest: manifest.clone(),
message: err.to_string(),
}
})?;
if !record.created_by_authority.speaks_for_composition() {
return Err(MobCompositionProvenanceError::CreatedByRehearsal {
manifest,
storage: mob_storage_path.to_path_buf(),
});
}
verify_authoritative_definition(&manifest, authoritative, supplied)
}
#[derive(Deserialize)]
struct ManifestVersionProbe {
manifest_version: u32,
}
fn verify_authoritative_definition(
manifest: &Path,
authoritative: &MobDefinition,
supplied: &MobDefinition,
) -> Result<(), MobCompositionProvenanceError> {
let fields = diverged_definition_fields(authoritative, supplied);
if fields.is_empty() {
Ok(())
} else {
Err(MobCompositionProvenanceError::Divergent {
manifest: manifest.to_path_buf(),
fields,
})
}
}
pub(crate) fn diverged_definition_fields(
recorded: &MobDefinition,
supplied: &MobDefinition,
) -> Vec<String> {
let (Ok(recorded_value), Ok(supplied_value)) = (
serde_json::to_value(recorded),
serde_json::to_value(supplied),
) else {
return if recorded == supplied {
Vec::new()
} else {
vec!["<whole definition>".to_string()]
};
};
let mut paths = Vec::new();
collect_diverged_paths("", &recorded_value, &supplied_value, &mut paths);
if paths.is_empty() && recorded_value != supplied_value {
return vec!["<whole definition>".to_string()];
}
paths.sort_unstable();
paths.dedup();
paths
}
fn collect_diverged_paths(
prefix: &str,
recorded: &serde_json::Value,
supplied: &serde_json::Value,
out: &mut Vec<String>,
) {
if recorded == supplied {
return;
}
match (recorded.as_object(), supplied.as_object()) {
(Some(recorded_map), Some(supplied_map)) => {
let mut keys: Vec<&String> = recorded_map.keys().chain(supplied_map.keys()).collect();
keys.sort_unstable();
keys.dedup();
for key in keys {
let recorded_child = recorded_map.get(key);
let supplied_child = supplied_map.get(key);
if recorded_child == supplied_child {
continue;
}
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match (recorded_child, supplied_child) {
(Some(left), Some(right)) => {
collect_diverged_paths(&path, left, right, out);
}
_ => out.push(path),
}
}
}
_ => out.push(if prefix.is_empty() {
"<whole definition>".to_string()
} else {
prefix.to_string()
}),
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompositionAuthority {
#[default]
Authoritative,
NonAuthoritative,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CandidateDefinition {
#[default]
RequireMatch,
Stored,
}
pub fn verify_candidate_resume(
fields: Vec<String>,
policy: CandidateDefinition,
) -> Result<Vec<String>, MobCompositionProvenanceError> {
if fields.is_empty() || policy == CandidateDefinition::Stored {
Ok(fields)
} else {
Err(MobCompositionProvenanceError::CandidateDivergent { fields })
}
}
impl CompositionAuthority {
#[must_use]
pub fn speaks_for_composition(self) -> bool {
matches!(self, Self::Authoritative)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MobStorageProvenance(Provenance);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
enum Provenance {
#[default]
Unspecified,
DeclaredEphemeral,
Persistent { path: PathBuf },
}
impl MobStorageProvenance {
pub fn declared_ephemeral() -> Self {
Self(Provenance::DeclaredEphemeral)
}
fn persistent(path: PathBuf) -> Self {
Self(Provenance::Persistent { path })
}
pub fn persistent_path(&self) -> Option<&Path> {
match &self.0 {
Provenance::Unspecified | Provenance::DeclaredEphemeral => None,
Provenance::Persistent { path } => Some(path),
}
}
pub fn permits_unverified_resume(&self) -> bool {
!matches!(self.0, Provenance::Unspecified)
}
}
pub fn persistent_mob_storage(
path: PathBuf,
) -> Result<(meerkat_mob::MobStorage, MobStorageProvenance), meerkat_mob::MobError> {
let storage = meerkat_mob::MobStorage::persistent(&path)?;
Ok((storage, MobStorageProvenance::persistent(path)))
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
fn wire(error: &MobCompositionProvenanceError) -> serde_json::Value {
serde_json::to_value(error.refusal()).expect("refusal serializes")
}
#[test]
fn every_refusal_has_a_stable_kind_and_its_facts_on_the_wire() {
let manifest = PathBuf::from("/state/mob.sqlite3.composition.json");
let storage = PathBuf::from("/state/mob.sqlite3");
let cases = [
(
MobCompositionProvenanceError::Missing {
manifest: manifest.clone(),
storage: storage.clone(),
},
json!({
"kind": "missing",
"fields": [],
"manifest": "/state/mob.sqlite3.composition.json",
"storage": "/state/mob.sqlite3"
}),
),
(
MobCompositionProvenanceError::Unreadable {
manifest: manifest.clone(),
message: "permission denied".to_string(),
},
json!({
"kind": "unreadable",
"fields": [],
"manifest": "/state/mob.sqlite3.composition.json"
}),
),
(
MobCompositionProvenanceError::Malformed {
manifest: manifest.clone(),
message: "expected value".to_string(),
},
json!({
"kind": "malformed",
"fields": [],
"manifest": "/state/mob.sqlite3.composition.json"
}),
),
(
MobCompositionProvenanceError::UnsupportedVersion {
manifest: manifest.clone(),
found: 9,
supported: 1,
},
json!({
"kind": "unsupported_version",
"fields": [],
"manifest": "/state/mob.sqlite3.composition.json",
"found_version": 9,
"supported_version": 1
}),
),
(
MobCompositionProvenanceError::Divergent {
manifest: manifest.clone(),
fields: vec!["profiles.lead.model".to_string()],
},
json!({
"kind": "divergent",
"fields": ["profiles.lead.model"],
"manifest": "/state/mob.sqlite3.composition.json"
}),
),
(
MobCompositionProvenanceError::NotRecorded {
manifest: manifest.clone(),
message: "read-only file system".to_string(),
},
json!({
"kind": "not_recorded",
"fields": [],
"manifest": "/state/mob.sqlite3.composition.json"
}),
),
(
MobCompositionProvenanceError::CreatedByRehearsal { manifest, storage },
json!({
"kind": "created_by_rehearsal",
"fields": [],
"manifest": "/state/mob.sqlite3.composition.json",
"storage": "/state/mob.sqlite3"
}),
),
(
MobCompositionProvenanceError::CandidateDivergent {
fields: vec![
"profiles.lead.tools.deny".to_string(),
"profiles.worker".to_string(),
],
},
json!({
"kind": "candidate_divergent",
"fields": ["profiles.lead.tools.deny", "profiles.worker"]
}),
),
(
MobCompositionProvenanceError::UnprovenStorage,
json!({ "kind": "unproven_storage", "fields": [] }),
),
];
for (error, expected) in cases {
assert_eq!(wire(&error), expected, "{error:?}");
let round_trip: CompositionProvenanceRefusal =
serde_json::from_value(expected).expect("the wire form reads back");
assert_eq!(round_trip, error.refusal());
}
}
#[test]
fn a_bootstrap_failure_exposes_only_a_composition_refusal() {
use crate::mob_handle_runtime::MobRuntimeError;
use crate::unified_runtime::UnifiedRuntimeBootstrapError;
let refused = UnifiedRuntimeBootstrapError::Mob(MobRuntimeError::CompositionProvenance(
MobCompositionProvenanceError::CandidateDivergent {
fields: vec!["profiles.lead.tools.deny".to_string()],
},
));
assert_eq!(
refused
.composition_provenance()
.map(MobCompositionProvenanceError::refusal_kind),
Some(CompositionProvenanceRefusalKind::CandidateDivergent)
);
let rolled_back = UnifiedRuntimeBootstrapError::ModuleStartupRollbackFailed {
startup_error: Box::new(refused),
rollback_error: MobRuntimeError::InvalidInput("rollback failed"),
};
assert_eq!(
rolled_back
.composition_provenance()
.map(MobCompositionProvenanceError::refusal_kind),
Some(CompositionProvenanceRefusalKind::CandidateDivergent)
);
for other in [
UnifiedRuntimeBootstrapError::Mob(MobRuntimeError::InvalidConfig("bad".to_string())),
UnifiedRuntimeBootstrapError::PreSpawnHook("hook".to_string()),
UnifiedRuntimeBootstrapError::ModuleStartupThreadPanicked,
] {
assert!(other.composition_provenance().is_none(), "{other}");
}
}
}