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-guest/src/facts.rs

//! **The product's facts** (A24 §2.2, audit A28 H11 and H12; v2.0 P3): the ONE answer to
//! `lifecycle.facts` (op 90), what an estate of the product IS — its lifecycle, its contract
//! with the shop (P3a), the sale proof, the feed keys its boxes read and the order form a buyer
//! fills in. [`crate::wire::LifecycleFacts`] is this type under its old name. The shop reads
//! the product here and nowhere else: monetize's core names no product.
//!
//! Every field is defaulted, so an older guest's answer (no contract, no proof, no form) reads
//! as a `Facts`; what it does not declare the shop refuses by name, never guesses.
//!
//! **Every field, mapped** (A28 H11):
//!
//! | before P3 | Facts |
//! |---|---|
//! | `LifecycleFacts.kind`, `.update_order`, `.front_role`, `.keep_medium`, `.medium_of`, `.other_medium`, `.backup_roles`, `.words`, `.default_shape`, `.data_roles`, `.live_slot`, `.upgraded_by_shop`, `.app_medium`, `.front_medium`, `.front_keep_medium` | the same names |
//! | `LifecycleFacts.proof_name` (the TXT proof of a buyer's own domain) | [`Facts::domain_proof_name`] (renamed so it does not clash with [`Facts::proof`]; the old name is still read) |
//! | shop-pairing `ProductContract.product` | [`Facts::kind`] |
//! | `ProductContract.billing`, `.offsite`, `.boxes_hold_console_token`, `.welcome`, `.sold_variant`, `.site_variant`, `.box_kind`, `.free_feed`, `.console_token_feed`, `.passkey_rp_feed` | the same names |
//! | `ProductContract.doors`, `.repos`, the judges of `sale_check` | [`Facts::proof`], a [`SaleProof`] |
//! | shop-pairing `welcome_readme` (Gunnar's README, in the core) | [`Facts::welcome_readme`] |
//! | `estate_order::RESERVED_ACCOUNTS`' `gunnar-ui`, `gunnar-guest` | [`Facts::reserved_accounts`] |
//! | the three Gunnar sizes as the only order (proto `EstateSizes`) | [`Facts::form`], an [`OrderForm`] the order is checked against |
//! | `Lifecycle::primary_role`'s rule (the first data role) | [`Facts::primary_role`] ([`Facts::primary`]) |
//! | `Lifecycle::roles`' rule, `monetize_cloud::lay::ROLES` (front, main, twin) | [`Facts::roles`], [`RoleFacts`] ([`Facts::role_names`]) |
//! | `estate_order::twin::REVIEWING_ROLES` (main, twin), the `twin` the shop grows | [`Facts::twin`], [`TwinFacts`] ([`Facts::twin_of`]) |
//! | change.rs "data never shrinks" (`main_data_gib`), a change of the numbers refused | [`Facts::grow`], [`GrowRule`] ([`Facts::grow_rule`]) |
//!
//! **The update order is the front LAST** (A28 H17): [`Facts::update_order`] names the boxes in
//! the order an update takes them, and the [`Facts::front_role`] drives the update and is
//! upgraded last (Gunnar and Holger: `twin`, `main`, `front`).

use std::collections::BTreeMap;

use serde_json::Value;

use crate::wire::{WireDataRole, WireWords};

/// **A product's facts** (A24 §2.2): the lifecycle, the contract fields (A28 H11), the sale
/// proof (A28 H12), the feed keys and the order form. Every field is defaulted: `{}` is a
/// `Facts`, and so is the `lifecycle.facts` answer of a guest built before a field.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Facts {
    /// The product kind (`gunnar`); today `LifecycleFacts.kind` and `ProductContract.product`.
    #[serde(default)]
    pub kind: String,
    /// The boxes in the order an update takes them: **the front LAST** (A28 H17). The front
    /// drives the update and is upgraded last (Gunnar and Holger: `twin`, `main`, `front`).
    #[serde(default)]
    pub update_order: Vec<String>,
    /// The medium kind the app boxes' release is read off.
    #[serde(default)]
    pub app_medium: String,
    /// The medium kind the driving box's (the front's) release is read off.
    #[serde(default)]
    pub front_medium: String,
    /// The role that drives an update and is upgraded LAST.
    #[serde(default)]
    pub front_role: String,
    /// The medium kind a box the shop upgrades is keep-booted from.
    #[serde(default)]
    pub keep_medium: String,
    /// The medium kind the front is keep-booted from by the shop.
    #[serde(default)]
    pub front_keep_medium: String,
    /// The roles the shop keep-boots in an upgrade.
    #[serde(default)]
    pub upgraded_by_shop: Vec<String>,
    /// The medium kind of each role; a role not named takes `other_medium`.
    #[serde(default)]
    pub medium_of: BTreeMap<String, String>,
    #[serde(default)]
    pub other_medium: String,
    /// The roles whose disks a terminate's final backup keeps.
    #[serde(default)]
    pub backup_roles: Vec<String>,
    /// How the shop names the product to its buyer.
    #[serde(default)]
    pub words: WireWords,
    /// The shape an `[estate_orders]` naming none is laid at; `null`: the config must name one.
    #[serde(default)]
    pub default_shape: Value,
    /// The machines whose data disks a bought data set grows, the primary first.
    #[serde(default)]
    pub data_roles: Vec<WireDataRole>,
    /// The TXT proof a customer's own domain carries (`<domain_proof_name>=<token>`); empty:
    /// `<kind>-proof`. Today's `proof_name`, renamed (A28 H11) so it does not clash with
    /// [`Facts::proof`]; an answer that says `proof_name` is still read.
    #[serde(default, alias = "proof_name")]
    pub domain_proof_name: String,
    /// Whether the product's boxes take a live update in a slot of their own. Absent: a live
    /// slot, as `LifecycleFacts` reads it.
    #[serde(default = "a_live_slot")]
    pub live_slot: bool,
    /// The product's main mints actor tickets (op 40, merged); the host checks them.
    #[serde(default)]
    pub tickets: bool,
    /// The test buttons the product declares (op 54, merged).
    #[serde(default)]
    pub test_actions: Vec<monetize_product::TestAction>,
    /// The product ships an estate guardian (op 60's `guardian`, merged).
    #[serde(default)]
    pub guardian: bool,

    // ---- The product's contract with the shop (A28 H11). `None`: the guest does not say, and
    // the shop refuses by name what needs it (it holds no product's contract of its own).
    /// Where a sold estate's pay page is.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub billing: Option<Billing>,
    /// Its main ships an off-site copy (the shop mints and feeds the off-site keys).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub offsite: Option<bool>,
    /// Its boxes serve the console's login themselves (the main and the twin are fed the bearer).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub boxes_hold_console_token: Option<bool>,
    /// Its primary makes the buyer's welcome repository (`<account>/welcome`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub welcome: Option<bool>,
    /// The variant a sold estate is recorded as.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sold_variant: Option<String>,
    /// The variant an operator site the ONE box laid itself is recorded as.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub site_variant: Option<String>,
    /// The pairing kind (nordisk-pairing `seed::Kind`, by its name) the product's boxes are
    /// seeded and planned as: `gunnar`, `holger`, or a product's own box kind (`Kind::Box`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub box_kind: Option<String>,
    /// The feed key that makes the sold front's own product free.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub free_feed: Option<String>,

    /// **The feed key the sold front reads its console bearer from** (Gunnar's
    /// `console_admin_token`, Holger's `holger_console_token`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub console_token_feed: Option<String>,
    /// **The feed key its boxes bind their passkey plane to** (fed the estate's host); `None`:
    /// its boxes hold no passkeys (Gunnar's console holds them on the front).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub passkey_rp_feed: Option<String>,
    /// **Its main and twin take a site's public repositories** (`[estates.<label>]
    /// public_repositories`, fed as `public_repositories` at lay and in a refeed; NIGHT-PUBREPO).
    /// `Some(false)`: its boxes take no such key (Gunnar's refuses a refeed that carries it,
    /// whole), so the shop feeds none. Absent: fed as declared, the rule before this field (a
    /// guest built before it cannot say it) — [`Facts::takes_public_repositories`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub public_repositories: Option<bool>,
    /// **Its main takes the site's public-site switch again** (`console_public_site = on | off`
    /// in a refeed; GRS-PUBLIC 2026-10-28): `Some(true)` for a product whose main stores the
    /// switch its console follows (Gunnar's). Absent: its boxes are never fed it in a refeed
    /// (a box that does not know the key refuses the refeed whole) — [`Facts::takes_public_site`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub public_site: Option<bool>,
    /// **The account names its estate keeps for its own machines** (Gunnar's `gunnar-ui`,
    /// `gunnar-guest`): no buyer's account may take one.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub reserved_accounts: Vec<String>,
    /// **The buyer's welcome README** the primary makes `<account>/welcome` from, with `{host}`,
    /// `{account}` and `{repo}` filled in. Asked for exactly when [`Facts::welcome`] is true.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub welcome_readme: Option<String>,

    /// **What proves a sale** (A28 H12): the public routes the sale check reads on the sold
    /// front and what each must answer. `None`: no sale of the product is proven, so none is
    /// captured.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub proof: Option<SaleProof>,

    /// **What a buyer sets in an order** (v2.0 P3, the parametric order): the fields of the
    /// order the wire carries (`EstateSizes.order`), checked by the shop before anything is
    /// priced or held. `None`: the product takes no order of its own (its plan reads the sizes).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub form: Option<OrderForm>,

    // ---- What an estate of the product IS, as data (A24 §2.2, P3-FACTS): before these, the
    // core decided them by Gunnar's shape (front, main, twin; main's data never shrinks).
    /// **The role that holds the tenants** (the ticket key, the backups; the report the shop
    /// meters a pulled product by). Empty: the first data role, else the front (the rule the
    /// core held before P3-FACTS) — [`Facts::primary`].
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub primary_role: String,
    /// **Every role the product's plan may lay**, each once (a role laid n times is ONE role,
    /// [`RoleFacts::many`]). Empty: the roles the lifecycle fields name — [`Facts::role_names`].
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub roles: Vec<RoleFacts>,
    /// **The twin**: the role that mirrors another and is elected by its load. `None`: the data
    /// role that mirrors the primary, if one does (the rule before P3-FACTS; the Lake has none) —
    /// [`Facts::twin_of`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub twin: Option<TwinFacts>,
    /// **What may change on a running estate** (a plan change). `None`: [`GrowRule::sizes`],
    /// the rule before P3-FACTS — [`Facts::grow_rule`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub grow: Option<GrowRule>,
    /// **The order kinds its boxes run** (A28 M23): the core's kinds
    /// (`monetize_product::pull::order::CORE`) it has a verb for, and its guardian's own. A box
    /// refuses any other kind BY NAME ([`Facts::runs`]). Empty: not declared, and the box decides
    /// as before.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub order_kinds: Vec<String>,
    /// **The product's OWN order kinds the operator may queue on one of his sites**
    /// (MONETIZE-FIX 2026-11-01, replacing the console relay): each a kind, the role whose box
    /// runs it, its words and its params. The ONE box checks an ask against the declaration,
    /// signs it as a [`monetize_product::pull::ProductOrder`] and queues it; the box pulls and runs
    /// it and acks with its answer. Data only: the core knows no kind by name.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub orders: Vec<OrderKindFacts>,
}

