use crate::DecisionHit;
use edda_ledger::sync::DEFAULT_MIRROR_STALE_HOURS;
use edda_ledger::Ledger;
use serde::Serialize;
use std::path::Path;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
const MIRROR_INDEX: &str = "docs/decisions/INDEX.md";
const MIRROR_STAMP_EVENT_TYPE: &str = "decision_import";
#[derive(Debug, Clone, Serialize)]
pub struct MirrorOrigin {
pub machine: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exported_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub age_hours: Option<f64>,
pub is_stale: bool,
pub threshold_hours: i64,
}
pub fn origins_for_hits(
ledger: &Ledger,
hits: &[DecisionHit],
repo_root: Option<&Path>,
) -> Vec<Option<MirrorOrigin>> {
if hits.is_empty() || !may_hold_mirror_origins(ledger) {
return vec![None; hits.len()];
}
let now = OffsetDateTime::now_utc();
let live = repo_root.and_then(live_mirror);
hits.iter()
.map(|h| {
ledger.get_event(&h.event_id).ok().flatten().and_then(|e| {
origin_from_payload(
&e.payload,
now,
live.as_ref().map(|(s, m)| (s.as_str(), m.as_str())),
)
})
})
.collect()
}
fn live_mirror(repo_root: &Path) -> Option<(String, String)> {
let text = std::fs::read_to_string(repo_root.join(MIRROR_INDEX)).ok()?;
let field = |name: &str| {
text.lines()
.find_map(|l| l.strip_prefix(name))
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
};
Some((
field("- **Exported at**:")?,
field("- **Exporting machine**:")?,
))
}
fn may_hold_mirror_origins(ledger: &Ledger) -> bool {
match ledger.iter_events_by_type(MIRROR_STAMP_EVENT_TYPE) {
Ok(imports) => !imports.is_empty(),
Err(_) => true,
}
}
pub fn annotate_hits(hits: &mut [DecisionHit], origins: &[Option<MirrorOrigin>]) {
for (hit, origin) in hits.iter_mut().zip(origins.iter()) {
hit.mirror = origin.clone();
}
}
fn origin_from_payload(
payload: &serde_json::Value,
now: OffsetDateTime,
live: Option<(&str, &str)>,
) -> Option<MirrorOrigin> {
let mirror = payload.get("mirror")?.as_object()?;
let machine = mirror
.get("machine")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let frozen = mirror
.get("exported_at")
.and_then(|v| v.as_str())
.map(str::to_string);
let exported_at = live
.filter(|(_, live_machine)| *live_machine == machine)
.map(|(stamp, _)| stamp.to_string())
.or(frozen);
let age_hours = exported_at.as_deref().and_then(|ts| {
OffsetDateTime::parse(ts, &Rfc3339)
.ok()
.map(|t| (now - t).as_seconds_f64() / 3600.0)
});
Some(MirrorOrigin {
machine,
exported_at,
is_stale: match age_hours {
Some(h) => h >= DEFAULT_MIRROR_STALE_HOURS as f64,
None => true,
},
age_hours,
threshold_hours: DEFAULT_MIRROR_STALE_HOURS,
})
}
pub fn stale_hint(origin: &MirrorOrigin) -> String {
let age = match origin.age_hours {
Some(h) => format!("{h:.1}h old"),
None => "stamp missing or unreadable".to_string(),
};
format!(
"⚠ stale-mirror hint: from {} — exported {} ({age}, threshold {}h). Re-export on the source machine and pull.",
origin.machine,
origin.exported_at.as_deref().unwrap_or("?"),
origin.threshold_hours,
)
}
#[cfg(test)]
mod tests {
use super::*;
use edda_core::event::{finalize_event, new_note_event};
use edda_core::Event;
use edda_ledger::ledger::{init_branches_json, init_head, init_workspace};
use edda_ledger::paths::EddaPaths;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn setup() -> Ledger {
let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let tmp = std::env::temp_dir().join(format!("edda_ask_mirror_{}_{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let paths = EddaPaths::discover(&tmp);
init_workspace(&paths).unwrap();
init_head(&paths, "main").unwrap();
init_branches_json(&paths, "main").unwrap();
Ledger::open(&tmp).unwrap()
}
fn append(ledger: &Ledger, event: &Event) -> String {
let mut chained = event.clone();
chained.parent_hash = ledger.last_event_hash().unwrap();
finalize_event(&mut chained).unwrap();
ledger.append_event(&chained).unwrap();
chained.event_id
}
fn note(text: &str) -> Event {
new_note_event("main", None, "system", text, &[]).unwrap()
}
fn mirror_import(machine: &str, exported_at: &str) -> Event {
let mut e = note("[sync] imported db.engine=sqlite");
e.event_type = "decision_import".to_string();
e.payload["mirror"] = serde_json::json!({
"machine": machine,
"exported_at": exported_at,
});
e
}
fn hit(event_id: &str) -> DecisionHit {
DecisionHit {
event_id: event_id.to_string(),
key: "db.engine".to_string(),
value: "sqlite".to_string(),
reason: String::new(),
domain: "db".to_string(),
branch: "main".to_string(),
ts: "2026-09-07T00:00:00Z".to_string(),
is_active: true,
governance: crate::DecisionGovernance::default(),
tags: Vec::new(),
village_id: None,
staleness: None,
mirror: None,
}
}
fn at(ts: &str) -> OffsetDateTime {
OffsetDateTime::parse(ts, &Rfc3339).unwrap()
}
fn payload(exported_at: Option<&str>) -> serde_json::Value {
match exported_at {
Some(ts) => serde_json::json!({"mirror": {"machine": "4090", "exported_at": ts}}),
None => serde_json::json!({"mirror": {"machine": "4090", "exported_at": null}}),
}
}
#[test]
fn a_decision_that_never_rode_a_mirror_has_no_origin() {
let local = serde_json::json!({"role": "system", "decision": {"key": "db.engine"}});
assert!(origin_from_payload(&local, at("2026-09-07T00:00:00Z"), None).is_none());
}
#[test]
fn a_fresh_mirror_is_not_marked() {
let o = origin_from_payload(
&payload(Some("2026-09-07T00:00:00Z")),
at("2026-09-07T06:00:00Z"),
None,
)
.expect("mirror payload");
assert_eq!(o.machine, "4090");
assert!(!o.is_stale, "6h < 24h threshold");
assert!((o.age_hours.expect("parsed stamp") - 6.0).abs() < 0.001);
}
#[test]
fn a_mirror_past_the_threshold_is_marked_stale() {
let o = origin_from_payload(
&payload(Some("2026-09-01T00:00:00Z")),
at("2026-09-07T00:00:00Z"),
None,
)
.expect("mirror payload");
assert!(o.is_stale, "144h >= 24h threshold");
assert!(stale_hint(&o).contains("4090"));
assert!(stale_hint(&o).contains("144.0h old"));
}
#[test]
fn exactly_at_the_threshold_is_stale() {
let o = origin_from_payload(
&payload(Some("2026-09-06T00:00:00Z")),
at("2026-09-07T00:00:00Z"),
None,
)
.expect("mirror payload");
assert!(o.is_stale);
}
#[test]
fn a_fresh_checkout_clears_a_marker_the_frozen_stamp_would_hold_forever() {
let frozen_and_ancient = payload(Some("2026-08-01T00:00:00Z"));
let now = at("2026-09-07T00:00:00Z");
let without_live =
origin_from_payload(&frozen_and_ancient, now, None).expect("mirror payload");
assert!(
without_live.is_stale,
"no live mirror to read ⇒ the frozen stamp is all we have"
);
let with_live = origin_from_payload(
&frozen_and_ancient,
now,
Some(("2026-09-06T18:00:00Z", "4090")),
)
.expect("mirror payload");
assert!(
!with_live.is_stale,
"a mirror re-exported 6h ago is not stale, whatever the import stamp said"
);
assert_eq!(
with_live.exported_at.as_deref(),
Some("2026-09-06T18:00:00Z"),
"the stamp reported is the one freshness was judged against"
);
assert_eq!(
with_live.machine, "4090",
"provenance still comes from the import event, not the live index"
);
}
#[test]
fn another_machines_fresh_export_does_not_clear_this_ones_marker() {
let from_4090 = payload(Some("2026-08-01T00:00:00Z"));
let now = at("2026-09-07T00:00:00Z");
let foreign =
origin_from_payload(&from_4090, now, Some(("2026-09-06T23:00:00Z", "docs-box")))
.expect("mirror payload");
assert!(
foreign.is_stale,
"a fresh export by docs-box says nothing about how current 4090's rulings are"
);
assert_eq!(
foreign.exported_at.as_deref(),
Some("2026-08-01T00:00:00Z"),
"the frozen stamp is reported, never another machine's"
);
let ours = origin_from_payload(&from_4090, now, Some(("2026-09-06T23:00:00Z", "4090")))
.expect("mirror payload");
assert!(!ours.is_stale);
assert_eq!(ours.exported_at.as_deref(), Some("2026-09-06T23:00:00Z"));
}
#[test]
fn an_unreadable_stamp_is_stale_not_silently_fresh() {
let missing = origin_from_payload(&payload(None), at("2026-09-07T00:00:00Z"), None)
.expect("mirror payload");
assert!(missing.is_stale);
assert!(missing.age_hours.is_none());
assert!(stale_hint(&missing).contains("stamp missing or unreadable"));
let garbage = origin_from_payload(
&payload(Some("not-a-timestamp")),
at("2026-09-07T00:00:00Z"),
None,
)
.expect("mirror payload");
assert!(garbage.is_stale);
assert!(garbage.age_hours.is_none());
}
#[test]
fn a_ledger_with_a_mirror_import_annotates_exactly_the_imported_hit() {
let ledger = setup();
let local = append(&ledger, ¬e("decided here this morning"));
let imported = append(&ledger, &mirror_import("4090", "2026-01-01T00:00:00Z"));
let mut hits = vec![hit(&imported), hit(&local), hit("evt_not_in_this_ledger")];
let origins = origins_for_hits(&ledger, &hits, None);
annotate_hits(&mut hits, &origins);
let o = hits[0].mirror.as_ref().expect("the import carries a stamp");
assert_eq!(o.machine, "4090");
assert_eq!(o.exported_at.as_deref(), Some("2026-01-01T00:00:00Z"));
assert!(o.is_stale, "a stamp from 2026-01-01 is long past 24h");
assert!(
hits[1].mirror.is_none(),
"a locally-decided row in a mirror-fed ledger did not ride a mirror"
);
assert!(
hits[2].mirror.is_none(),
"an event that cannot be read is not evidence of a mirror"
);
}
#[test]
fn a_ledger_with_no_mirror_import_answers_none_for_every_hit() {
let ledger = setup();
let local = append(&ledger, ¬e("decided here this morning"));
assert!(
ledger.get_event(&local).unwrap().is_some(),
"the row is present, so `None` below is the short-circuit's answer \
and not a lookup that missed"
);
assert!(
ledger
.iter_events_by_type(MIRROR_STAMP_EVENT_TYPE)
.unwrap()
.is_empty(),
"the condition the short-circuit keys on"
);
let mut hits = vec![hit(&local), hit("evt_not_in_this_ledger")];
let origins = origins_for_hits(&ledger, &hits, None);
assert_eq!(
origins.len(),
hits.len(),
"one answer per hit, short-circuit or not — `annotate_hits` zips \
the two and would silently drop the tail"
);
assert!(origins.iter().all(Option::is_none));
annotate_hits(&mut hits, &origins);
assert!(hits.iter().all(|h| h.mirror.is_none()));
assert!(
origins_for_hits(&ledger, &[], None).is_empty(),
"no hits, no probe: a query that matched nothing did no ledger \
work before this short-circuit and must do none after"
);
}
}