Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

crates/holger-monetize-guest/src/guardian.rs

//! **holger's estate guardian, in holger's own guest** (lane W3, 2026-10-19) — the holger impl
//! of [`monetize_product_guest::guardian::EstateGuardian`], gunnar's lifecycle in holger's twin
//! model. The ONE box links no product, so this is the guardian it runs for a Holger estate:
//! the guest answers `guardian.diagnose`, `guardian.plan` and `guardian.observe` with it.
//! It is the same code as monetize's linked plugin (`monetize/products/holger/src/guardian.rs`);
//! monetize's `the_built_holger_guest_guards_as_the_linked_plugin_does` holds the two equal.
//!
//! Pure: it reads what the boxes REPORTED (`Fleet.Report` health words) and answers findings
//! and plans. Nothing here dials a box. Every verb on the estate is one of holger's own
//! (`holger.v1.AdminService`), run by the box that pulled the signed order:
//!
//! | order | body | run by | holger's verbs |
//! |---|---|---|---|
//! | `become_twin` | `{primary}` | a freshly laid box | `BecomeTwin` (the election, exactly once, a FRESH box only) + `TwinCsr` |
//! | `adopt_twin` | `{twin, principal}` | the main | `IssueTwinCertificate` for `holger-twin`; the twin installs it (`InstallTwinCertificate`) |
//! | `fence_primary` | `{primary, epoch}` | the main, on itself | every door refuses writes from then on |
//! | `recharge_from_twin` | `{main, twin, accept_lost, epoch}` | a FRESH main | `TwinCsr`/`IssueTwinCertificate`/`InstallTwinCertificate` purpose `recharge`, then `Recharge` (it resumes a `restoring` one) |
//! | `review_twin` | `{}` | the main | `TwinStatus` read again, its verdict on the next report |
//! | `restate_entitlements` | `{snapshot}` (signed `Snapshot`) | the main, once recharged | `EntitlementService.Set` per fact |
//!
//! **Where holger differs from gunnar, and why the plans do.** A holger box installs from ONE
//! medium with no role and is TOLD it is the twin (`BecomeTwin`), so a new twin needs
//! `become_twin` before the main adopts it. `Recharge` runs only on a FRESH main
//! (`RechargeStatus.fresh`; a box holding state of its own is re-imaged first), so a main that
//! diverged is not recharged in place: it is replaced (New server). And a recharged main mints
//! its own issuer, so the twin is RE-POINTED at it — by the recharge itself (holger's recharge lane), so the rebuild plan is Gunnar's step for step.
//!
//! Health words a holger box reports (flat, the box's Fleet client fills them from
//! `RechargeStatus`, `TwinStatus` and the admin view's twin rows): `serving`
//! (`yes`/`no`/`fenced`), `data` (`ok` = holding or recharged, `fresh`, `restoring`),
//! `recharge` (`failed` when the last `Recharge` named failures), `serves_at` (`host:port`),
//! `twin` (`paired`/`silent`/`none`, on the main), `primary` (`live`/`silent`, on the twin),
//! `complete` (`yes`/`no`, on the twin: its last follow cycle that reached the primary was
//! whole), `epoch`.

use monetize_product_guest::guardian::rebuild::{self, Twin};
use monetize_product_guest::guardian::{
    role, CloudStep, EstateGuardian, EstateView, Finding, Remedy, Step, StepState,
};

/// The one holger guardian (stateless).
pub static GUARDIAN: HolgerGuardian = HolgerGuardian;

pub struct HolgerGuardian;

use monetize_product_guest::guardian::steps::{self, heard, order, recharge, restate, wait, word};
/// The plan's building blocks and bounds are SHARED since P4a (monetize-product
/// `guardian::steps`, the same in Gunnar's guest): `order`, `wait`, `recharge`, `restate`,
/// `heard`, `word`, and how long a laid box, a pairing and a recharge may take.
pub use monetize_product_guest::guardian::steps::{
    HEARD_WITHIN_MS, PAIRED_WITHIN_MS, RECHARGED_WITHIN_MS,
};