/// **One order kind a product declares** ([`Facts::orders`]).
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct OrderKindFacts {
    /// The kind: a plain word (`a-z`, `0-9`, `_`), never a core kind.
    pub kind: String,
    /// The role whose box runs it. Empty: the primary role.
    #[serde(default)]
    pub role: String,
    /// What it does, for the operator's button and the CLI's list.
    #[serde(default)]
    pub words: String,
    /// The params it takes; any other is refused by name.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub params: Vec<OrderParamFacts>,
    /// The one param that is a FILE (its value is the operator's file, kept on the ONE box and
    /// pulled by the box: `monetize_product::pull::ask::FETCH`). Empty: none.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub file: String,
}

/// **One param of a declared order kind.**
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct OrderParamFacts {
    pub name: String,
    #[serde(default)]
    pub words: String,
    #[serde(default)]
    pub required: bool,
}

/// Pure: a kind or param name is a plain word.
fn plain_word(w: &str) -> bool {
    !w.is_empty()
        && w.len() <= 64
        && w.bytes()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
}

impl OrderKindFacts {
    /// Pure: **the declaration is one the core takes**: a plain kind that is no core kind, plain
    /// param names each once, the file one of them. `Err` names what is wrong.
    pub fn check(&self) -> Result<(), String> {
        if !plain_word(&self.kind) || monetize_product::pull::is_core(&self.kind) {
            return Err(format!("the order kind {:?} is not a plain word of the product's own (a-z, 0-9, _; never a core kind)", self.kind));
        }
        let mut seen = std::collections::BTreeSet::new();
        for p in &self.params {
            if !plain_word(&p.name) || !seen.insert(p.name.as_str()) {
                return Err(format!(
                    "the order kind {}'s param {:?} is not a plain word named once",
                    self.kind, p.name
                ));
            }
        }
        if !self.file.is_empty() && !seen.contains(self.file.as_str()) {
            return Err(format!(
                "the order kind {}'s file {:?} is none of its params",
                self.kind, self.file
            ));
        }
        Ok(())
    }

    /// Pure: **`given` as the declaration takes it**: every required param there, no other.
    /// `file`: whether a file was given for [`OrderKindFacts::file`].
    pub fn admits(&self, given: &BTreeMap<String, String>, file: bool) -> Result<(), String> {
        if let Some(k) = given
            .keys()
            .find(|k| !self.params.iter().any(|p| &p.name == *k) || **k == self.file)
        {
            return Err(format!(
                "{} takes no param {k:?} (it takes {})",
                self.kind,
                self.param_list()
            ));
        }
        if let Some(p) = self.params.iter().find(|p| {
            p.required
                && p.name != self.file
                && given.get(&p.name).is_none_or(|v| v.trim().is_empty())
        }) {
            return Err(format!("{} needs {}: {}", self.kind, p.name, p.words));
        }
        match (self.file.is_empty(), file) {
            (true, true) => Err(format!("{} takes no file", self.kind)),
            (false, false)
                if self
                    .params
                    .iter()
                    .any(|p| p.name == self.file && p.required) =>
            {
                Err(format!("{} needs the file {}", self.kind, self.file))
            }
            _ => Ok(()),
        }
    }

    /// The params' names, for the words.
    pub fn param_list(&self) -> String {
        if self.params.is_empty() {
            return "none".into();
        }
        self.params
            .iter()
            .map(|p| {
                if p.required {
                    format!("{}*", p.name)
                } else {
                    p.name.clone()
                }
            })
            .collect::<Vec<_>>()
            .join(", ")
    }
}

