Skip to main content

edda_ask/
mirror.rs

1//! Cross-machine mirror provenance at the **read** end (GH-671).
2//!
3//! `edda sync --from-mirror` warns about a dead mirror at import time. That
4//! signal dies with the command: once the rows are in the ledger, `edda ask`
5//! on machine B renders a decision that arrived over a three-week-old mirror
6//! exactly like one decided here this morning. The doneWhen clause this module
7//! answers is "讀端過期時 `edda ask` 輸出有標示" — the marking belongs at the
8//! read end, not only at the write end.
9//!
10//! Mechanism: the mirror import stamps `payload["mirror"]` on its
11//! `decision_import` event (`edda_ledger::sync::make_import_event`), and the
12//! imported row's `event_id` **is** that event's id, so a hit resolves its own
13//! provenance with one `get_event`. Nothing is written at query time — the
14//! same query-time-derivation contract [`crate::staleness`] follows.
15//!
16//! Staleness matches [`edda_ledger::sync::MirrorFreshness::is_stale`]: older
17//! than the threshold, **or unreadable**. Unknown freshness must be visible,
18//! never silently fresh.
19//!
20//! **Provenance is frozen; freshness is live.** Which machine a decision came
21//! over is a fact about the past and is read from the import event. How stale
22//! that is cannot be, because an already-imported decision is *skipped* on
23//! every later import (`sync_from_mirror`'s self-import guard), so the stamp on
24//! its import event is never rewritten. Ageing that frozen stamp meant every
25//! mirrored decision on a perfectly current machine read stale 24 hours after
26//! it first arrived, forever, and the hint's own remedy — re-export and pull —
27//! could not clear it. So freshness is taken from the mirror **in this
28//! checkout at query time**, which is what "讀端過期" names and what pulling a
29//! fresh mirror actually changes — but only when that mirror is the *same
30//! machine's*. A checkout's `docs/decisions/` is rewritten by whichever machine
31//! last ran the wave-close export, routinely this one, and that says nothing
32//! about how current a peer's rulings are. So the frozen stamp is not a rare
33//! fallback: it stands for every decision whose origin machine is not the one
34//! the local mirror belongs to, which in a fleet is most of them.
35
36use 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
44/// Repo-relative mirror directory, fixed by `ledger.cross-machine-projection`
45/// clause (1). Kept in step with `edda_bridge_claude::mirror_import`.
46const MIRROR_INDEX: &str = "docs/decisions/INDEX.md";
47
48/// The only event type that can carry `payload["mirror"]`.
49///
50/// `edda_ledger::sync::make_import_event` is the sole writer of that stamp and
51/// always writes it onto a `decision_import` event, which is what makes "this
52/// ledger has no `decision_import` events" a sound proxy for "no hit here can
53/// have mirror provenance". A future writer that stamps some other event type
54/// has to be taught to this constant too.
55const MIRROR_STAMP_EVENT_TYPE: &str = "decision_import";
56
57/// The mirror a decision arrived over, and how dead it was.
58#[derive(Debug, Clone, Serialize)]
59pub struct MirrorOrigin {
60    /// Exporting machine from the mirror's `INDEX.md`, or the directory name
61    /// when the stamp named none.
62    pub machine: String,
63    /// The `- **Exported at**:` stamp freshness was judged against: the mirror
64    /// in this checkout **when that mirror is [`Self::machine`]'s own**,
65    /// otherwise the stamp this row was imported under. Another machine's
66    /// mirror — including this box's own re-export — never speaks for a peer's
67    /// rulings. Absent when neither could be read.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub exported_at: Option<String>,
70    /// Age of that stamp in hours at query time; `None` when unparseable.
71    #[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
77/// Resolve each hit's mirror provenance. Non-mirror decisions map to `None`,
78/// which is every decision in a single-machine project.
79///
80/// Best-effort by construction: a hit whose event cannot be read maps to
81/// `None` rather than failing the query — an unreadable event is not evidence
82/// that a decision came from a mirror.
83pub fn origins_for_hits(
84    ledger: &Ledger,
85    hits: &[DecisionHit],
86    repo_root: Option<&Path>,
87) -> Vec<Option<MirrorOrigin>> {
88    // `edda ask` runs this twice per query (decisions + timeline) and once more
89    // per project under `--fleet`, so the per-hit `get_event` below is paid ~2N
90    // times — for a field that is `None` on every row of a single-machine
91    // project, because such a project has no `decision_import` events for a
92    // lookup to find. One index-backed probe (`idx_events_type`) answers that
93    // for the whole list, so N primary-key lookups collapse into one seek that
94    // reads no rows at all. A ledger that does have imports takes exactly the
95    // per-hit path it took before.
96    if hits.is_empty() || !may_hold_mirror_origins(ledger) {
97        return vec![None; hits.len()];
98    }
99    let now = OffsetDateTime::now_utc();
100    // Read once for the whole list: it is the same mirror for every hit.
101    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
115/// The committed mirror in this checkout: its stamp **and whose it is**.
116///
117/// Both halves matter. A checkout's `docs/decisions/` is rewritten by this
118/// machine's own wave-close export (`scripts/fleet/ratify-merged.sh`), so its
119/// stamp routinely belongs to a *different* machine than the one a given
120/// decision arrived from. Ageing a decision from `4090` against this box's own
121/// fresh export would clear the marker for a mirror nobody re-pulled, and print
122/// `from 4090 — exported <this box's timestamp>`, which is simply false.
123///
124/// Prefixes and trimming match `edda_ledger::sync::parse_index_meta` and
125/// `edda_bridge_claude::mirror_import::read_stamp` — three readers of one file,
126/// which only stay in agreement if they all strip it the same way.
127fn 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
141/// Whether a per-hit lookup could find any provenance at all.
142///
143/// Same best-effort rule as the lookups it guards: a probe that could not be
144/// read answers `true`, because failing to read the ledger is not evidence that
145/// nothing arrived over a mirror. The cost of being wrong that way is the
146/// per-hit path that ran before this short-circuit existed.
147fn 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
154/// Annotate an in-memory hit list with the origins from [`origins_for_hits`].
155pub 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
161/// The pure half: read `payload["mirror"]` for provenance, and age the mirror
162/// this checkout actually holds — falling back to the frozen import stamp only
163/// when there is no live mirror to read.
164fn 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    // The live stamp only speaks for this decision if the mirror in the
180    // checkout is the same machine's. Anyone else's — including this box's own
181    // re-export — says nothing about how current `machine`'s rulings are.
182    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        // Same rule as the import-time warning: unknown age is stale.
195        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
204/// The human line `edda ask` prints under a decision that rode a dead mirror.
205pub 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    /// A real on-disk ledger — this workspace does not mock internal crates,
230    /// and the short-circuit below is a claim about what SQLite holds.
231    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    /// A `decision_import` carrying the stamp `sync::make_import_event` writes.
255    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        // The single-machine case: every decision, and the reason the marker
297        // does not become noise in a solo project.
298        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        // Boundary matches `MirrorFreshness::is_stale`: `>=`, not `>`.
331        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        // The round-3 P1. An already-imported decision is skipped on every
343        // later import, so `payload["mirror"]["exported_at"]` is frozen at
344        // whatever it was the first time. Ageing that meant a machine that
345        // pulls faithfully still read stale after 24h, permanently, and the
346        // hint's own remedy could not clear it. Freshness is the mirror this
347        // checkout holds now.
348        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        // Round 4. The checkout's `docs/decisions/` is rewritten by whichever
382        // machine last ran the wave-close export — routinely this box, not the
383        // one a given decision came from. Taking its stamp unconditionally
384        // cleared the marker for a mirror nobody re-pulled and printed
385        // "from 4090 — exported <this box's timestamp>", which is false.
386        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        // Same stamp, same machine: that is the pull the marker exists to
403        // reward, and it must still clear.
404        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        // Death visibility: unknown freshness must be visible.
413        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    /// The half of the short-circuit that must NOT fire: a ledger holding a
430    /// mirror import still resolves provenance one hit at a time. The
431    /// locally-decided row beside it stays `None`, so a passing probe cannot be
432    /// mistaken for a blanket "everything here came over a mirror".
433    #[test]
434    fn a_ledger_with_a_mirror_import_annotates_exactly_the_imported_hit() {
435        let ledger = setup();
436        let local = append(&ledger, &note("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    /// The single-machine case the short-circuit exists for. The `None`s here
458    /// have to be the same `None`s the per-hit path produced, so the test pins
459    /// both halves: the events really are in the ledger (a lookup would have
460    /// found them and still answered `None`), and there is no
461    /// `decision_import` for one to find.
462    #[test]
463    fn a_ledger_with_no_mirror_import_answers_none_for_every_hit() {
464        let ledger = setup();
465        let local = append(&ledger, &note("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}