/// The twin can give the estate back whole: its last cycle that reached the primary was
/// complete. An older box that reports no `complete` word is taken at its follow's word.
fn twin_whole(v: &EstateView) -> bool {
    word(v, role::TWIN, "complete") != "no"
}

/// **Holger's twin as the guard reads it** (lane G11): it still hears its primary unless it
/// says `primary=silent`; it can give the estate back when its last follow that reached the
/// primary was whole ([`twin_whole`]). Holger keeps no log head, so the guard's R4 has nothing
/// to compare (`holds: None`); the whole follow is Holger's answer to "is the main ahead".
/// `None` while the twin is silent.
fn twin_of(v: &EstateView) -> Option<Twin> {
    let b = v.get(role::TWIN).filter(|_| !v.silent(role::TWIN))?;
    let why = b.word("why");
    Some(Twin {
        addr: rebuild::twin_addr(v),
        hears_primary: b.word("primary") != "silent",
        recharge_possible: twin_whole(v),
        verdict: if twin_whole(v) {
            "its last follow was whole".into()
        } else {
            format!(
                "its last follow was not whole ({})",
                if why.is_empty() {
                    "no reason given"
                } else {
                    why
                }
            )
        },
        holds: None,
    })
}

impl EstateGuardian for HolgerGuardian {
    fn diagnose(&self, v: &EstateView) -> Vec<Finding> {
        let main_silent = v.silent(role::MAIN);
        let has_twin = v.get(role::TWIN).is_some();
        let twin_silent = v.silent(role::TWIN);
        let twin_up = has_twin && !twin_silent;
        let evidence = vec![heard(v, role::MAIN), heard(v, role::TWIN)];
        if main_silent && has_twin && twin_silent {
            return vec![steps::both_lost(evidence)];
        }
        let mut out = Vec::new();
        // ★ Gunnar's rule (W3 parity, gunnar cf02854a): a re-laid main that reports itself HELD
        // (`data=fresh serving=no`, M3's boot hold) is no finding of its own. It is the box the
        // rebuild plan laid (New server: fence, lay, heard, recharge, recharged, restate), and
        // that plan's recharge step fills it; a person may still press Recharge from twin.
        if main_silent && twin_up {
            let twin_blind = word(v, role::TWIN, "primary") == "silent";
            let whole = twin_whole(v);
            let words = match (twin_blind, whole) {
                (true, true) => "the main is silent, and the twin cannot see it either: the main is rebuilt from the twin (never promoted)".to_string(),
                (true, false) => format!(
                    "the main is silent, and the twin cannot see it either — but the twin's last follow was not whole ({}): a person decides (accept the loss, or bring the main's disk back)",
                    if word(v, role::TWIN, "why").is_empty() { "no reason given" } else { word(v, role::TWIN, "why") }
                ),
                (false, _) => "the main is silent to its provider; the twin still sees it".to_string(),
            };
            // ★ The guard (Gunnar's `rebuild::judge`, R1-R6, shared: lane G11): silence alone
            // never lays a main again. Auto-fix runs New server only where the guard says
            // `lay_again` — the main GONE at the provider, a whole twin that does not hear it.
            let guard = rebuild::auto_new_server(v, twin_of(v));
            out.push(steps::main_lost(&words, &guard, evidence.clone()));
        }
        out.extend(steps::twin_lost(v, evidence));
        out
    }