impl Facts {
    /// **The declared order kind `kind`**, or `None`.
    pub fn order_kind(&self, kind: &str) -> Option<&OrderKindFacts> {
        self.orders.iter().find(|o| o.kind == kind)
    }
}

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

    fn enrol() -> OrderKindFacts {
        OrderKindFacts {
            kind: "downstream_enrol".into(),
            role: "main".into(),
            words: "enrol a downstream".into(),
            params: vec![
                OrderParamFacts {
                    name: "name".into(),
                    words: "its name".into(),
                    required: true,
                },
                OrderParamFacts {
                    name: "key".into(),
                    words: String::new(),
                    required: false,
                },
            ],
            file: String::new(),
        }
    }

    /// ★ A declared kind is a plain word of the product's own, never a core kind; its params
    /// are plain words named once; its file is one of them.
    #[test]
    fn a_declared_kind_is_the_products_own_and_plain() {
        assert!(enrol().check().is_ok());
        for bad in ["refeed", "fetch", "Enrol", "", "a b"] {
            assert!(
                OrderKindFacts {
                    kind: bad.into(),
                    ..enrol()
                }
                .check()
                .is_err(),
                "{bad:?}"
            );
        }
        let twice = OrderKindFacts {
            params: vec![enrol().params[0].clone(), enrol().params[0].clone()],
            ..enrol()
        };
        assert!(twice.check().is_err());
        assert!(
            OrderKindFacts {
                file: "bundle".into(),
                ..enrol()
            }
            .check()
            .is_err(),
            "the file is a param"
        );
    }

    /// ★ An ask is taken only as declared: every required param, no other, a file only where
    /// one is declared.
    #[test]
    fn an_ask_is_taken_only_as_declared() {
        let m = |kv: &[(&str, &str)]| {
            kv.iter()
                .map(|(k, v)| (k.to_string(), v.to_string()))
                .collect::<BTreeMap<_, _>>()
        };
        assert!(enrol()
            .admits(&m(&[("name", "lab-1"), ("key", "ab")]), false)
            .is_ok());
        assert!(enrol()
            .admits(&m(&[("key", "ab")]), false)
            .unwrap_err()
            .contains("needs name"));
        assert!(enrol()
            .admits(&m(&[("name", "x"), ("via", "y")]), false)
            .unwrap_err()
            .contains("no param \"via\""));
        assert!(enrol()
            .admits(&m(&[("name", "x")]), true)
            .unwrap_err()
            .contains("no file"));
        let put = OrderKindFacts {
            kind: "bundle_put".into(),
            params: vec![OrderParamFacts {
                name: "bundle".into(),
                words: String::new(),
                required: true,
            }],
            file: "bundle".into(),
            ..enrol()
        };
        assert!(put.check().is_ok());
        assert!(put.admits(&m(&[]), true).is_ok());
        assert!(put
            .admits(&m(&[]), false)
            .unwrap_err()
            .contains("needs the file"));
        assert!(
            put.admits(&m(&[("bundle", "x")]), true).is_err(),
            "the file is no typed param"
        );
    }
}

/// **One role a product's plan may lay** (A24 §2.2 `RoleFacts`; its medium, keep medium, data
/// and backup are the lifecycle fields `medium_of`, `data_roles` and `backup_roles`).
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RoleFacts {
    pub role: String,
    /// The role may be laid n times: its first machine carries the role's name, the next ones
    /// `<role>-<n>` (`be`, `be-2`; the Lake's ingestors, backends and frontends).
    #[serde(default)]
    pub many: bool,
    /// The role may be laid or not (the Lake's graph database).
    #[serde(default)]
    pub optional: bool,
    /// **The ports a machine of this role opens to any address** (OX-WGDS5, 2026-10-29), on top
    /// of the estate's own lanes the cloud always writes: the product's declaration, which the
    /// shop puts on the plan's shape and on every firewall it writes again. None: nothing more
    /// is opened.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub open: Vec<PortFacts>,
}

/// **One port a role opens to any address** ([`RoleFacts::open`]): `protocol` `udp` or `tcp`,
/// `why` the product's words (the firewall rule's comment).
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PortFacts {
    pub protocol: String,
    pub port: u16,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub why: String,
}

/// **The twin** (A24 §2.2 `TwinFacts`): the role that mirrors `mirrors` (Gunnar's and Holger's
/// `twin` mirrors `main`). Its health is read from the two roles' reports, and it grows by
/// itself when it stays behind.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TwinFacts {
    pub role: String,
    pub mirrors: String,
}

/// **What may change on a running estate** (A24 §2.2 `GrowRule`).
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GrowRule {
    /// The numbers that never go down: a field of the product's order (`store_gib`) or of the
    /// sizes (`main_data_gib`).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub never_shrinks: Vec<String>,
    /// The roles a plan change may lay more machines of (`be`: `be-2` is laid).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub may_add: Vec<String>,
    /// The roles a plan change may take machines of away.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub may_remove: Vec<String>,
    /// **The order a box is sent before it is taken away** (A4-FACTS): one of the product's own
    /// order kinds ([`Facts::order_kinds`]). The box acks it once it serves nothing more and, for
    /// a role named in [`GrowRule::moves_data`], once the estate moved its data to the boxes that
    /// stay. Empty: the product names no drain, and no machine is taken away.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub drain: String,
    /// **The roles whose data the estate moves off a box by its drain** (A4-FACTS). A machine
    /// that holds data (a data disk) of a role not named here is never taken away: the change is
    /// refused by name.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub moves_data: Vec<String>,
}

impl GrowRule {
    /// **The rule before P3-FACTS** (one shop, 2026-10-07): main's data disks never shrink, and
    /// a plan change resizes the machines that stand (none is added or taken away).
    pub fn sizes() -> GrowRule {
        GrowRule {
            never_shrinks: vec!["main_data_gib".into()],
            ..GrowRule::default()
        }
    }

    /// Pure: **the first number that would shrink**: `(field, now, asked)`; `from` and `to` are
    /// the sizes as JSON (`main_data_gib`, …, and the product's `order`). A field is read from the
    /// order first, then from the sizes; a field neither names is not compared.
    pub fn shrinks(&self, from: &Value, to: &Value) -> Option<(String, u64, u64)> {
        let num = |v: &Value, k: &str| {
            v.get("order")
                .and_then(|o| o.get(k))
                .and_then(Value::as_u64)
                .or_else(|| v.get(k).and_then(Value::as_u64))
        };
        self.never_shrinks
            .iter()
            .find_map(|k| match (num(from, k), num(to, k)) {
                (Some(a), Some(b)) if b < a => Some((k.clone(), a, b)),
                _ => None,
            })
    }

    /// Pure: [`GrowRule::shrinks`], said in words.
    pub fn shrink_refusal(&self, from: &Value, to: &Value) -> Option<String> {
        self.shrinks(from, to)
            .map(|(k, a, b)| format!("{k} never shrinks: it is {a} now, and {b} was asked"))
    }

