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

nordisk/monetize

crates/monetize-product/src/guardian.rs

//! **The estate guardian lane** (5.4 resilience): what a product knows about repairing one
//! of its estates, as a PLAN — never as a call into the estate.
//!
//! Monetize never calls INTO an estate. The estate's boxes report (`Fleet.Report`), and the
//! guardian reads those reports, opens a case, takes a human's button (or the estate's
//! auto-fix opt-in), does the CLOUD part itself through `monetize_cloud::Cloud`
//! (fence, lay), and queues SIGNED orders that the boxes pull and run through the product's
//! own repair verbs (gunnar: `gunnar-repair`) — the same functions their local admin button
//! and their boot check run. This trait is the product's half: diagnose, plan, observe.
//! The runner (cases, approvals, the order queue, the fence invariant) is
//! `monetize-guardian`, shared by every product.
//!
//! It replaced the dial-in restore and twinship lanes, which are gone (no legacy).

use std::collections::BTreeMap;

/// The guard before a main's data is destroyed (Gunnar's `rebuild::judge`, R1–R6), shared by
/// every product's guardian (lane G11).
pub mod rebuild;

/// The plan's building blocks every product's guardian writes the same way (P4a): `order`,
/// `wait`, `recharge`, `restate`, `heard`, `word` and the three bounds.
pub mod steps;

/// The roles of an estate's boxes.
pub mod role {
    pub const MAIN: &str = "main";
    pub const TWIN: &str = "twin";
    pub const FRONT: &str = "front";
}

/// **The buttons** — one per repair the guardian can drive.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Remedy {
    /// The main's data is lost or diverged: the twin's log is pumped into it (a fresh main
    /// first when the old box is gone).
    RechargeFromTwin,
    /// The primary reviews its twin and reports the `TwinReview`. Nothing changes.
    ReviewTwin,
    /// The twin is gone: a new box is laid, installed, and the primary adopts it.
    NewTwin,
    /// The main is gone: a new box is laid, installed, and recharged from the twin.
    NewServer,
    /// The front is lost: restored from its provider's daily backup (S8, 2026-10-11) — its own
    /// keys kept, nothing re-trusted. No backup: refused; the front is re-laid by hand (OM-6).
    RestoreFront,
}

impl Remedy {
    pub const ALL: [Remedy; 5] = [Remedy::RechargeFromTwin, Remedy::ReviewTwin, Remedy::NewTwin, Remedy::NewServer, Remedy::RestoreFront];

    pub fn as_str(self) -> &'static str {
        match self {
            Remedy::RechargeFromTwin => "recharge_from_twin",
            Remedy::ReviewTwin => "review_twin",
            Remedy::NewTwin => "new_twin",
            Remedy::NewServer => "new_server",
            Remedy::RestoreFront => "restore_front",
        }
    }

    pub fn parse(s: &str) -> Option<Remedy> {
        Remedy::ALL.into_iter().find(|r| r.as_str() == s)
    }

    /// The button's words.
    pub fn words(self) -> &'static str {
        match self {
            Remedy::RechargeFromTwin => "Recharge from twin",
            Remedy::ReviewTwin => "Review twin",
            Remedy::NewTwin => "New twin",
            Remedy::NewServer => "New server",
            Remedy::RestoreFront => "Restore front from backup",
        }
    }
}

/// One box as monetize last HEARD it — nothing here was dialled.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct BoxView {
    pub role: String,
    /// When its newest `Fleet.Report` arrived (unix ms); 0: never heard.
    pub heard_unix_ms: u64,
    pub build: String,
    /// The box's own health words, flat (`serving`, `data`, `serves_at`, `twin`, …).
    pub health: BTreeMap<String, String>,
    /// The product's twin review as the box last reported it (JSON text), empty when none.
    pub twin_review: String,
    /// The orders it acked, newest last.
    pub acks: Vec<AckView>,
    /// Where the cloud laid it (its utility IPv4), empty when monetize laid nothing.
    pub laid_ipv4: String,
    /// What the provider says of the box, when the runner asked (`active`, `stopped`,
    /// `gone`); empty: not asked.
    pub at_provider: String,
}

impl BoxView {
    /// Silent: never heard, or not heard for `silent_after_ms`.
    pub fn silent(&self, now_unix_ms: u64, silent_after_ms: u64) -> bool {
        self.heard_unix_ms == 0 || now_unix_ms.saturating_sub(self.heard_unix_ms) > silent_after_ms
    }

    pub fn word(&self, key: &str) -> &str {
        self.health.get(key).map(String::as_str).unwrap_or("")
    }
}

/// One order a box acked.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AckView {
    pub seq: u64,
    pub kind: String,
    pub ok: bool,
    pub detail: String,
}

