monetize-product 0.3.0

The product plugin trait for monetize: how a metered product reports Usage and receives an Entitlement. Types and one trait, serde only — no ledger, no vendor, no network.
Documentation
//! **The plan's building blocks every product's guardian writes the same way** (P4a; A22 W10,
//! A28 §4 "guardian helpers"): moved from Holger's guest (`holger-monetize-guest/src/guardian.rs`)
//! and Gunnar's (`gunnar-monetize-guest/src/guardian.rs`), which carried the same `order`,
//! `wait`, `recharge`, `restate`, `heard` and the same three bounds. What stays the product's:
//! its findings, which steps a remedy takes, and what its boxes' words mean.

use super::{EstateView, Step};

/// How long a laid box has to call home (cloud create + installer + first boot).
pub const HEARD_WITHIN_MS: u64 = 30 * 60_000;
/// How long the main has to show its new (or re-pointed) twin paired after adopting it.
pub const PAIRED_WITHIN_MS: u64 = 10 * 60_000;
/// How long a recharge may take to show the main's data whole again.
pub const RECHARGED_WITHIN_MS: u64 = 6 * 3_600_000;

/// A signed order of `kind` for the box in role `to`, its body's string values as given
/// (`$addr:<role>`, `$epoch`, `$principal` are filled in when the runner queues it).
pub fn order(to: &str, kind: &str, body: &[(&str, &str)]) -> Step {
    Step::Order { role: to.into(), kind: kind.into(), body: body.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() }
}

/// Wait for the box in role `r` to show `what` within `within_ms`.
pub fn wait(r: &str, what: &str, within_ms: u64) -> Step {
    Step::Await { role: r.into(), what: what.into(), within_ms }
}

/// The main filled from its twin (`recharge_from_twin`), at the case's fence epoch.
pub fn recharge() -> Step {
    order(super::role::MAIN, "recharge_from_twin", &[("main", "$addr:main"), ("twin", "$addr:twin"), ("accept_lost", "false"), ("epoch", "$epoch")])
}

/// The money back on a recharged main: monetize's books as a signed snapshot it pulls. The
/// twin carries the caps on a good day; only monetize can re-state a sold cap on the days it
/// does not.
pub fn restate() -> Step {
    order(super::role::MAIN, "restate_entitlements", &[("snapshot", "$entitlements")])
}

/// One line of evidence about a box: when it was heard and what it said.
pub fn heard(v: &EstateView, r: &str) -> String {
    match v.get(r) {
        None => format!("the {r}: no box registered"),
        Some(b) if b.heard_unix_ms == 0 => format!("the {r}: never heard since its key was registered"),
        Some(b) => {
            let words: Vec<String> = b.health.iter().map(|(k, v)| format!("{k}={v}")).collect();
            format!("the {r}: last report {} s ago ({})", v.now_unix_ms.saturating_sub(b.heard_unix_ms) / 1000, words.join(" "))
        }
    }
}

/// The health word `key` of the box in role `r` (empty: no such box, or it said none).
pub fn word<'a>(v: &'a EstateView, r: &str, key: &str) -> &'a str {
    v.get(r).map(|b| b.word(key)).unwrap_or("")
}

#[cfg(test)]
mod tests {
    use super::super::{role, BoxView};
    use super::*;
    use std::collections::BTreeMap;

    fn view() -> EstateView {
        let mut boxes = BTreeMap::new();
        boxes.insert("main".to_string(), BoxView { role: "main".into(), heard_unix_ms: 40_000, health: BTreeMap::from([("data".into(), "ok".into()), ("serving".into(), "yes".into())]), ..BoxView::default() });
        boxes.insert("twin".to_string(), BoxView { role: "twin".into(), ..BoxView::default() });
        EstateView { product: "p".into(), instance: "acme".into(), now_unix_ms: 100_000, silent_after_ms: 90_000, boxes }
    }

    #[test]
    fn an_order_and_a_wait_are_the_steps_the_runner_reads() {
        assert_eq!(order(role::TWIN, "become_twin", &[("primary", "$addr:main")]), Step::Order { role: "twin".into(), kind: "become_twin".into(), body: BTreeMap::from([("primary".to_string(), "$addr:main".to_string())]) });
        assert_eq!(wait(role::MAIN, "paired", PAIRED_WITHIN_MS), Step::Await { role: "main".into(), what: "paired".into(), within_ms: 600_000 });
        assert_eq!((HEARD_WITHIN_MS, RECHARGED_WITHIN_MS), (1_800_000, 21_600_000));
    }

    #[test]
    fn the_recharge_and_the_restate_name_the_main_and_the_runners_fills() {
        let Step::Order { role, kind, body } = recharge() else { panic!("an order") };
        assert_eq!((role.as_str(), kind.as_str()), ("main", "recharge_from_twin"));
        assert_eq!(body.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>(), ["accept_lost=false", "epoch=$epoch", "main=$addr:main", "twin=$addr:twin"]);
        assert_eq!(restate(), order("main", "restate_entitlements", &[("snapshot", "$entitlements")]));
    }

    #[test]
    fn the_evidence_says_when_each_box_was_heard_and_what_it_said() {
        let v = view();
        assert_eq!(heard(&v, "main"), "the main: last report 60 s ago (data=ok serving=yes)");
        assert_eq!(heard(&v, "twin"), "the twin: never heard since its key was registered");
        assert_eq!(heard(&v, "front"), "the front: no box registered");
        assert_eq!((word(&v, "main", "serving"), word(&v, "main", "twin"), word(&v, "front", "data")), ("yes", "", ""));
    }
}