    /// Pure: **machines a plan change would add or take away that this rule does not allow**,
    /// in words. Each machine is named as laid (`be-2`); its role is the one of `roles` it is a
    /// member of ([`RoleFacts::of`]).
    pub fn machines_refusal(
        &self,
        roles: &[RoleFacts],
        added: &[String],
        removed: &[String],
    ) -> Option<String> {
        let role_of = |m: &String| {
            RoleFacts::of(roles, m)
                .map(|r| r.role.clone())
                .unwrap_or_else(|| m.clone())
        };
        if let Some(m) = added.iter().find(|m| !self.may_add.contains(&role_of(m))) {
            let may = if self.may_add.is_empty() {
                "this product adds no machine by a plan change".to_string()
            } else {
                format!(
                    "this product adds machines of {} only",
                    self.may_add.join(", ")
                )
            };
            return Some(format!("a plan change lays no new {m}: {may}"));
        }
        if let Some(m) = removed
            .iter()
            .find(|m| !self.may_remove.contains(&role_of(m)))
        {
            let may = if self.may_remove.is_empty() {
                "this product takes no machine away by a plan change".to_string()
            } else {
                format!(
                    "this product takes machines of {} only",
                    self.may_remove.join(", ")
                )
            };
            return Some(format!("a plan change takes no {m} away: {may}"));
        }
        if let Some(m) = removed.first().filter(|_| self.drain.trim().is_empty()) {
            return Some(format!("a plan change takes no {m} away: this product names no drain, so no box of it is emptied before it goes"));
        }
        None
    }

    /// Pure: **a machine to take away whose data cannot move** (A4-FACTS), in words: of
    /// `removed`, the first that `holds_data` (it has a data disk) and whose role is not one the
    /// estate moves data off ([`GrowRule::moves_data`]). `None`: every one may be drained.
    pub fn data_refusal(
        &self,
        roles: &[RoleFacts],
        removed: &[String],
        holds_data: &dyn Fn(&str) -> bool,
    ) -> Option<String> {
        let role_of = |m: &String| {
            RoleFacts::of(roles, m)
                .map(|r| r.role.clone())
                .unwrap_or_else(|| m.clone())
        };
        let m = removed
            .iter()
            .find(|m| holds_data(m) && !self.moves_data.contains(&role_of(m)))?;
        Some(format!("{m} holds the estate's data, and this product does not move the data of a {} off a box: it is not taken away", role_of(m)))
    }
}

impl RoleFacts {
    /// Pure: **the role `machine` is laid as** — the role of that name, or the role `<role>` of a
    /// numbered member `<role>-<n>` when the role is laid n times ([`RoleFacts::many`]).
    pub fn of<'a>(roles: &'a [RoleFacts], machine: &str) -> Option<&'a RoleFacts> {
        roles.iter().find(|r| r.role == machine).or_else(|| {
            let (base, n) = machine.rsplit_once('-')?;
            n.parse::<u32>().ok().filter(|n| *n >= 2)?;
            roles.iter().find(|r| r.many && r.role == base)
        })
    }
}

fn a_live_slot() -> bool {
    true
}

impl Default for Facts {
    fn default() -> Self {
        Facts {
            kind: String::new(),
            update_order: Vec::new(),
            app_medium: String::new(),
            front_medium: String::new(),
            front_role: String::new(),
            keep_medium: String::new(),
            front_keep_medium: String::new(),
            upgraded_by_shop: Vec::new(),
            medium_of: BTreeMap::new(),
            other_medium: String::new(),
            backup_roles: Vec::new(),
            words: WireWords::default(),
            default_shape: Value::Null,
            data_roles: Vec::new(),
            domain_proof_name: String::new(),
            live_slot: a_live_slot(),
            tickets: false,
            test_actions: Vec::new(),
            guardian: false,
            billing: None,
            offsite: None,
            boxes_hold_console_token: None,
            welcome: None,
            sold_variant: None,
            site_variant: None,
            box_kind: None,
            free_feed: None,
            console_token_feed: None,
            passkey_rp_feed: None,
            public_repositories: None,
            public_site: None,
            reserved_accounts: Vec::new(),
            welcome_readme: None,
            proof: None,
            form: None,
            primary_role: String::new(),
            roles: Vec::new(),
            twin: None,
            grow: None,
            order_kinds: Vec::new(),
            orders: Vec::new(),
        }
    }
}

impl Facts {
    /// **Whether the shop feeds this product's boxes a site's public repositories**
    /// ([`Facts::public_repositories`]): only a product that says it takes none is fed none.
    pub fn takes_public_repositories(&self) -> bool {
        self.public_repositories != Some(false)
    }

    /// **Whether the shop feeds this product's boxes a site's public-site switch in a refeed**
    /// ([`Facts::public_site`]): only a product that says it takes it.
    pub fn takes_public_site(&self) -> bool {
        self.public_site == Some(true)
    }

    /// **Whether this product's box runs an order of `kind`**, from [`Facts::order_kinds`] only:
    /// `Err` is the refusal BY NAME a box acks with. Facts that name no kinds refuse nothing.
    pub fn runs(&self, kind: &str) -> Result<(), String> {
        if self.order_kinds.is_empty()
            || (!kind.is_empty() && self.order_kinds.iter().any(|k| k == kind))
        {
            return Ok(());
        }
        let product = if self.words.title.is_empty() {
            self.kind.as_str()
        } else {
            self.words.title.as_str()
        };
        Err(format!(
            "REFUSED [order-kind] a {product} box runs no `{kind}` order; it runs {}",
            self.order_kinds.join(", ")
        ))
    }
}

impl Facts {
    /// Pure: **the front is upgraded LAST** (A28 H17): when the facts name a front role, it is
    /// the last box of [`Facts::update_order`]. Facts that name no front role, or an order that
    /// does not hold the front, have nothing to break.
    pub fn front_is_last(&self) -> bool {
        self.front_role.is_empty()
            || !self.update_order.contains(&self.front_role)
            || self.update_order.last() == Some(&self.front_role)
    }

    /// Pure: **the primary** ([`Facts::primary_role`]): the role named, else the first data role,
    /// else the front.
    pub fn primary(&self) -> String {
        let named = self.primary_role.trim();
        if !named.is_empty() {
            return named.to_string();
        }
        self.data_roles
            .first()
            .map(|r| r.role.clone())
            .unwrap_or_else(|| self.front_role.clone())
    }

    /// Pure: **every role, each once** ([`Facts::roles`]): the roles named, else the front, the
    /// data roles, the update order and the backup roles, in that order.
    pub fn role_names(&self) -> Vec<String> {
        let mut v: Vec<String> = Vec::new();
        let named: Vec<String> = self.roles.iter().map(|r| r.role.clone()).collect();
        let derived = std::iter::once(self.front_role.clone())
            .chain(self.data_roles.iter().map(|d| d.role.clone()))
            .chain(self.update_order.iter().cloned())
            .chain(self.backup_roles.iter().cloned());
        let all: Box<dyn Iterator<Item = String>> = if named.is_empty() {
            Box::new(derived)
        } else {
            Box::new(named.into_iter())
        };
        for r in all {
            if !r.trim().is_empty() && !v.contains(&r) {
                v.push(r);
            }
        }
        v
    }

    /// Pure: **the twin** ([`Facts::twin`]): the one named, else the data role that mirrors the
    /// primary, if one does.
    pub fn twin_of(&self) -> Option<TwinFacts> {
        if let Some(t) = self.twin.as_ref().filter(|t| !t.role.trim().is_empty()) {
            return Some(t.clone());
        }
        let role = self
            .data_roles
            .iter()
            .find(|d| d.mirrors_primary)?
            .role
            .clone();
        Some(TwinFacts {
            role,
            mirrors: self.primary(),
        })
    }