/// **One estate, as the guardian sees it**: its boxes by role, read from reports.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct EstateView {
    pub product: String,
    pub instance: String,
    pub now_unix_ms: u64,
    /// A box not heard for this long is silent (3 report intervals).
    pub silent_after_ms: u64,
    pub boxes: BTreeMap<String, BoxView>,
}

impl EstateView {
    pub fn get(&self, role: &str) -> Option<&BoxView> {
        self.boxes.get(role)
    }

    /// The box in `role` is silent (or was never heard, or does not exist).
    pub fn silent(&self, role: &str) -> bool {
        self.boxes.get(role).is_none_or(|b| b.silent(self.now_unix_ms, self.silent_after_ms))
    }
}

/// **What is wrong**, with what shows it.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Finding {
    /// Stable code: `twin_lost`, `main_lost`, `main_data_lost`, …
    pub code: String,
    /// The box it is about.
    pub role: String,
    /// One sentence for the case screen.
    pub words: String,
    /// The reported facts it rests on.
    pub evidence: Vec<String>,
    /// The buttons that answer it, the recommended first.
    pub remedies: Vec<Remedy>,
    /// What auto-fix (when the estate opted in) may take on its own; `None`: only a human.
    pub auto: Option<Remedy>,
}

/// The cloud half of a step: done by the runner through `Cloud`, never by the estate.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CloudStep {
    /// The role's old box given back WHOLE — its server (stopped by the provider's destroy),
    /// every disk and its release clone, as the laid record names them — until the
    /// provider's own listing holds none of it (`Change::Retire`). Idempotent.
    Fence { role: String },
    /// One box laid again for `role` through the shop's lay + installer path. The runner
    /// refuses it while anything of the role's old box still stands, or cannot be looked
    /// up (the fence invariant).
    Lay { role: String },
    /// The role's box restored from its provider's newest backup (S8: the FRONT, whose disk
    /// holds the estate's keys) — in place, or made again from the backup at a new address.
    /// Lays nothing; the shop's own restore path (`World::restore_one`).
    Restore { role: String },
}

/// **One step of a plan.**
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Step {
    Cloud(CloudStep),
    /// A signed order queued for the box in `role`; met when it acks `ok`, failed when it
    /// acks not ok. String values `$addr:<role>` in `body` are filled in when queued with
    /// that box's address (its `serves_at`, else its laid IPv4); `$epoch` with the case's
    /// fence epoch; `$principal` with who approved.
    Order { role: String, kind: String, body: BTreeMap<String, String> },
    /// Wait for the estate to show something: `heard` (a report from `role` newer than the
    /// step) is the runner's; anything else is [`EstateGuardian::observe`]'s.
    Await { role: String, what: String, within_ms: u64 },
}

impl Step {
    pub fn words(&self) -> String {
        match self {
            Step::Cloud(CloudStep::Fence { role }) => format!("fence {role} (server and disks given back, read back gone)"),
            Step::Cloud(CloudStep::Lay { role }) => format!("lay a new {role} (installer)"),
            Step::Cloud(CloudStep::Restore { role }) => format!("restore {role} from its daily backup"),
            Step::Order { role, kind, .. } => format!("order {kind} → {role}"),
            Step::Await { role, what, .. } => format!("await {role} {what}"),
        }
    }
}

/// What [`EstateGuardian::observe`] says of an `Await`.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum StepState {
    Waiting(String),
    Met(String),
    Failed(String),
}

/// **The product's guardian**: pure functions over what the estate reported.
pub trait EstateGuardian: Send + Sync {
    /// What is wrong now. Empty: nothing.
    fn diagnose(&self, view: &EstateView) -> Vec<Finding>;
    /// The steps that carry out `remedy` on this estate, or why it may not run now.
    fn plan(&self, remedy: Remedy, view: &EstateView) -> Result<Vec<Step>, String>;
    /// Is the `Await` step met? The runner answers `heard` itself and never asks.
    fn observe(&self, role: &str, what: &str, view: &EstateView) -> StepState;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_remedy_round_trips_its_wire_name() {
        for r in Remedy::ALL {
            assert_eq!(Remedy::parse(r.as_str()), Some(r));
        }
        assert_eq!(Remedy::parse("rollback"), None);
        assert_eq!(serde_json_like(Remedy::NewTwin), "new_twin");
    }

    fn serde_json_like(r: Remedy) -> &'static str {
        r.as_str()
    }

    #[test]
    fn a_box_never_heard_is_silent_and_a_fresh_one_is_not() {
        let mut v = EstateView { now_unix_ms: 100_000, silent_after_ms: 30_000, ..Default::default() };
        assert!(v.silent("twin"));
        v.boxes.insert("twin".into(), BoxView { role: "twin".into(), heard_unix_ms: 90_000, ..Default::default() });
        assert!(!v.silent("twin"));
        v.now_unix_ms = 121_000;
        assert!(v.silent("twin"));
    }
}