    fn plan(&self, remedy: Remedy, v: &EstateView) -> Result<Vec<Step>, String> {
        let main_up = !v.silent(role::MAIN);
        let twin_up = !v.silent(role::TWIN);
        match remedy {
            Remedy::NewTwin => {
                if !main_up {
                    return Err("no primary is reporting to adopt a new twin: the main comes first (New server)".into());
                }
                Ok(vec![
                    Step::Cloud(CloudStep::Fence {
                        role: role::TWIN.into(),
                    }),
                    Step::Cloud(CloudStep::Lay {
                        role: role::TWIN.into(),
                    }),
                    wait(role::TWIN, "heard", HEARD_WITHIN_MS),
                    // A holger box is TOLD it is the twin, once, while it is fresh.
                    order(role::TWIN, "become_twin", &[("primary", "$addr:main")]),
                    order(
                        role::MAIN,
                        "adopt_twin",
                        &[("twin", "$addr:twin"), ("principal", "$principal")],
                    ),
                    wait(role::MAIN, "paired", PAIRED_WITHIN_MS),
                ])
            }
            // The guard before the fence gives the old main back whole (lane G11).
            Remedy::NewServer => steps::new_server(v, twin_of(v)),
            Remedy::RechargeFromTwin => {
                if !twin_up {
                    return Err("no twin is reporting: nothing to recharge from".into());
                }
                if !main_up {
                    return Err(
                        "the main is silent and cannot pull its order: press New server".into(),
                    );
                }
                let data = word(v, role::MAIN, "data");
                if !matches!(data, "fresh" | "restoring") {
                    return Err(format!(
                        "the main reports data={}: holger recharges only a FRESH main (a box that holds state of its own is re-imaged, never overwritten) — press New server",
                        if data.is_empty() { "?" } else { data }
                    ));
                }
                let mut steps = Vec::new();
                if word(v, role::MAIN, "serving") == "yes" {
                    steps.push(order(
                        role::MAIN,
                        "fence_primary",
                        &[("primary", "$addr:main"), ("epoch", "$epoch")],
                    ));
                }
                steps.push(recharge());
                steps.push(wait(role::MAIN, "recharged", RECHARGED_WITHIN_MS));
                steps.push(restate());
                Ok(steps)
            }
            Remedy::ReviewTwin => steps::review_twin(v),
            // S8: the front reports nothing on Fleet; main and twin do (shared, P4).
            Remedy::RestoreFront => steps::restore_front(v),
        }
    }