    /// Pure: **what may change on a running estate** ([`Facts::grow`]): the rule named, else
    /// [`GrowRule::sizes`].
    pub fn grow_rule(&self) -> GrowRule {
        self.grow.clone().unwrap_or_else(GrowRule::sizes)
    }
}

/// **Where a sold estate's pay page is** (the shop's `Billing`, as data).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Billing {
    /// The sold console serves `https://<host>/billing/{tenant}` itself (Gunnar's).
    Console,
    /// The front serves none: it is fed the pay page the site row carries (Holger's).
    SitePayPage,
}

/// **What proves a sale** (P3a, A28 H12): typed checks the sale check runs, in order, over the
/// sold front's PUBLIC name, before the sale is captured. Each product names its OWN routes;
/// there is no shared `/auth/first-admin` (Gunnar: `/auth/status` and
/// `/auth/passkey/register/start`; Holger: `/-/auth/passkey/config` and its register start).
///
/// The words a check is asked with: `{invite}` (the first-login invite the ready mail carries)
/// and `{account}` (the sold account), in a [`ProofAsk::path`] percent-encoded and in a
/// [`ProofAsk::body`] as JSON strings (the account trimmed). The invite and the bearer are never
/// in a word the check says.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SaleProof {
    /// The checks, in order; the first that fails is the answer, named by its `what`.
    pub checks: Vec<ProofCheck>,
    /// No sale is proven for an empty account (both of today's judges refuse it first).
    #[serde(default = "yes")]
    pub needs_account: bool,
    /// **The first-admin door** as `site first-link` asks it (spent, open to the invite, or
    /// closed); `None`: the product has none.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub first_door: Option<FirstDoorProof>,
}

fn yes() -> bool {
    true
}

/// One check of a [`SaleProof`]: one request, and what its answer must hold. The answer must
/// be HTTP 200 (both of today's judges ask that of every door).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ProofCheck {
    /// What it proves, in the words a failure starts with (`the login page`).
    pub what: String,
    pub ask: ProofAsk,
    /// Every one must hold, in order.
    pub expect: Vec<ProofExpect>,
}

/// **The method and body check** (A28 H12): how one route is asked.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ProofAsk {
    #[serde(default)]
    pub method: ProofMethod,
    /// The product's own route, with its query (`/auth/passkey/register/start?invite={invite}&user={account}`).
    pub path: String,
    /// A JSON body (a `POST`), its string leaves `{invite}` and `{account}` filled in.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub body: Option<Value>,
    /// Asked with the console's bearer (the break-glass admin token); else as the buyer, with none.
    #[serde(default)]
    pub bearer: bool,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum ProofMethod {
    #[default]
    Get,
    Post,
}

/// **What an answer must hold** (A28 H12). A `field` or `list` is a JSON pointer
/// (`/passkey_enabled`, `/publicKey/challenge`, `/items`); a check whose expectations read JSON
/// fails on a body that is not JSON.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "is", rename_all = "snake_case")]
pub enum ProofExpect {
    /// The body holds `text` (the login page's own mark).
    Contains { text: String },
    /// **A JSON status field** equals `value` (`passkey_enabled` true, `bootstrap_spent` false).
    Equals { field: String, value: Value },
    /// A JSON field is a non-empty string (the passkey challenge).
    Text { field: String },
    /// **The bearer list rule**: `list` is an array in which exactly ONE item's `name_field` is
    /// `name`, and that item's `id_field` is `id` (`{account}` filled in): exactly one
    /// `<account>/welcome`.
    ExactlyOne {
        list: String,
        name_field: String,
        name: String,
        id_field: String,
        id: String,
    },
    /// `list` is an array with at least one item that has a string `name_field` (Holger's roster).
    NotEmpty { list: String, name_field: String },
}

/// **The first-admin door** (owner 2026-10-22, `site first-link`): its status route says
/// whether the door is spent and whether passkeys are offered; its start route gives a passkey
/// challenge to an invite that still opens it (nothing is spent: the invite is redeemed only at
/// `finish`).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FirstDoorProof {
    pub status: ProofAsk,
    /// The status field that is `true` once the first admin signed in.
    pub spent: String,
    /// The status field that is `true` while passkeys are offered.
    pub enabled: String,
    pub start: ProofAsk,
    /// The start answer's challenge (a non-empty string).
    pub challenge: String,
}

/// **The order form** (v2.0 P3, the parametric order; A24 `OrderForm`): the fields a buyer sets
/// in an order of the product, each with its kind, range and default. The shop checks an
/// order against it ([`OrderForm::check`]) before anything is priced or held, and hands the
/// guest's plan the checked order with every default filled in.
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct OrderForm {
    pub fields: Vec<OrderField>,
}

/// One field of an [`OrderForm`].
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct OrderField {
    /// The key in the order (`store_gib`, `be`).
    pub name: String,
    pub kind: FieldKind,
    /// A number's lowest and highest value (both included); `0`/`0`: no range.
    #[serde(default)]
    pub min: u64,
    #[serde(default)]
    pub max: u64,
    /// A number is a multiple of `step`; `0`: any.
    #[serde(default)]
    pub step: u64,
    /// A number's only values (`1`, `3`, `5`); empty: the range alone.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub choices: Vec<u64>,
    /// What an order that names no value gets.
    pub default: Value,
    /// What the buyer reads the field as (`Apache Doris backends`).
    #[serde(default)]
    pub words: String,
}

/// What kind of value an [`OrderField`] holds.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
    /// A number of machines or members.
    Count,
    /// A size in GiB.
    Gib,
    /// A provider's plan name (`STARTER-2xCPU-4GB`).
    Plan,
    /// Yes or no.
    Bool,
}

impl OrderForm {
    /// Pure: **the order, checked and filled in**: `null` is the default order; any other order
    /// is a JSON object whose every key the form names and whose every value is of its field's
    /// kind and in its range. Every field the order does not name takes its default. `Err`
    /// names the first field that is wrong, in the buyer's words.
    pub fn check(&self, order: &Value) -> Result<Value, String> {
        let given = match order {
            Value::Null => serde_json::Map::new(),
            Value::Object(o) => o.clone(),
            other => return Err(format!("an order is a set of named values, not {other}")),
        };
        if let Some(k) = given
            .keys()
            .find(|k| !self.fields.iter().any(|f| &f.name == *k))
        {
            return Err(format!(
                "the order names `{k}`, which this product's order form does not"
            ));
        }
        let mut out = serde_json::Map::new();
        for f in &self.fields {
            let v = given
                .get(&f.name)
                .cloned()
                .unwrap_or_else(|| f.default.clone());
            f.check(&v)?;
            out.insert(f.name.clone(), v);
        }
        Ok(Value::Object(out))
    }
}

impl OrderField {
    fn said(&self) -> &str {
        if self.words.trim().is_empty() {
            &self.name
        } else {
            &self.words
        }
    }

