1use crate::DecisionHit;
37use edda_ledger::sync::DEFAULT_MIRROR_STALE_HOURS;
38use edda_ledger::Ledger;
39use serde::Serialize;
40use std::path::Path;
41use time::format_description::well_known::Rfc3339;
42use time::OffsetDateTime;
43
44const MIRROR_INDEX: &str = "docs/decisions/INDEX.md";
47
48const MIRROR_STAMP_EVENT_TYPE: &str = "decision_import";
56
57#[derive(Debug, Clone, Serialize)]
59pub struct MirrorOrigin {
60 pub machine: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
69 pub exported_at: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub age_hours: Option<f64>,
73 pub is_stale: bool,
74 pub threshold_hours: i64,
75}
76
77pub fn origins_for_hits(
84 ledger: &Ledger,
85 hits: &[DecisionHit],
86 repo_root: Option<&Path>,
87) -> Vec<Option<MirrorOrigin>> {
88 if hits.is_empty() || !may_hold_mirror_origins(ledger) {
97 return vec![None; hits.len()];
98 }
99 let now = OffsetDateTime::now_utc();
100 let live = repo_root.and_then(live_mirror);
102 hits.iter()
103 .map(|h| {
104 ledger.get_event(&h.event_id).ok().flatten().and_then(|e| {
105 origin_from_payload(
106 &e.payload,
107 now,
108 live.as_ref().map(|(s, m)| (s.as_str(), m.as_str())),
109 )
110 })
111 })
112 .collect()
113}
114
115fn live_mirror(repo_root: &Path) -> Option<(String, String)> {
128 let text = std::fs::read_to_string(repo_root.join(MIRROR_INDEX)).ok()?;
129 let field = |name: &str| {
130 text.lines()
131 .find_map(|l| l.strip_prefix(name))
132 .map(|v| v.trim().to_string())
133 .filter(|v| !v.is_empty())
134 };
135 Some((
136 field("- **Exported at**:")?,
137 field("- **Exporting machine**:")?,
138 ))
139}
140
141fn may_hold_mirror_origins(ledger: &Ledger) -> bool {
148 match ledger.iter_events_by_type(MIRROR_STAMP_EVENT_TYPE) {
149 Ok(imports) => !imports.is_empty(),
150 Err(_) => true,
151 }
152}
153
154pub fn annotate_hits(hits: &mut [DecisionHit], origins: &[Option<MirrorOrigin>]) {
156 for (hit, origin) in hits.iter_mut().zip(origins.iter()) {
157 hit.mirror = origin.clone();
158 }
159}
160
161fn origin_from_payload(
165 payload: &serde_json::Value,
166 now: OffsetDateTime,
167 live: Option<(&str, &str)>,
168) -> Option<MirrorOrigin> {
169 let mirror = payload.get("mirror")?.as_object()?;
170 let machine = mirror
171 .get("machine")
172 .and_then(|v| v.as_str())
173 .unwrap_or("?")
174 .to_string();
175 let frozen = mirror
176 .get("exported_at")
177 .and_then(|v| v.as_str())
178 .map(str::to_string);
179 let exported_at = live
183 .filter(|(_, live_machine)| *live_machine == machine)
184 .map(|(stamp, _)| stamp.to_string())
185 .or(frozen);
186 let age_hours = exported_at.as_deref().and_then(|ts| {
187 OffsetDateTime::parse(ts, &Rfc3339)
188 .ok()
189 .map(|t| (now - t).as_seconds_f64() / 3600.0)
190 });
191 Some(MirrorOrigin {
192 machine,
193 exported_at,
194 is_stale: match age_hours {
196 Some(h) => h >= DEFAULT_MIRROR_STALE_HOURS as f64,
197 None => true,
198 },
199 age_hours,
200 threshold_hours: DEFAULT_MIRROR_STALE_HOURS,
201 })
202}
203
204pub fn stale_hint(origin: &MirrorOrigin) -> String {
206 let age = match origin.age_hours {
207 Some(h) => format!("{h:.1}h old"),
208 None => "stamp missing or unreadable".to_string(),
209 };
210 format!(
211 "⚠ stale-mirror hint: from {} — exported {} ({age}, threshold {}h). Re-export on the source machine and pull.",
212 origin.machine,
213 origin.exported_at.as_deref().unwrap_or("?"),
214 origin.threshold_hours,
215 )
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use edda_core::event::{finalize_event, new_note_event};
222 use edda_core::Event;
223 use edda_ledger::ledger::{init_branches_json, init_head, init_workspace};
224 use edda_ledger::paths::EddaPaths;
225 use std::sync::atomic::{AtomicU64, Ordering};
226
227 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
228
229 fn setup() -> Ledger {
232 let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
233 let tmp = std::env::temp_dir().join(format!("edda_ask_mirror_{}_{n}", std::process::id()));
234 let _ = std::fs::remove_dir_all(&tmp);
235 let paths = EddaPaths::discover(&tmp);
236 init_workspace(&paths).unwrap();
237 init_head(&paths, "main").unwrap();
238 init_branches_json(&paths, "main").unwrap();
239 Ledger::open(&tmp).unwrap()
240 }
241
242 fn append(ledger: &Ledger, event: &Event) -> String {
243 let mut chained = event.clone();
244 chained.parent_hash = ledger.last_event_hash().unwrap();
245 finalize_event(&mut chained).unwrap();
246 ledger.append_event(&chained).unwrap();
247 chained.event_id
248 }
249
250 fn note(text: &str) -> Event {
251 new_note_event("main", None, "system", text, &[]).unwrap()
252 }
253
254 fn mirror_import(machine: &str, exported_at: &str) -> Event {
256 let mut e = note("[sync] imported db.engine=sqlite");
257 e.event_type = "decision_import".to_string();
258 e.payload["mirror"] = serde_json::json!({
259 "machine": machine,
260 "exported_at": exported_at,
261 });
262 e
263 }
264
265 fn hit(event_id: &str) -> DecisionHit {
266 DecisionHit {
267 event_id: event_id.to_string(),
268 key: "db.engine".to_string(),
269 value: "sqlite".to_string(),
270 reason: String::new(),
271 domain: "db".to_string(),
272 branch: "main".to_string(),
273 ts: "2026-09-07T00:00:00Z".to_string(),
274 is_active: true,
275 governance: crate::DecisionGovernance::default(),
276 tags: Vec::new(),
277 village_id: None,
278 staleness: None,
279 mirror: None,
280 }
281 }
282
283 fn at(ts: &str) -> OffsetDateTime {
284 OffsetDateTime::parse(ts, &Rfc3339).unwrap()
285 }
286
287 fn payload(exported_at: Option<&str>) -> serde_json::Value {
288 match exported_at {
289 Some(ts) => serde_json::json!({"mirror": {"machine": "4090", "exported_at": ts}}),
290 None => serde_json::json!({"mirror": {"machine": "4090", "exported_at": null}}),
291 }
292 }
293
294 #[test]
295 fn a_decision_that_never_rode_a_mirror_has_no_origin() {
296 let local = serde_json::json!({"role": "system", "decision": {"key": "db.engine"}});
299 assert!(origin_from_payload(&local, at("2026-09-07T00:00:00Z"), None).is_none());
300 }
301
302 #[test]
303 fn a_fresh_mirror_is_not_marked() {
304 let o = origin_from_payload(
305 &payload(Some("2026-09-07T00:00:00Z")),
306 at("2026-09-07T06:00:00Z"),
307 None,
308 )
309 .expect("mirror payload");
310 assert_eq!(o.machine, "4090");
311 assert!(!o.is_stale, "6h < 24h threshold");
312 assert!((o.age_hours.expect("parsed stamp") - 6.0).abs() < 0.001);
313 }
314
315 #[test]
316 fn a_mirror_past_the_threshold_is_marked_stale() {
317 let o = origin_from_payload(
318 &payload(Some("2026-09-01T00:00:00Z")),
319 at("2026-09-07T00:00:00Z"),
320 None,
321 )
322 .expect("mirror payload");
323 assert!(o.is_stale, "144h >= 24h threshold");
324 assert!(stale_hint(&o).contains("4090"));
325 assert!(stale_hint(&o).contains("144.0h old"));
326 }
327
328 #[test]
329 fn exactly_at_the_threshold_is_stale() {
330 let o = origin_from_payload(
332 &payload(Some("2026-09-06T00:00:00Z")),
333 at("2026-09-07T00:00:00Z"),
334 None,
335 )
336 .expect("mirror payload");
337 assert!(o.is_stale);
338 }
339
340 #[test]
341 fn a_fresh_checkout_clears_a_marker_the_frozen_stamp_would_hold_forever() {
342 let frozen_and_ancient = payload(Some("2026-08-01T00:00:00Z"));
349 let now = at("2026-09-07T00:00:00Z");
350
351 let without_live =
352 origin_from_payload(&frozen_and_ancient, now, None).expect("mirror payload");
353 assert!(
354 without_live.is_stale,
355 "no live mirror to read ⇒ the frozen stamp is all we have"
356 );
357
358 let with_live = origin_from_payload(
359 &frozen_and_ancient,
360 now,
361 Some(("2026-09-06T18:00:00Z", "4090")),
362 )
363 .expect("mirror payload");
364 assert!(
365 !with_live.is_stale,
366 "a mirror re-exported 6h ago is not stale, whatever the import stamp said"
367 );
368 assert_eq!(
369 with_live.exported_at.as_deref(),
370 Some("2026-09-06T18:00:00Z"),
371 "the stamp reported is the one freshness was judged against"
372 );
373 assert_eq!(
374 with_live.machine, "4090",
375 "provenance still comes from the import event, not the live index"
376 );
377 }
378
379 #[test]
380 fn another_machines_fresh_export_does_not_clear_this_ones_marker() {
381 let from_4090 = payload(Some("2026-08-01T00:00:00Z"));
387 let now = at("2026-09-07T00:00:00Z");
388
389 let foreign =
390 origin_from_payload(&from_4090, now, Some(("2026-09-06T23:00:00Z", "docs-box")))
391 .expect("mirror payload");
392 assert!(
393 foreign.is_stale,
394 "a fresh export by docs-box says nothing about how current 4090's rulings are"
395 );
396 assert_eq!(
397 foreign.exported_at.as_deref(),
398 Some("2026-08-01T00:00:00Z"),
399 "the frozen stamp is reported, never another machine's"
400 );
401
402 let ours = origin_from_payload(&from_4090, now, Some(("2026-09-06T23:00:00Z", "4090")))
405 .expect("mirror payload");
406 assert!(!ours.is_stale);
407 assert_eq!(ours.exported_at.as_deref(), Some("2026-09-06T23:00:00Z"));
408 }
409
410 #[test]
411 fn an_unreadable_stamp_is_stale_not_silently_fresh() {
412 let missing = origin_from_payload(&payload(None), at("2026-09-07T00:00:00Z"), None)
414 .expect("mirror payload");
415 assert!(missing.is_stale);
416 assert!(missing.age_hours.is_none());
417 assert!(stale_hint(&missing).contains("stamp missing or unreadable"));
418
419 let garbage = origin_from_payload(
420 &payload(Some("not-a-timestamp")),
421 at("2026-09-07T00:00:00Z"),
422 None,
423 )
424 .expect("mirror payload");
425 assert!(garbage.is_stale);
426 assert!(garbage.age_hours.is_none());
427 }
428
429 #[test]
434 fn a_ledger_with_a_mirror_import_annotates_exactly_the_imported_hit() {
435 let ledger = setup();
436 let local = append(&ledger, ¬e("decided here this morning"));
437 let imported = append(&ledger, &mirror_import("4090", "2026-01-01T00:00:00Z"));
438
439 let mut hits = vec![hit(&imported), hit(&local), hit("evt_not_in_this_ledger")];
440 let origins = origins_for_hits(&ledger, &hits, None);
441 annotate_hits(&mut hits, &origins);
442
443 let o = hits[0].mirror.as_ref().expect("the import carries a stamp");
444 assert_eq!(o.machine, "4090");
445 assert_eq!(o.exported_at.as_deref(), Some("2026-01-01T00:00:00Z"));
446 assert!(o.is_stale, "a stamp from 2026-01-01 is long past 24h");
447 assert!(
448 hits[1].mirror.is_none(),
449 "a locally-decided row in a mirror-fed ledger did not ride a mirror"
450 );
451 assert!(
452 hits[2].mirror.is_none(),
453 "an event that cannot be read is not evidence of a mirror"
454 );
455 }
456
457 #[test]
463 fn a_ledger_with_no_mirror_import_answers_none_for_every_hit() {
464 let ledger = setup();
465 let local = append(&ledger, ¬e("decided here this morning"));
466
467 assert!(
468 ledger.get_event(&local).unwrap().is_some(),
469 "the row is present, so `None` below is the short-circuit's answer \
470 and not a lookup that missed"
471 );
472 assert!(
473 ledger
474 .iter_events_by_type(MIRROR_STAMP_EVENT_TYPE)
475 .unwrap()
476 .is_empty(),
477 "the condition the short-circuit keys on"
478 );
479
480 let mut hits = vec![hit(&local), hit("evt_not_in_this_ledger")];
481 let origins = origins_for_hits(&ledger, &hits, None);
482 assert_eq!(
483 origins.len(),
484 hits.len(),
485 "one answer per hit, short-circuit or not — `annotate_hits` zips \
486 the two and would silently drop the tail"
487 );
488 assert!(origins.iter().all(Option::is_none));
489 annotate_hits(&mut hits, &origins);
490 assert!(hits.iter().all(|h| h.mirror.is_none()));
491
492 assert!(
493 origins_for_hits(&ledger, &[], None).is_empty(),
494 "no hits, no probe: a query that matched nothing did no ledger \
495 work before this short-circuit and must do none after"
496 );
497 }
498}