use crate::meta::Value;
pub const CONFIRMED: &str = "confirmed";
pub const GENERATED: &str = "generated";
const AGENT_PREFIX: &str = "agent:";
const PROCESS_PREFIX: &str = "process:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Actor {
Person(String),
Agent(String),
Process(String),
}
impl Actor {
pub fn parse(raw: &str) -> Actor {
if let Some(id) = raw.strip_prefix(AGENT_PREFIX) {
Actor::Agent(id.to_string())
} else if let Some(id) = raw.strip_prefix(PROCESS_PREFIX) {
Actor::Process(id.to_string())
} else {
Actor::Person(raw.to_string())
}
}
pub fn is_person(&self) -> bool {
matches!(self, Actor::Person(_))
}
pub fn id(&self) -> &str {
match self {
Actor::Person(id) | Actor::Agent(id) | Actor::Process(id) => id,
}
}
}
impl std::fmt::Display for Actor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Actor::Person(id) => f.write_str(id),
Actor::Agent(id) => write!(f, "{AGENT_PREFIX}{id}"),
Actor::Process(id) => write!(f, "{PROCESS_PREFIX}{id}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Generated {
pub by: String,
pub at: String,
}
impl Generated {
pub fn read(meta: &Value) -> Option<Generated> {
let map = meta.get(GENERATED)?.as_mapping()?;
Some(Generated {
by: map.get("by")?.as_str()?.to_string(),
at: map.get("at")?.as_str()?.to_string(),
})
}
pub fn actor(&self) -> Actor {
Actor::parse(&self.by)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Confirmation {
pub by: String,
pub at: String,
pub of: Option<String>,
}
impl Confirmation {
pub fn actor(&self) -> Actor {
Actor::parse(&self.by)
}
pub fn read_all(meta: &Value) -> Vec<Confirmation> {
let Some(entries) = meta.get(CONFIRMED).and_then(Value::as_sequence) else {
return Vec::new();
};
entries
.iter()
.filter_map(|entry| {
let map = entry.as_mapping()?;
Some(Confirmation {
by: map.get("by")?.as_str()?.to_string(),
at: map.get("at")?.as_str()?.to_string(),
of: map.get("of").and_then(Value::as_str).map(str::to_string),
})
})
.collect()
}
pub fn is_stale(&self, updated: Option<&str>, content_hash: Option<&str>) -> bool {
if let Some(updated) = updated
&& updated > self.at.as_str()
{
return true;
}
match (&self.of, content_hash) {
(Some(of), Some(hash)) => of != hash,
(Some(_), None) => true,
(None, _) => false,
}
}
pub fn to_value(&self) -> Value {
let mut map = crate::meta::Mapping::new();
map.insert("by".into(), Value::String(self.by.clone()));
map.insert("at".into(), Value::String(self.at.clone()));
if let Some(of) = &self.of {
map.insert("of".into(), Value::String(of.clone()));
}
Value::Mapping(map)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Tier {
Unconfirmed,
MachineConfirmed,
HumanConfirmed,
}
impl Tier {
pub fn as_str(&self) -> &'static str {
match self {
Tier::Unconfirmed => "unconfirmed",
Tier::MachineConfirmed => "machine-confirmed",
Tier::HumanConfirmed => "human-confirmed",
}
}
}
impl std::fmt::Display for Tier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Confirmations {
pub live: Vec<Confirmation>,
pub stale: Vec<Confirmation>,
}
impl Confirmations {
pub fn read(meta: &Value, updated_field: Option<&str>) -> Confirmations {
let updated = updated_field
.and_then(|field| meta.get(field))
.and_then(Value::as_str);
let hash = meta.get("content_hash").and_then(Value::as_str);
let mut out = Confirmations::default();
for entry in Confirmation::read_all(meta) {
if entry.is_stale(updated, hash) {
out.stale.push(entry);
} else {
out.live.push(entry);
}
}
out
}
pub fn tier(&self) -> Tier {
if self.live.is_empty() {
Tier::Unconfirmed
} else if self.live.iter().any(|c| c.actor().is_person()) {
Tier::HumanConfirmed
} else {
Tier::MachineConfirmed
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(yaml: &str) -> Value {
Value::Mapping(crate::meta::parse_mapping(yaml, fig::Format::Yaml).unwrap())
}
#[test]
fn a_bare_actor_is_a_person_and_a_prefix_says_otherwise() {
assert!(Actor::parse("amh").is_person());
assert!(Actor::parse("Adam Harris").is_person());
assert_eq!(
Actor::parse("agent:claude-opus-5"),
Actor::Agent("claude-opus-5".into())
);
assert_eq!(Actor::parse("process:prov"), Actor::Process("prov".into()));
assert!(Actor::parse("agnet:x").is_person());
assert_eq!(Actor::parse("agent:x").to_string(), "agent:x");
}
#[test]
fn the_list_is_read_for_what_it_can_say() {
let m = meta(
"confirmed:\n- by: amh\n at: 2026-09-11T09:20:00.000000Z\n- by: nobody\n- not: a confirmation\n- 3\n",
);
let all = Confirmation::read_all(&m);
assert_eq!(all.len(), 1);
assert_eq!(all[0].by, "amh");
assert!(all[0].of.is_none());
assert!(Confirmation::read_all(&meta("confirmed: yes\n")).is_empty());
assert!(Confirmation::read_all(&meta("title: x\n")).is_empty());
}
#[test]
fn a_confirmation_is_stale_once_the_stamp_is_newer() {
let c = Confirmation {
by: "amh".into(),
at: "2026-09-11T09:20:00.000000Z".into(),
of: None,
};
assert!(!c.is_stale(None, None));
assert!(!c.is_stale(Some("2026-09-11T09:20:00.000000Z"), None));
assert!(!c.is_stale(Some("2026-09-11T09:19:59.999999Z"), None));
assert!(c.is_stale(Some("2026-09-11T09:20:00.000001Z"), None));
let bound = Confirmation {
of: Some("sha256:aa".into()),
..c.clone()
};
assert!(!bound.is_stale(None, Some("sha256:aa")));
assert!(bound.is_stale(None, Some("sha256:bb")));
assert!(bound.is_stale(None, None), "the digest it named is gone");
}
#[test]
fn the_tier_is_derived_from_live_entries_only() {
let m = meta(
"updated: 2026-09-11T10:00:00.000000Z\n\
confirmed:\n\
- by: amh\n at: 2026-09-11T09:00:00.000000Z\n\
- by: agent:claude-opus-5\n at: 2026-09-11T11:00:00.000000Z\n",
);
let c = Confirmations::read(&m, Some("updated"));
assert_eq!(c.stale.len(), 1);
assert_eq!(c.live.len(), 1);
assert_eq!(c.tier(), Tier::MachineConfirmed);
let c = Confirmations::read(&m, None);
assert_eq!(c.stale.len(), 0);
assert_eq!(c.tier(), Tier::HumanConfirmed);
assert_eq!(
Confirmations::read(&meta("title: x\n"), Some("updated")).tier(),
Tier::Unconfirmed
);
}
#[test]
fn generated_is_read_when_well_formed() {
let m = meta("generated:\n by: process:prov\n at: 2026-09-11T09:00:00.000000Z\n");
let g = Generated::read(&m).unwrap();
assert_eq!(g.actor(), Actor::Process("prov".into()));
assert!(Generated::read(&meta("generated: prov\n")).is_none());
}
}