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

nordisk/gunnar

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

//! **Gunnar's estate guardian, from the guest** (2026-10-19, monetize lane W3): the ONE box
//! links no product, so Gunnar's guardian reaches it only through this signed guest's
//! `guardian.diagnose` / `guardian.plan` / `guardian.observe` (ops 100-102).
//!
//! These are the rules of Gunnar's linked guardian (`monetize/products/gunnar/src/guardian.rs`),
//! word for word: `tests/the_guest_guards_its_estate.rs` holds every finding, plan and
//! observation to the linked `GUARDIAN` itself. The owner's decisions (2026-10-12) are in them:
//! a dead main is rebuilt from a level twin (fence, lay, recharge — never promoted), a twin that
//! lacks entries leaves it to a person, and nothing grows by itself; the runner applies an auto
//! remedy only on an estate whose auto-fix is on.
//!
//! 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 `gunnar-repair`'s, run by
//! the box that pulled the signed order:
//!
//! | order | body | run by |
//! |---|---|---|
//! | `recharge_from_twin` | `{main, twin, accept_lost, epoch}` | the main (fresh or data-lost) |
//! | `fence_primary` | `{primary, epoch}` | the main, on itself, before a recharge while it still serves |
//! | `adopt_twin` | `{twin, principal}` | the main, once a new twin called home |
//! | `review_twin` | `{}` | the main |
//! | `restate_entitlements` | `{snapshot}` (signed `Snapshot`) | the main, once recharged and serving |
//!
//! Health words a gunnar box reports (flat): `serving` (`yes`/`no`/`fenced`), `data`
//! (`ok`/`lost`/`diverged`/`fresh`), `serves_at` (`host:port`), `twin` (`paired`/`silent`/`none`,
//! on the main), `primary` (`live`/`silent`, on the twin), `epoch`.

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

/// The one gunnar guardian (stateless).
pub static GUARDIAN: GunnarGuardian = GunnarGuardian;

pub struct GunnarGuardian;

use monetize_product_guest::guardian::steps::{self, heard, order, recharge, restate, wait, word};
/// The bounds and the plan's building blocks are the ones every product's guardian writes the
/// same way: `monetize_product::guardian::steps` (P4a; T16 / P4-REST, A28 M11), as Holger's
/// guest builds its plans.
pub use monetize_product_guest::guardian::steps::{
    HEARD_WITHIN_MS, PAIRED_WITHIN_MS, RECHARGED_WITHIN_MS,
};

/// **Gunnar'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 it lacks no entry (`lag_entries` 0, an
/// older twin that reports none is taken at its word); the highest entry it holds is off its
/// own review (`twin_review`, `head_seq`/`applied_seq`), for the guard's R4 against the main's
/// last `data_head`. `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 lag: u64 = b.word("lag_entries").parse().unwrap_or(0);
    let review: Option<serde_json::Value> = serde_json::from_str(&b.twin_review).ok();
    let holds = review.as_ref().and_then(|r| {
        Some(
            r.get("head_seq")?
                .as_u64()?
                .max(r.get("applied_seq").and_then(|a| a.as_u64()).unwrap_or(0)),
        )
    });
    Some(Twin {
        addr: rebuild::twin_addr(v),
        hears_primary: b.word("primary") != "silent",
        recharge_possible: lag == 0,
        verdict: if lag == 0 {
            "it lacks no entry".into()
        } else {
            format!("it lacks {lag} entr(y/ies) the main wrote")
        },
        holds,
    })
}

impl EstateGuardian for GunnarGuardian {
    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();
        let data = word(v, role::MAIN, "data");
        if !main_silent && matches!(data, "lost" | "diverged") {
            out.push(Finding {
                code: "main_data_lost".into(),
                role: role::MAIN.into(),
                words: format!("the main reports its data {data}: recharge from the twin"),
                evidence: evidence.clone(),
                remedies: vec![Remedy::RechargeFromTwin],
                auto: twin_up.then_some(Remedy::RechargeFromTwin),
            });
        }
        if main_silent && twin_up {
            let twin_blind = v
                .get(role::TWIN)
                .is_some_and(|b| b.word("primary") == "silent");
            // ★ Main rebuild (owner 2026-10-12), the guard's R4 over the health words: a twin
            // that reports entries it never applied (`lag_entries` > 0) cannot give the
            // estate back whole — a person decides; auto-fix does not rebuild from it.
            let lag: u64 = v
                .get(role::TWIN)
                .map(|b| b.word("lag_entries"))
                .and_then(|w| w.parse().ok())
                .unwrap_or(0);
            let words = match (twin_blind, lag) {
                (true, 0) => "the main is silent, and the twin cannot see it either: the main is rebuilt from the twin (never promoted)".to_string(),
                (true, n) => format!("the main is silent, and the twin cannot see it either — but the twin lacks {n} entr(y/ies) the main wrote: a person decides (accept the loss, or bring the main's disk back)"),
                (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 twin that can give it back and
            // does not hear it, the main not ahead of 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),
                    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 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!(
                "gunnar's guardian does not know how to await {other:?}"
            )),
        }
    }
}