    /// Pure: `v` is of this field's kind and in its range; `Err` says why in the buyer's words.
    pub fn check(&self, v: &Value) -> Result<(), String> {
        match self.kind {
            FieldKind::Count | FieldKind::Gib => {
                let n = v
                    .as_u64()
                    .ok_or_else(|| format!("{} is a whole number, not {v}", self.said()))?;
                if !self.choices.is_empty() {
                    if !self.choices.contains(&n) {
                        let c: Vec<String> = self.choices.iter().map(u64::to_string).collect();
                        return Err(format!(
                            "{} is one of {}; {n} is not",
                            self.said(),
                            c.join(", ")
                        ));
                    }
                } else if (self.min, self.max) != (0, 0) && !(self.min..=self.max).contains(&n) {
                    return Err(format!(
                        "{} is {} to {}; {n} is not",
                        self.said(),
                        self.min,
                        self.max
                    ));
                }
                if self.step > 0 && n % self.step != 0 {
                    return Err(format!(
                        "{} goes in steps of {}; {n} is not",
                        self.said(),
                        self.step
                    ));
                }
                Ok(())
            }
            FieldKind::Plan => {
                let p = v.as_str().map(str::trim).unwrap_or_default();
                if p.is_empty()
                    || p.len() > 64
                    || !p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
                {
                    return Err(format!("{} is a plan name, not {v}", self.said()));
                }
                Ok(())
            }
            FieldKind::Bool => v
                .as_bool()
                .map(|_| ())
                .ok_or_else(|| format!("{} is yes or no (true or false), not {v}", self.said())),
        }
    }
}

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

    /// A Gunnar-shaped `lifecycle.facts` answer, lifecycle fields only.
    fn gunnar_lifecycle() -> Facts {
        Facts {
            kind: "gunnar".into(),
            update_order: vec!["twin".into(), "main".into(), "front".into()],
            app_medium: "app".into(),
            front_medium: "front".into(),
            front_role: "front".into(),
            keep_medium: "keep".into(),
            front_keep_medium: "front-keep".into(),
            upgraded_by_shop: vec!["front".into()],
            medium_of: BTreeMap::from([("front".to_string(), "front".to_string())]),
            other_medium: "app".into(),
            backup_roles: vec!["main".into()],
            words: WireWords {
                name: "gunnar".into(),
                title: "Gunnar".into(),
                account_home: "gunnar.rs".into(),
            },
            default_shape: serde_json::json!({"front": "STARTER-1xCPU-2GB"}),
            data_roles: vec![WireDataRole {
                role: "main".into(),
                mirrors_primary: false,
                set_suffix: "data".into(),
            }],
            domain_proof_name: "gunnar-proof".into(),
            live_slot: false,
            ..Facts::default()
        }
    }

    /// ★ **Old guests stay readable** (A28 H11): the `lifecycle.facts` answer of a guest built
    /// before P3 (its `proof_name`, no contract, no proof, no form) decodes as `Facts` with
    /// every lifecycle field kept and nothing of the contract said; `{}` is the default `Facts`.
    #[test]
    fn an_older_guests_answer_is_read_as_facts_with_nothing_lost() {
        let old = serde_json::json!({
            "kind": "gunnar", "update_order": ["twin", "main", "front"], "app_medium": "app", "front_medium": "front",
            "front_role": "front", "keep_medium": "keep", "front_keep_medium": "front-keep", "upgraded_by_shop": ["front"],
            "medium_of": {"front": "front"}, "other_medium": "app", "backup_roles": ["main"],
            "words": {"name": "gunnar", "title": "Gunnar", "account_home": "gunnar.rs"},
            "default_shape": {"front": "STARTER-1xCPU-2GB"},
            "data_roles": [{"role": "main", "mirrors_primary": false, "set_suffix": "data"}],
            "proof_name": "gunnar-proof", "live_slot": false,
        });
        let facts: Facts = serde_json::from_value(old).unwrap();
        assert_eq!(facts, gunnar_lifecycle());
        assert_eq!(
            facts.domain_proof_name, "gunnar-proof",
            "the old name is read"
        );
        assert_eq!(
            (
                facts.billing,
                facts.offsite,
                facts.welcome,
                facts.box_kind.as_deref(),
                facts.proof.as_ref(),
                facts.form.as_ref()
            ),
            (None, None, None, None, None, None)
        );
        let empty: Facts = serde_json::from_str("{}").unwrap();
        assert_eq!(empty, Facts::default());
        assert!(empty.live_slot, "absent: a live slot");
        assert_eq!(
            crate::wire::LifecycleFacts::default(),
            Facts::default(),
            "the old name is this type"
        );
    }

    /// ★ **The 13 fields** (A28 H11) are declared, written and read back; a facts answer that
    /// says none of them writes none of them (the wire of today, plus the renamed proof name).
    #[test]
    fn the_thirteen_contract_and_lifecycle_fields_round_trip() {
        let facts = Facts {
            billing: Some(Billing::SitePayPage),
            offsite: Some(true),
            boxes_hold_console_token: Some(true),
            welcome: Some(false),
            sold_variant: Some("holger-sold".into()),
            site_variant: Some("holger-site".into()),
            box_kind: Some("holger".into()),
            free_feed: Some("plugin.holger.free".into()),
            upgraded_by_shop: vec!["front".into(), "main".into()],
            app_medium: "holger".into(),
            front_medium: "holger-front".into(),
            front_keep_medium: "holger-front-keep".into(),
            domain_proof_name: "holger-proof".into(),
            console_token_feed: Some("holger_console_token".into()),
            passkey_rp_feed: Some("passkey_rp_id".into()),
            welcome_readme: Some("# {account} on {host}".into()),
            ..gunnar_lifecycle()
        };
        let wire = serde_json::to_value(&facts).unwrap();
        for key in [
            "billing",
            "offsite",
            "boxes_hold_console_token",
            "welcome",
            "sold_variant",
            "site_variant",
            "box_kind",
            "free_feed",
            "console_token_feed",
            "passkey_rp_feed",
            "welcome_readme",
            "upgraded_by_shop",
            "app_medium",
            "front_medium",
            "front_keep_medium",
            "domain_proof_name",
        ] {
            assert!(wire.get(key).is_some(), "`{key}` is on the wire");
        }
        assert_eq!(wire["billing"], "site_pay_page");
        assert!(
            wire.get("proof_name").is_none(),
            "the clashing name is not written"
        );
        assert_eq!(serde_json::from_value::<Facts>(wire).unwrap(), facts);
        let bare = serde_json::to_value(gunnar_lifecycle()).unwrap();
        for key in [
            "billing",
            "offsite",
            "boxes_hold_console_token",
            "welcome",
            "sold_variant",
            "site_variant",
            "box_kind",
            "free_feed",
            "console_token_feed",
            "passkey_rp_feed",
            "welcome_readme",
            "proof",
            "form",
        ] {
            assert!(
                bare.get(key).is_none(),
                "`{key}` is not written when not said"
            );
        }
    }

    /// ★ **The front LAST** (A28 H17): Gunnar's and Holger's order (`twin`, `main`, `front`)
    /// passes; an order that updates the front first does not.
    #[test]
    fn the_front_is_upgraded_last() {
        let facts = gunnar_lifecycle();
        assert!(facts.front_is_last());
        let first = Facts {
            update_order: vec!["front".into(), "twin".into(), "main".into()],
            ..facts.clone()
        };
        assert!(
            !first.front_is_last(),
            "the front first is the doc's old error"
        );
        assert!(
            Facts::default().front_is_last(),
            "no front named: nothing to break"
        );
        let lake = Facts {
            front_role: "front".into(),
            update_order: ["ingestor", "fe", "be", "front"].map(String::from).to_vec(),
            ..Facts::default()
        };
        assert!(lake.front_is_last(), "the Lake's order");
    }

    /// A `SaleProof` is data: it round-trips through JSON, a method defaults to `GET`, and an
    /// expectation names what it is (`"is": "exactly_one"`).
    #[test]
    fn a_sale_proof_round_trips_as_data() {
        let proof = SaleProof {
            checks: vec![ProofCheck {
                what: "the welcome repository".into(),
                ask: ProofAsk {
                    method: ProofMethod::Get,
                    path: "/api/repos?limit=2000".into(),
                    body: None,
                    bearer: true,
                },
                expect: vec![
                    ProofExpect::Equals {
                        field: "/truncated".into(),
                        value: Value::Bool(false),
                    },
                    ProofExpect::ExactlyOne {
                        list: "/items".into(),
                        name_field: "name".into(),
                        name: "welcome".into(),
                        id_field: "path".into(),
                        id: "{account}/welcome".into(),
                    },
                ],
            }],
            needs_account: true,
            first_door: Some(FirstDoorProof {
                status: ProofAsk {
                    method: ProofMethod::Get,
                    path: "/-/auth/passkey/config".into(),
                    body: None,
                    bearer: false,
                },
                spent: "/bootstrap_spent".into(),
                enabled: "/enabled".into(),
                start: ProofAsk {
                    method: ProofMethod::Post,
                    path: "/-/auth/passkey/register/start".into(),
                    body: Some(serde_json::json!({"invite": "{invite}", "display": "{account}"})),
                    bearer: false,
                },
                challenge: "/publicKey/challenge".into(),
            }),
        };
        let wire = serde_json::to_value(&proof).unwrap();
        assert_eq!(wire["checks"][0]["expect"][1]["is"], "exactly_one");
        assert_eq!(wire["first_door"]["start"]["method"], "POST");
        assert_eq!(serde_json::from_value::<SaleProof>(wire).unwrap(), proof);
        let short: SaleProof = serde_json::from_str(r#"{"checks":[{"what":"the login page","ask":{"path":"/login"},"expect":[{"is":"contains","text":"/login.js"}]}]}"#).unwrap();
        assert_eq!(
            (
                short.checks[0].ask.method,
                short.needs_account,
                short.first_door.is_none()
            ),
            (ProofMethod::Get, true, true)
        );
    }

    /// A Lake-shaped form: a store in hundreds, a count with choices, a plan, a yes or no.
    fn lake_form() -> OrderForm {
        let f = |name: &str,
                 kind: FieldKind,
                 min: u64,
                 max: u64,
                 step: u64,
                 choices: &[u64],
                 default: Value| OrderField {
            name: name.into(),
            kind,
            min,
            max,
            step,
            choices: choices.to_vec(),
            default,
            words: String::new(),
        };
        OrderForm {
            fields: vec![
                f(
                    "store_gib",
                    FieldKind::Gib,
                    100,
                    100_000,
                    100,
                    &[],
                    serde_json::json!(100),
                ),
                f(
                    "fe",
                    FieldKind::Count,
                    0,
                    0,
                    0,
                    &[1, 3, 5],
                    serde_json::json!(1),
                ),
                f(
                    "ingestor_plan",
                    FieldKind::Plan,
                    0,
                    0,
                    0,
                    &[],
                    serde_json::json!("STARTER-1xCPU-2GB"),
                ),
                f(
                    "graph",
                    FieldKind::Bool,
                    0,
                    0,
                    0,
                    &[],
                    serde_json::json!(false),
                ),
            ],
        }
    }

    /// ★ **The parametric order is checked against the product's form** (v2.0 P3): null is the
    /// default order; a named value takes its field's place; an unknown key, a value out of range,
    /// off its step, not one of its choices or of the wrong kind is refused by name.
    #[test]
    fn an_order_is_checked_and_filled_in_from_the_form() {
        let form = lake_form();
        assert_eq!(
            form.check(&Value::Null).unwrap(),
            serde_json::json!({"store_gib": 100, "fe": 1, "ingestor_plan": "STARTER-1xCPU-2GB", "graph": false})
        );
        assert_eq!(
            form.check(&serde_json::json!({"fe": 3, "graph": true}))
                .unwrap()["fe"],
            3
        );
        for (order, words) in [
            (serde_json::json!({"be": 2}), "names `be`"),
            (serde_json::json!({"store_gib": 50}), "100 to 100000"),
            (serde_json::json!({"store_gib": 150}), "steps of 100"),
            (serde_json::json!({"fe": 2}), "one of 1, 3, 5"),
            (serde_json::json!({"fe": "3"}), "whole number"),
            (
                serde_json::json!({"ingestor_plan": "no plan!"}),
                "plan name",
            ),
            (serde_json::json!({"graph": 1}), "yes or no"),
            (serde_json::json!([1]), "set of named values"),
        ] {
            let e = form.check(&order).unwrap_err();
            assert!(e.contains(words), "{order}: {e}");
        }
        let wire = serde_json::to_value(&form).unwrap();
        assert_eq!(wire["fields"][1]["kind"], "count");
        assert_eq!(serde_json::from_value::<OrderForm>(wire).unwrap(), form);
    }

    /// ★ **The primary, the roles, the twin and the grow rule are the product's facts**
    /// (P3-FACTS, A24 §2.2): named, they are what the facts say; unnamed (a guest built before
    /// them), they are what the core derived before — Gunnar's main, front/main/twin, the twin
    /// that mirrors main, main's data never shrinking — so no older answer changes.
    #[test]
    fn the_primary_roles_twin_and_grow_rule_are_read_from_the_facts_else_derived_as_before() {
        let old = gunnar_lifecycle();
        assert_eq!(old.primary(), "main");
        assert_eq!(old.role_names(), ["front", "main", "twin"]);
        assert_eq!(old.twin_of(), None, "no data role mirrors main here");
        let mut g = old.clone();
        g.data_roles.push(WireDataRole {
            role: "twin".into(),
            mirrors_primary: true,
            set_suffix: "-twin".into(),
        });
        assert_eq!(
            g.twin_of(),
            Some(TwinFacts {
                role: "twin".into(),
                mirrors: "main".into()
            }),
            "the twin as derived before"
        );
        assert_eq!(old.grow_rule(), GrowRule::sizes());
        // A product that says otherwise is answered as it says (the Lake's shape).
        let lake = Facts {
            primary_role: "fe".into(),
            roles: ["front", "ingestor", "be", "fe", "graph"]
                .iter()
                .map(|r| RoleFacts {
                    role: r.to_string(),
                    many: ["ingestor", "be", "fe"].contains(r),
                    optional: *r == "graph",
                    ..RoleFacts::default()
                })
                .collect(),
            grow: Some(GrowRule {
                never_shrinks: vec!["store_gib".into()],
                may_add: vec!["be".into()],
                ..GrowRule::default()
            }),
            data_roles: vec![WireDataRole {
                role: "be".into(),
                mirrors_primary: false,
                set_suffix: String::new(),
            }],
            front_role: "front".into(),
            ..Facts::default()
        };
        assert_eq!(lake.primary(), "fe", "named, never the first data role");
        assert_eq!(
            lake.role_names(),
            ["front", "ingestor", "be", "fe", "graph"],
            "the optional graph included"
        );
        assert_eq!(lake.twin_of(), None);
        // On the wire: named fields ride, unnamed ones are absent, and both read back.
        let wire = serde_json::to_value(&lake).unwrap();
        assert_eq!(wire["primary_role"], "fe");
        assert_eq!(
            wire["roles"][2],
            serde_json::json!({"role": "be", "many": true, "optional": false})
        );
        assert!(serde_json::to_value(&old).unwrap().get("twin").is_none());
        assert_eq!(serde_json::from_value::<Facts>(wire).unwrap(), lake);
    }

    /// ★ **A number the grow rule keeps never shrinks** — read from the order first, then from
    /// the sizes — and a plan change lays or takes away only the machines the rule allows, each
    /// named by its role (`be-2` is a `be`).
    #[test]
    fn the_grow_rule_refuses_a_shrink_and_machines_it_does_not_allow_by_name() {
        let sizes = GrowRule::sizes();
        let s = |gib: u64| serde_json::json!({"main_data_gib": gib, "order": null});
        assert_eq!(sizes.shrink_refusal(&s(20), &s(20)), None);
        assert_eq!(sizes.shrink_refusal(&s(20), &s(40)), None);
        assert_eq!(
            sizes.shrinks(&s(20), &s(10)),
            Some(("main_data_gib".to_string(), 20, 10))
        );
        assert!(sizes
            .shrink_refusal(&s(20), &s(10))
            .unwrap()
            .contains("main_data_gib never shrinks: it is 20 now, and 10 was asked"));
        let lake = GrowRule {
            never_shrinks: vec!["store_gib".into()],
            may_add: vec!["be".into(), "ingestor".into()],
            may_remove: vec!["ingestor".into()],
            drain: "drain".into(),
            ..GrowRule::default()
        };
        let o = |gib: u64| serde_json::json!({"main_data_gib": 0, "order": {"store_gib": gib}});
        assert!(lake
            .shrink_refusal(&o(500), &o(400))
            .unwrap()
            .contains("store_gib never shrinks"));
        assert_eq!(lake.shrink_refusal(&o(500), &o(600)), None);
        let roles: Vec<RoleFacts> = [
            ("front", false),
            ("ingestor", true),
            ("be", true),
            ("fe", true),
        ]
        .iter()
        .map(|(r, many)| RoleFacts {
            role: r.to_string(),
            many: *many,
            ..RoleFacts::default()
        })
        .collect();
        let v = |x: &[&str]| x.iter().map(|m| m.to_string()).collect::<Vec<_>>();
        assert_eq!(
            RoleFacts::of(&roles, "be-2").map(|r| r.role.as_str()),
            Some("be")
        );
        assert_eq!(
            RoleFacts::of(&roles, "front-2"),
            None,
            "a role laid once has no members"
        );
        assert_eq!(
            RoleFacts::of(&roles, "be-1"),
            None,
            "the first member carries the role's name"
        );
        assert_eq!(
            lake.machines_refusal(
                &roles,
                &v(&["be-2", "be-3", "ingestor-2"]),
                &v(&["ingestor-3"])
            ),
            None
        );
        assert!(lake
            .machines_refusal(&roles, &v(&["fe-2"]), &[])
            .unwrap()
            .contains("lays no new fe-2: this product adds machines of be, ingestor only"));
        assert!(lake
            .machines_refusal(&roles, &[], &v(&["be-2"]))
            .unwrap()
            .contains("takes no be-2 away"));
        assert!(
            sizes
                .machines_refusal(&roles, &v(&["be-2"]), &[])
                .unwrap()
                .contains("adds no machine by a plan change"),
            "the rule before P3-FACTS adds none"
        );
    }

    /// ★ **A machine is taken away only when the product says how it is emptied** (A4-FACTS):
    /// a rule that names no drain refuses every removal by name; one that does still refuses a
    /// machine that holds data of a role whose data the estate does not move (`moves_data`), and
    /// takes one whose data it moves, or one that holds none.
    #[test]
    fn a_machine_goes_only_by_the_products_drain_and_its_data_only_where_the_estate_moves_it() {
        let roles: Vec<RoleFacts> = [("front", false), ("node", true), ("vault", true)]
            .iter()
            .map(|(r, many)| RoleFacts {
                role: r.to_string(),
                many: *many,
                optional: true,
                ..RoleFacts::default()
            })
            .collect();
        let v = |x: &[&str]| x.iter().map(|m| m.to_string()).collect::<Vec<_>>();
        let no_drain = GrowRule {
            may_remove: v(&["node", "vault"]),
            ..GrowRule::default()
        };
        assert!(
            no_drain
                .machines_refusal(&roles, &[], &v(&["node-2"]))
                .unwrap()
                .contains("takes no node-2 away: this product names no drain"),
            "no drain, no removal"
        );
        let rule = GrowRule {
            may_remove: v(&["node", "vault"]),
            drain: "drain".into(),
            moves_data: v(&["node"]),
            ..GrowRule::default()
        };
        assert_eq!(
            rule.machines_refusal(&roles, &[], &v(&["node-2", "vault-2"])),
            None,
            "both may go by the rule"
        );
        let disks = |m: &str| m != "node-3";
        assert_eq!(
            rule.data_refusal(&roles, &v(&["node-2", "node-3"]), &disks),
            None,
            "the estate moves a node's data; node-3 holds none"
        );
        let said = rule
            .data_refusal(&roles, &v(&["node-2", "vault-2"]), &disks)
            .unwrap();
        assert!(said.contains("vault-2 holds the estate's data, and this product does not move the data of a vault off a box"), "{said}");
        assert_eq!(
            rule.data_refusal(&roles, &v(&["vault-2"]), &|_| false),
            None,
            "a vault with no data disk holds nothing to move"
        );
        // On the wire: absent in an older guest's answer, and read back as named.
        assert_eq!(
            serde_json::from_value::<GrowRule>(serde_json::json!({"may_remove": ["node"]}))
                .unwrap()
                .drain,
            ""
        );
        let wire = serde_json::to_value(&rule).unwrap();
        assert_eq!(
            (wire["drain"].clone(), wire["moves_data"].clone()),
            (serde_json::json!("drain"), serde_json::json!(["node"]))
        );
        assert!(
            serde_json::to_value(GrowRule::sizes())
                .unwrap()
                .get("drain")
                .is_none(),
            "an unnamed drain is absent"
        );
    }

    /// A28 M23: a box refuses an order kind its product does not run BY NAME, and the list it
    /// reads is its facts' (`order_kinds`): no product code decides it.
    #[test]
    fn a_box_refuses_an_order_kind_its_facts_do_not_name() {
        let f = Facts {
            kind: "lake".into(),
            words: WireWords {
                name: "lake".into(),
                title: "Lake".into(),
                account_home: String::new(),
            },
            order_kinds: vec!["entitlement".into(), "add_principal".into()],
            ..Default::default()
        };
        assert_eq!(f.runs("entitlement"), Ok(()));
        assert_eq!(f.runs("add_principal"), Ok(()));
        let no = f.runs("grow_go_ahead").unwrap_err();
        assert!(no.starts_with("REFUSED [order-kind] "), "{no}");
        assert!(
            no.contains("`grow_go_ahead`")
                && no.contains("Lake")
                && no.contains("entitlement, add_principal"),
            "{no}"
        );
        assert!(f.runs("").is_err(), "an empty kind is no kind");
    }

    /// Facts that name no kinds are the old answer: the box decides, as before.
    #[test]
    fn facts_that_name_no_order_kinds_refuse_nothing_and_keep_their_wire_shape() {
        let f = Facts::default();
        assert_eq!(f.runs("anything"), Ok(()));
        let v = serde_json::to_value(&f).unwrap();
        assert!(
            v.get("order_kinds").is_none(),
            "an empty list is not on the wire"
        );
        let back: Facts =
            serde_json::from_value(serde_json::json!({"order_kinds": ["entitlement"]})).unwrap();
        assert_eq!(back.order_kinds, vec!["entitlement".to_string()]);
    }
}