    fn observe(&self, r: &str, what: &str, v: &EstateView) -> StepState {
        let Some(b) = v.get(r) else {
            return StepState::Waiting(format!("the {r} is not registered yet"));
        };
        match what {
            "paired" => steps::observe_paired(r, b),
            "recharged" => steps::observe_recharged(r, b),
            other => StepState::Failed(format!(
                "holger's guardian does not know how to await {other:?}"
            )),
        }
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use monetize_product_guest::guardian::BoxView;

    use super::*;

    const NOW: u64 = 1_800_000_000_000;

    fn boxed(r: &str, heard_ago_ms: Option<u64>, words: &[(&str, &str)]) -> (String, BoxView) {
        let health: BTreeMap<String, String> = words
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect();
        (
            r.to_string(),
            BoxView {
                role: r.into(),
                heard_unix_ms: heard_ago_ms.map(|a| NOW - a).unwrap_or(0),
                health,
                ..Default::default()
            },
        )
    }

    /// The provider's word on the box (the runner fills it for a silent main monetize laid).
    fn at(mut b: (String, BoxView), word: &str) -> (String, BoxView) {
        b.1.at_provider = word.into();
        b
    }

    fn view(boxes: Vec<(String, BoxView)>) -> EstateView {
        EstateView {
            product: "holger".into(),
            instance: "acme".into(),
            now_unix_ms: NOW,
            silent_after_ms: 90_000,
            boxes: boxes.into_iter().collect(),
        }
    }

    fn healthy_main() -> (String, BoxView) {
        boxed(
            "main",
            Some(1_000),
            &[
                ("serving", "yes"),
                ("data", "ok"),
                ("serves_at", "10.6.0.10:50051"),
                ("twin", "paired"),
            ],
        )
    }
    fn healthy_twin() -> (String, BoxView) {
        boxed(
            "twin",
            Some(1_000),
            &[
                ("serving", "no"),
                ("data", "ok"),
                ("serves_at", "10.6.0.12:50051"),
                ("primary", "live"),
                ("complete", "yes"),
            ],
        )
    }

    fn codes(v: &EstateView) -> Vec<String> {
        GUARDIAN.diagnose(v).into_iter().map(|f| f.code).collect()
    }

    fn order_kinds(steps: &[Step]) -> Vec<String> {
        steps
            .iter()
            .map(|s| match s {
                Step::Cloud(CloudStep::Fence { role }) => format!("fence {role}"),
                Step::Cloud(CloudStep::Lay { role }) => format!("lay {role}"),
                Step::Cloud(CloudStep::Restore { role }) => format!("restore {role}"),
                Step::Order { role, kind, .. } => format!("{kind}→{role}"),
                Step::Await { role, what, .. } => format!("await {role} {what}"),
            })
            .collect()
    }

    /// The ops the ONE box calls answer what this guardian says, across the guest ABI.
    #[test]
    fn the_guardian_ops_answer_across_the_abi() {
        use monetize_product_guest::{dispatch, ops, wire, Answer, NoHost};
        let held = view(vec![
            boxed(
                "main",
                Some(1_000),
                &[
                    ("serving", "no"),
                    ("data", "fresh"),
                    ("serves_at", "10.6.0.10:50051"),
                ],
            ),
            healthy_twin(),
        ]);
        let raw = dispatch(
            &crate::HolgerGuest,
            &NoHost,
            ops::GUARDIAN_DIAGNOSE,
            &wire::encode(&held),
        );
        let found: Answer<Vec<Finding>> = wire::decode(&raw).unwrap();
        assert_eq!(found.unwrap(), GUARDIAN.diagnose(&held));
        let req = wire::GuardianPlanReq {
            remedy: Remedy::RechargeFromTwin,
            view: held.clone(),
        };
        let raw = dispatch(
            &crate::HolgerGuest,
            &NoHost,
            ops::GUARDIAN_PLAN,
            &wire::encode(&req),
        );
        let planned: Answer<Result<Vec<Step>, String>> = wire::decode(&raw).unwrap();
        assert_eq!(
            order_kinds(&planned.unwrap().unwrap()),
            vec![
                "recharge_from_twin→main",
                "await main recharged",
                "restate_entitlements→main"
            ]
        );
        let req = wire::GuardianObserveReq {
            role: "main".into(),
            what: "recharged".into(),
            view: held,
        };
        let raw = dispatch(
            &crate::HolgerGuest,
            &NoHost,
            ops::GUARDIAN_OBSERVE,
            &wire::encode(&req),
        );
        let seen: Answer<StepState> = wire::decode(&raw).unwrap();
        assert!(matches!(seen.unwrap(), StepState::Waiting(_)));
    }

    #[test]
    fn a_healthy_estate_has_no_finding() {
        assert!(codes(&view(vec![healthy_main(), healthy_twin()])).is_empty());
        assert!(
            codes(&view(vec![healthy_main()])).is_empty(),
            "an estate with no twin at all is not a lost twin"
        );
    }

    #[test]
    fn a_held_main_is_no_finding_of_its_own() {
        // Gunnar's rule (W3 parity): a re-laid main that reports itself held is the rebuild
        // plan's box; its recharge step fills it. No separate finding, auto or not.
        for data in ["fresh", "restoring"] {
            let main = boxed("main", Some(1_000), &[("serving", "no"), ("data", data)]);
            assert!(
                codes(&view(vec![main, healthy_twin()])).is_empty(),
                "{data}"
            );
        }
        // holger has no `diverged`: a main holding its own state is never a recharge case.
        assert!(codes(&view(vec![
            boxed("main", Some(1_000), &[("serving", "yes"), ("data", "ok")]),
            healthy_twin()
        ]))
        .is_empty());
    }

    #[test]
    fn a_lost_main_is_rebuilt_automatically_only_from_a_whole_blind_twin() {
        let silent_main = at(boxed("main", Some(600_000), &[("data", "ok")]), "gone");
        let blind_whole = boxed(
            "twin",
            Some(1_000),
            &[("primary", "silent"), ("complete", "yes")],
        );
        let f = GUARDIAN.diagnose(&view(vec![silent_main.clone(), blind_whole]));
        assert_eq!(
            (f[0].code.as_str(), f[0].auto),
            ("main_lost", Some(Remedy::NewServer))
        );
        assert!(f[0].words.contains("never promoted"), "{}", f[0].words);
        assert_eq!(
            f[0].remedies,
            vec![Remedy::NewServer, Remedy::RechargeFromTwin],
            "Gunnar's two buttons (a silent main's recharge is refused at plan time)"
        );
        let blind_partial = boxed(
            "twin",
            Some(1_000),
            &[
                ("primary", "silent"),
                ("complete", "no"),
                ("why", "3 artifacts failed"),
            ],
        );
        let f = GUARDIAN.diagnose(&view(vec![silent_main.clone(), blind_partial]));
        assert_eq!((f[0].code.as_str(), f[0].auto), ("main_lost", None));
        assert!(f[0].words.contains("3 artifacts failed"), "{}", f[0].words);
        assert_eq!(
            GUARDIAN.diagnose(&view(vec![silent_main, healthy_twin()]))[0].auto,
            None,
            "the twin still sees the primary"
        );
    }

    /// ★ **Row 5 (lane G11): silence alone never lays a main again** (it did, at this file's
    /// `main_lost`, before). The guard (Gunnar's `rebuild::judge`) reads the provider: a main GONE
    /// is laid again by auto-fix; a standing one is booted again first, a stopped one started,
    /// and an unasked provider is no evidence.
    #[test]
    fn auto_fix_lays_a_silent_main_again_only_when_the_provider_says_it_is_gone() {
        let whole = || {
            boxed(
                "twin",
                Some(1_000),
                &[("primary", "silent"), ("complete", "yes")],
            )
        };
        for (word, auto, said) in [
            ("gone", Some(Remedy::NewServer), "GONE"),
            ("active", None, "booted once more"),
            ("stopped", None, "start it"),
            ("", None, "not asked"),
        ] {
            let f = GUARDIAN.diagnose(&view(vec![
                at(boxed("main", Some(600_000), &[]), word),
                whole(),
            ]));
            assert_eq!(
                (f[0].code.as_str(), f[0].auto),
                ("main_lost", auto),
                "{word:?}: {}",
                f[0].words
            );
            assert!(f[0].words.contains(said), "{word:?}: {}", f[0].words);
            assert_eq!(
                f[0].remedies,
                vec![Remedy::NewServer, Remedy::RechargeFromTwin],
                "the buttons stay"
            );
        }
    }

    #[test]
    fn a_silent_twin_is_twin_lost_and_the_main_keeps_serving() {
        let f = GUARDIAN.diagnose(&view(vec![
            healthy_main(),
            boxed("twin", Some(600_000), &[]),
        ]));
        assert_eq!(f.len(), 1);
        assert_eq!(
            (f[0].code.as_str(), f[0].role.as_str(), f[0].auto),
            ("twin_lost", "twin", Some(Remedy::NewTwin))
        );
        let main = boxed(
            "main",
            Some(1_000),
            &[("serving", "yes"), ("data", "ok"), ("twin", "silent")],
        );
        assert_eq!(codes(&view(vec![main])), vec!["twin_lost"]);
    }

    #[test]
    fn both_silent_is_for_a_human() {
        let f = GUARDIAN.diagnose(&view(vec![
            boxed("main", Some(600_000), &[]),
            boxed("twin", Some(600_000), &[]),
        ]));
        assert_eq!(
            (f.len(), f[0].code.as_str(), f[0].auto),
            (1, "both_lost", None)
        );
    }

    #[test]
    fn new_twin_is_laid_told_it_is_the_twin_and_adopted() {
        let v = view(vec![healthy_main(), boxed("twin", Some(600_000), &[])]);
        let steps = GUARDIAN.plan(Remedy::NewTwin, &v).unwrap();
        assert_eq!(
            order_kinds(&steps),
            vec![
                "fence twin",
                "lay twin",
                "await twin heard",
                "become_twin→twin",
                "adopt_twin→main",
                "await main paired"
            ]
        );
        let Step::Order { body, .. } = &steps[3] else {
            panic!()
        };
        assert_eq!(body.get("primary").map(String::as_str), Some("$addr:main"));
        assert!(GUARDIAN
            .plan(
                Remedy::NewTwin,
                &view(vec![boxed("main", Some(600_000), &[]), healthy_twin()])
            )
            .is_err());
    }

    #[test]
    fn new_server_is_gunnars_rebuild_fence_lay_heard_recharge_restate() {
        let blind = || {
            boxed(
                "twin",
                Some(1_000),
                &[
                    ("serving", "no"),
                    ("serves_at", "10.6.0.12:50051"),
                    ("primary", "silent"),
                    ("complete", "yes"),
                ],
            )
        };
        let v = view(vec![at(boxed("main", Some(600_000), &[]), "gone"), blind()]);
        let steps = GUARDIAN.plan(Remedy::NewServer, &v).unwrap();
        assert_eq!(
            order_kinds(&steps),
            vec![
                "fence main",
                "lay main",
                "await main heard",
                "recharge_from_twin→main",
                "await main recharged",
                "restate_entitlements→main"
            ]
        );
        let Step::Order { body, .. } = &steps[3] else {
            panic!()
        };
        assert_eq!(
            (
                body["main"].as_str(),
                body["twin"].as_str(),
                body["epoch"].as_str(),
                body["accept_lost"].as_str()
            ),
            ("$addr:main", "$addr:twin", "$epoch", "false")
        );
        let Step::Order { body, .. } = &steps[5] else {
            panic!()
        };
        assert_eq!(
            body.get("snapshot").map(String::as_str),
            Some("$entitlements")
        );
        assert!(GUARDIAN
            .plan(
                Remedy::NewServer,
                &view(vec![
                    boxed("main", Some(600_000), &[]),
                    boxed("twin", Some(600_000), &[])
                ])
            )
            .is_err());
        // ★ The guard (lane G11): a person's New server destroys no standing disk, lays no
        // second main beside one the twin still hears, and leaves a partial twin to a person.
        let standing = GUARDIAN
            .plan(
                Remedy::NewServer,
                &view(vec![
                    at(boxed("main", Some(600_000), &[]), "active"),
                    blind(),
                ]),
            )
            .unwrap_err();
        assert!(standing.contains("booted once more"), "{standing}");
        let heard = GUARDIAN
            .plan(
                Remedy::NewServer,
                &view(vec![
                    at(boxed("main", Some(600_000), &[]), "gone"),
                    healthy_twin(),
                ]),
            )
            .unwrap_err();
        assert!(heard.contains("still hears its primary"), "{heard}");
        let partial = boxed(
            "twin",
            Some(1_000),
            &[
                ("primary", "silent"),
                ("complete", "no"),
                ("why", "3 artifacts failed"),
            ],
        );
        let partial = GUARDIAN
            .plan(
                Remedy::NewServer,
                &view(vec![at(boxed("main", Some(600_000), &[]), "gone"), partial]),
            )
            .unwrap_err();
        assert!(partial.contains("3 artifacts failed"), "{partial}");
        // An operator's estate (monetize laid no main: no provider word): the twin's half of the
        // guard decides here, and the operator's kit confirms before it destroys anything.
        assert!(GUARDIAN
            .plan(
                Remedy::NewServer,
                &view(vec![boxed("main", Some(600_000), &[]), blind()])
            )
            .is_ok());
    }

    #[test]
    fn recharge_runs_only_on_a_fresh_or_restoring_main() {
        let fresh = boxed("main", Some(1_000), &[("serving", "no"), ("data", "fresh")]);
        let steps = GUARDIAN
            .plan(Remedy::RechargeFromTwin, &view(vec![fresh, healthy_twin()]))
            .unwrap();
        assert_eq!(
            order_kinds(&steps),
            vec![
                "recharge_from_twin→main",
                "await main recharged",
                "restate_entitlements→main"
            ]
        );
        let serving = boxed(
            "main",
            Some(1_000),
            &[("serving", "yes"), ("data", "restoring")],
        );
        let steps = GUARDIAN
            .plan(
                Remedy::RechargeFromTwin,
                &view(vec![serving, healthy_twin()]),
            )
            .unwrap();
        assert_eq!(order_kinds(&steps)[0], "fence_primary→main");
        let holding = GUARDIAN
            .plan(
                Remedy::RechargeFromTwin,
                &view(vec![healthy_main(), healthy_twin()]),
            )
            .unwrap_err();
        assert!(
            holding.contains("FRESH") && holding.contains("New server"),
            "{holding}"
        );
        assert!(GUARDIAN
            .plan(
                Remedy::RechargeFromTwin,
                &view(vec![
                    boxed("main", Some(1_000), &[("data", "fresh")]),
                    boxed("twin", Some(600_000), &[])
                ])
            )
            .is_err());
    }

    #[test]
    fn review_twin_and_restore_front_are_gunnars_shape() {
        assert_eq!(
            order_kinds(
                &GUARDIAN
                    .plan(
                        Remedy::ReviewTwin,
                        &view(vec![healthy_main(), healthy_twin()])
                    )
                    .unwrap()
            ),
            vec!["review_twin→main"]
        );
        let steps = GUARDIAN
            .plan(
                Remedy::RestoreFront,
                &view(vec![boxed("main", Some(600_000), &[]), healthy_twin()]),
            )
            .unwrap();
        assert_eq!(
            order_kinds(&steps),
            vec!["restore front", "await twin heard"]
        );
    }

    #[test]
    fn observe_reads_paired_and_recharged_from_the_report() {
        let v = view(vec![healthy_main(), healthy_twin()]);
        assert!(matches!(
            GUARDIAN.observe("main", "paired", &v),
            StepState::Met(_)
        ));
        assert!(matches!(
            GUARDIAN.observe("main", "recharged", &v),
            StepState::Met(_)
        ));
        let v = view(vec![boxed(
            "main",
            Some(1_000),
            &[("data", "restoring"), ("twin", "none")],
        )]);
        assert!(matches!(
            GUARDIAN.observe("main", "paired", &v),
            StepState::Waiting(_)
        ));
        assert!(matches!(
            GUARDIAN.observe("main", "recharged", &v),
            StepState::Waiting(_)
        ));
        let v = view(vec![boxed(
            "main",
            Some(1_000),
            &[("data", "restoring"), ("recharge", "failed")],
        )]);
        assert!(matches!(
            GUARDIAN.observe("main", "recharged", &v),
            StepState::Failed(_)
        ));
        assert!(matches!(
            GUARDIAN.observe("main", "dancing", &v),
            StepState::Failed(_)
        ));
        // ★ Owner 2026-11-03: Gunnar's rule for Holger too — a main that reports its data whole
        // is recharged, whatever an older `recharge` word still says.
        let v = view(vec![boxed(
            "main",
            Some(1_000),
            &[("data", "ok"), ("recharge", "failed")],
        )]);
        assert!(matches!(
            GUARDIAN.observe("main", "recharged", &v),
            StepState::Met(_)
        ));
    }
}