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

nordisk/monetize

crates/monetize-server/src/estate_order/lifecycle.rs

//! **ONE lifecycle per product kind** (owner 2026-10-17): what a sold estate of a product IS —
//! the machines it is laid as, the order its boxes are updated in and the media each runs, the
//! boxes the shop upgrades in place, what a terminate keeps, and the sale check that lets its
//! money be captured. The core (`estate_order`) holds the money, the queue, the pen, the run
//! file, the cloud and the DNS; it asks THIS for every product-specific rule and hardcodes none.
//!
//! ```text
//!   [estate_orders] product = "<id>"  ──▶ [products.<id>] plugin  (else the id, else its family
//!                                         before the first '-': `gunnar-test2` is a gunnar)
//!                                     ──▶ the lifecycle registered for that kind
//!                                     ──▶ none: the start and the quote are REFUSED by name
//! ```
//!
//! There is no native lifecycle (v2.0 P2, T12 12.4): a product's own guest brings its lifecycle
//! ([`register`], from `guest_lifecycle::follow`); any other product is refused by name rather
//! than laid with someone else's rules. The tests sell on `crate::testing::lifecycles`.

use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};

use monetize_cloud::lay::EstatePlan;

use super::{EstateOrderConfig, EstateSizes, Installer, LadderStep, SaleCheckAt, Shape};
use crate::facts::{Facts, GrowRule, RoleFacts, TwinFacts};

/// **What the product is called** in the words its buyer reads (`Your Gunnar is ready`, `Your
/// gunnar acme.gunnar.rs is paid until …`, `a private Gunnar is not sold here`).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ProductWords {
    /// In a sentence's middle and in a line item (`gunnar`).
    pub name: String,
    /// As a name (`Gunnar`).
    pub title: String,
    /// Where a buyer's account of it lives, as the buyer knows it (`gunnar.rs`); empty: unnamed.
    pub account_home: String,
}

impl ProductWords {
    /// The words of a kind that names none: its kind, and the kind with a capital.
    pub fn of_kind(kind: &str) -> ProductWords {
        let mut c = kind.chars();
        let title = c
            .next()
            .map(|f| f.to_uppercase().collect::<String>() + c.as_str())
            .unwrap_or_default();
        ProductWords {
            name: kind.to_string(),
            title,
            account_home: String::new(),
        }
    }

    /// Where no product's lifecycle can be named (a box that sells no estates).
    pub fn neutral() -> ProductWords {
        ProductWords {
            name: "estate".into(),
            title: "estate".into(),
            account_home: String::new(),
        }
    }
}

/// **What a buyer's mails say of the product** beyond its name (lane M4): what it is, where it
/// serves, the buyer's first step and its pay page, each a line the product brings (its
/// lifecycle, native or guest). An empty line is not said. `{rp}` is the sign-in host,
/// `{account}` the buyer's account, `{pay_page}` the estate's pay page.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct MailWords {
    /// What it is, after its name in the thanks (`an artifact repository`).
    pub noun: String,
    /// Where the buyer reaches what it serves (`git is at git.{rp}.`).
    pub serves: String,
    /// What an own domain serves (`git and the web`); empty: the web.
    pub own_domain_serves: String,
    /// The buyer's first step (`Your first repository, {account}/welcome, says how to start.`).
    pub first_step: String,
    /// Where the next period is paid (`Pay for its next period at {pay_page}.`).
    pub pay_line: String,
}

impl MailWords {
    /// `self` with every line `over` names put in its place.
    pub fn overlaid(self, over: &MailWords) -> MailWords {
        let pick = |own: String, o: &str| {
            if o.trim().is_empty() {
                own
            } else {
                o.to_string()
            }
        };
        MailWords {
            noun: pick(self.noun, &over.noun),
            serves: pick(self.serves, &over.serves),
            own_domain_serves: pick(self.own_domain_serves, &over.own_domain_serves),
            first_step: pick(self.first_step, &over.first_step),
            pay_line: pick(self.pay_line, &over.pay_line),
        }
    }
}

/// **A machine whose data disks are grown** (the data set bought at the shop): its role, whether
/// its data disk holds the PRIMARY's whole set (and so follows the primary's paid size), and the
/// suffix its data set is booked under (`<product><suffix>`). The first is the primary.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DataRole {
    pub role: String,
    /// Its one data disk holds the primary's whole set (Gunnar's twin).
    pub mirrors_primary: bool,
    /// `-twin`: its set is `<product>-twin`; empty: `<product>`.
    pub set_suffix: String,
}

/// **How a sold estate is updated** to the release the shop sells now.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdatePlan {
    /// The boxes in the order they are updated (the primary keeps serving while its log-holder
    /// goes first; the box that drives the others last).
    pub order: Vec<String>,
    /// The medium kind the app boxes' release is read off (`release_id(cfg, app_medium)`).
    pub app_medium: String,
    /// The medium kind the driving box's (front's) release is read off.
    pub front_medium: String,
    /// The role that drives the update and is upgraded last, by itself or by the shop.
    pub front_role: String,
    /// The medium kind a box the shop upgrades is keep-booted from (`install_then=poweroff`).
    pub keep_medium: String,
    /// The medium kind the front is keep-booted from by the shop.
    pub front_keep_medium: String,
    /// The roles `SoldEstate.Upgrade` keep-boots (the front upgrades last, by itself).
    pub upgraded_by_shop: Vec<String>,
    /// **The boxes carry a live slot**: an update may stage the release's live app on them.
    /// `false`: every box of every update goes through its keep medium (Gunnar's and Holger's
    /// carry one; a product guest that declares none, as the toy).
    pub live_slot: bool,
}

/// **How a product lays an estate**: the plan for `company` at `sizes` (its guest's op 91, or a
/// test's own rule), asked with the lifecycle itself so a plan can read its facts.
pub type LayPlan = Arc<
    dyn Fn(&Lifecycle, &EstateOrderConfig, &str, &EstateSizes, &str) -> Result<EstatePlan, String>
        + Send
        + Sync,
>;

/// **A product kind's estate lifecycle** (MONETIZE-FIX step 3: no adapter trait, A24 §2.2): the
/// product's FACTS (op 90) and its lay plan (op 91), nothing else. Every rule the core asks is
/// read off the facts here; none of them moves money or writes the run.
#[derive(Clone)]
pub struct Lifecycle {
    /// The product id it came with (`[products.<id>]`), for the words of a refusal.
    pub product: String,
    /// What the product says it IS (op 90).
    pub facts: Facts,
    lay: LayPlan,
}

impl std::fmt::Debug for Lifecycle {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Lifecycle")
            .field("product", &self.product)
            .field("kind", &self.facts.kind)
            .finish()
    }
}

impl Lifecycle {
    /// The lifecycle of `product` with `facts`, laid by `lay`.
    pub fn new(product: impl Into<String>, facts: Facts, lay: LayPlan) -> Lifecycle {
        Lifecycle {
            product: product.into(),
            facts,
            lay,
        }
    }
    /// The product kind this is (`gunnar`).
    pub fn kind(&self) -> &str {
        &self.facts.kind
    }
    /// **LAY**: the plan `lay_estate` is asked for — every machine with its role, plan, disks
    /// and labels, `{company}` filled in. `Err` names why no plan could be made: nothing is laid.
    pub fn lay_plan(
        &self,
        cfg: &EstateOrderConfig,
        company: &str,
        sizes: &EstateSizes,
        twin_plan: &str,
    ) -> Result<EstatePlan, String> {
        (self.lay)(self, cfg, company, sizes, twin_plan)
    }
    /// **UPDATE / UPGRADE**: the order, the media, the keep media and the boxes the shop
    /// keep-boots ([`UpdatePlan`]).
    pub fn update_plan(&self) -> UpdatePlan {
        let f = &self.facts;
        UpdatePlan {
            order: f.update_order.clone(),
            app_medium: f.app_medium.clone(),
            front_medium: f.front_medium.clone(),
            front_role: f.front_role.clone(),
            keep_medium: f.keep_medium.clone(),
            front_keep_medium: f.front_keep_medium.clone(),
            upgraded_by_shop: f.upgraded_by_shop.clone(),
            // The guest says (B4); a guest built before the field reads as a live slot.
            live_slot: f.live_slot,
        }
    }
    /// **MEDIA**: the medium kind a machine of `role` is laid, templated, seeded and released from.
    pub fn medium_kind(&self, role: &str) -> String {
        self.facts
            .medium_of
            .get(role)
            .cloned()
            .unwrap_or_else(|| self.facts.other_medium.clone())
    }
    /// The roles whose disks hold the estate's data.
    pub fn backup_roles(&self) -> Vec<String> {
        self.facts.backup_roles.clone()
    }
    /// **SALE CHECK**: before the order's money is captured, prove the sold instance serves what
    /// the buyer is about to be sent to: the installer judges by the facts' proof (S4).
    pub fn sale_check(
        &self,
        installer: &dyn Installer,
        at: &SaleCheckAt<'_>,
    ) -> Result<String, String> {
        installer.sale_check(at)
    }
    /// **WORDS**: what the buyer reads the product called. A product that names none: its kind.
    pub fn words(&self) -> ProductWords {
        let w = &self.facts.words;
        if w.name.trim().is_empty() {
            return ProductWords::of_kind(self.kind());
        }
        ProductWords {
            name: w.name.clone(),
            title: w.title.clone(),
            account_home: w.account_home.clone(),
        }
    }
    /// **MAIL WORDS**: the `mail_words` rider of its facts' `default_shape` (lane M4).
    pub fn mail_words(&self) -> MailWords {
        super::guest_lifecycle::mail_words_over(None, &self.facts.default_shape)
    }
    /// **SHAPE**: the machines an `[estate_orders]` that names no `shape` is priced at, from its
    /// facts' `default_shape`. `None`: the config must name one.
    pub fn default_shape(&self) -> Option<Shape> {
        if self.facts.default_shape.is_null() {
            return None;
        }
        // A JSON that carries only the riders (`ladder`, `mail_words`, `names`) names no shape.
        if ["front", "main", "twin"]
            .iter()
            .all(|k| self.facts.default_shape.get(*k).is_none())
        {
            return None;
        }
        match serde_json::from_value(self.facts.default_shape.clone()) {
            Ok(s) => Some(s),
            Err(e) => {
                eprintln!("monetize-server: lifecycle {}: [products.{}] its default shape is not one ({e}); the config must name the shape", self.kind(), self.product);
                None
            }
        }
    }
    /// **SIZES**: the `ladder` rider of its facts' `default_shape`. `None`: the shop's ladder.
    pub fn ladder(&self) -> Option<Vec<LadderStep>> {
        let steps = self.facts.default_shape.get("ladder")?;
        match serde_json::from_value::<Vec<LadderStep>>(steps.clone()) {
            Ok(s) if !s.is_empty() => Some(s),
            Ok(_) => None,
            Err(e) => {
                eprintln!("monetize-server: lifecycle {}: [products.{}] its ladder is not one ({e}); the shop's ladder sells it", self.kind(), self.product);
                None
            }
        }
    }
    /// **GROW**: the machines whose data disks a bought data set grows, the primary first.
    pub fn data_roles(&self) -> Vec<DataRole> {
        self.facts
            .data_roles
            .iter()
            .map(|r| DataRole {
                role: r.role.clone(),
                mirrors_primary: r.mirrors_primary,
                set_suffix: r.set_suffix.clone(),
            })
            .collect()
    }
    /// **OWN DOMAIN**: the name of the TXT proof a customer's own domain carries.
    pub fn proof_name(&self) -> String {
        if self.facts.domain_proof_name.trim().is_empty() {
            format!("{}-proof", self.kind())
        } else {
            self.facts.domain_proof_name.clone()
        }
    }
    /// **NAMES**: the `names` rider of its facts' `default_shape` (`""`, `www.`, …); absent: the
    /// host, `www.` and `git.`.
    pub fn served_prefixes(&self) -> Vec<String> {
        super::guest_lifecycle::names_over(None, &self.facts.default_shape)
    }
    /// **PRIMARY**: its facts' `primary_role`, else the first data role.
    pub fn primary_role(&self) -> String {
        self.facts.primary()
    }
    /// **FACTS**: what the product says it IS (op 90).
    pub fn facts(&self) -> Facts {
        self.facts.clone()
    }
    /// **ROLES**: its facts' `roles`, else those its lifecycle fields name.
    pub fn roles(&self) -> Vec<String> {
        self.facts.role_names()
    }
    /// **ROLE FACTS**: its facts' `roles`, else each named role laid once.
    pub fn role_facts(&self) -> Vec<RoleFacts> {
        if self.facts.roles.is_empty() {
            return self
                .roles()
                .into_iter()
                .map(|role| RoleFacts {
                    role,
                    ..Default::default()
                })
                .collect();
        }
        self.facts.roles.clone()
    }
    /// **TWIN**: its facts' `twin`, else the data role that mirrors the primary.
    pub fn twin(&self) -> Option<TwinFacts> {
        self.facts.twin_of()
    }
    /// **GROW RULE**: its facts' `grow`, else main's data never shrinks.
    pub fn grow(&self) -> GrowRule {
        self.facts.grow_rule()
    }
}

/// Pure: **every laid member of each role in `order`** (monetize v2.0 P1, the Lake): a role,
/// then its numbered members `<role>-<n>` as laid, by `n` (`be`, `be-2`, `be-3`). A product
/// that lays each role once (Gunnar, Holger) gets `order` back as it is.
pub fn members_in(order: &[String], laid: &[&str]) -> Vec<String> {
    let mut out = Vec::new();
    for role in order {
        if laid.contains(&role.as_str()) && !out.contains(role) {
            out.push(role.clone());
        }
        let mut more: Vec<(u32, &str)> = laid
            .iter()
            .filter_map(|l| {
                l.strip_prefix(role.as_str())?
                    .strip_prefix('-')?
                    .parse::<u32>()
                    .ok()
                    .map(|n| (n, *l))
            })
            .collect();
        more.sort();
        for (_, l) in more {
            if !out.iter().any(|o| o == l) {
                out.push(l.to_string());
            }
        }
    }
    out
}

/// Pure: **`a, b and c`** (`joint` = `and`, `or`): the roles as a sentence names them.
pub fn listed(roles: &[String], joint: &str) -> String {
    match roles {
        [] => String::new(),
        [one] => one.clone(),
        [init @ .., last] => format!("{} {joint} {last}", init.join(", ")),
    }
}

/// **The product a run's ledger rows, setup lines and fleet feed are on**: the kind of `cfg`'s
/// product's lifecycle (`gunnar` for every Gunnar site, `gunnar-test2` too), else — no estates
/// sold here, or a product with no lifecycle — `cfg`'s product id, else `estate`.
pub fn instance_product(cfg: Option<&EstateOrderConfig>) -> String {
    match cfg {
        Some(c) => match of(c) {
            Ok(l) => l.kind().to_string(),
            Err(_) => c
                .sold_product()
                .map(str::to_owned)
                .unwrap_or_else(|_| ProductWords::neutral().name),
        },
        None => ProductWords::neutral().name,
    }
}

/// **The product's name in a sentence's middle** (`gunnar`): [`words_of`]'s `name`.
pub fn name_of(cfg: &EstateOrderConfig) -> String {
    words_of(Some(cfg)).name
}

/// **The buyer's words for `cfg`'s product**: its lifecycle's, else (no estates sold here)
/// [`ProductWords::neutral`].
pub fn words_of(cfg: Option<&EstateOrderConfig>) -> ProductWords {
    cfg.and_then(|c| of(c).ok())
        .map(|l| l.words())
        .unwrap_or_else(ProductWords::neutral)
}

/// **The buyer's mail words for `cfg`'s product**: its lifecycle's, else none.
pub fn mail_words_of(cfg: Option<&EstateOrderConfig>) -> MailWords {
    cfg.and_then(|c| of(c).ok())
        .map(|l| l.mail_words())
        .unwrap_or_default()
}

/// The lifecycles a plugin (a product's guest, or a test) brought, by kind.
static REGISTERED: RwLock<BTreeMap<String, Arc<Lifecycle>>> = RwLock::new(BTreeMap::new());

/// **A product plugin brings its lifecycle** for `kind`; a later one replaces it.
pub fn register(kind: &str, lifecycle: Arc<Lifecycle>) {
    REGISTERED
        .write()
        .unwrap_or_else(|e| e.into_inner())
        .insert(kind.to_string(), lifecycle);
}

/// **A plugin's lifecycle goes** (its guest is gone, or no longer brings one): nothing answers
/// for its kind any more.
pub fn unregister(kind: &str) {
    REGISTERED
        .write()
        .unwrap_or_else(|e| e.into_inner())
        .remove(kind);
}

/// The lifecycle a plugin (or a test) REGISTERED for `kind`, if any; never a native one.
pub fn registered(kind: &str) -> Option<Arc<Lifecycle>> {
    REGISTERED
        .read()
        .unwrap_or_else(|e| e.into_inner())
        .get(kind)
        .cloned()
}

/// The lifecycle of `kind`, if any: the one a plugin registered (there is no native one).
pub fn find(kind: &str) -> Option<Arc<Lifecycle>> {
    registered(kind)
}

/// **The lifecycle `cfg`'s product is sold with**, or the refusal that names it: the kind its
/// `[products.<id>] plugin` names ([`EstateOrderConfig::lifecycle`]), else the product id, else
/// its family before the first `-` (a site's own product, `gunnar-test2`).
pub fn of(cfg: &EstateOrderConfig) -> Result<Arc<Lifecycle>, String> {
    let product = cfg.sold_product()?;
    let named = cfg.lifecycle.trim();
    if !named.is_empty() {
        return find(named).ok_or_else(|| refused(product, named));
    }
    if let Some(l) = find(product) {
        return Ok(l);
    }
    match product.split_once('-') {
        Some((family, _)) if !family.is_empty() => {
            find(family).ok_or_else(|| refused(product, product))
        }
        _ => Err(refused(product, product)),
    }
}

/// Pure: **a product id's family**: the part before its first `-` (`gunnar-test2` → `gunnar`,
/// `holger-rs` → `holger`), else the id itself — the rule [`of`] reads a site's own product by.
pub fn family_of(product: &str) -> &str {
    let p = product.trim();
    p.split_once('-')
        .map(|(f, _)| f)
        .filter(|f| !f.is_empty())
        .unwrap_or(p)
}

fn refused(product: &str, kind: &str) -> String {
    format!("[estate_orders] product {product:?} has no estate lifecycle (kind {kind:?}): nothing here lays, updates or ends such an estate, so none is sold")
}

/// **The plan the core lays**: [`Lifecycle::lay_plan`] of `cfg`'s product, refused by name
/// for a product with no lifecycle.
pub fn lay_plan_of(
    cfg: &EstateOrderConfig,
    company: &str,
    sizes: &EstateSizes,
    twin_plan: &str,
) -> Result<EstatePlan, String> {
    plan_of(of(cfg)?.as_ref(), cfg, company, sizes, twin_plan)
}

/// Pure: **the product's shape, as the cloud checks a plan by it** (A4-FACTS): its role facts
/// ([`Lifecycle::role_facts`]) — which roles it lays, which n times, which may be left out,
/// which ports each opens to anyone.
pub fn shape_of(life: &Lifecycle) -> monetize_cloud::lay::PlanShape {
    // The ports each role opens to anyone ride along as the product declares them (OX-WGDS5).
    let open = |ports: Vec<monetize_product_guest::facts::PortFacts>| {
        ports
            .into_iter()
            .map(|p| monetize_cloud::lay::OpenPort {
                protocol: p.protocol,
                port: p.port,
                why: p.why,
            })
            .collect()
    };
    monetize_cloud::lay::PlanShape {
        roles: life
            .role_facts()
            .into_iter()
            .map(|r| monetize_cloud::lay::ShapeRole {
                role: r.role,
                many: r.many,
                optional: r.optional,
                open: open(r.open),
            })
            .collect(),
    }
}

/// **`laid` as its firewall is written NOW** (OX-WGDS5): the record with the product's shape as
/// its facts say it now ([`shape_of`]), so every firewall written again — an update's, a plan
/// change's — opens each machine's declared ports and no other, also on an estate laid before
/// its product declared one. A product with no lifecycle here keeps the record's own shape.
pub fn shaped_now(
    cfg: &EstateOrderConfig,
    laid: &monetize_cloud::lay::LaidEstate,
) -> monetize_cloud::lay::LaidEstate {
    let shape = of(cfg)
        .map(|l| Some(shape_of(&l)))
        .unwrap_or_else(|_| laid.shape.clone());
    monetize_cloud::lay::LaidEstate {
        shape,
        ..laid.clone()
    }
}

/// **`life`'s plan, shaped** (A4-FACTS): [`Lifecycle::lay_plan`] with the product's own
/// shape on it ([`shape_of`]), so the cloud's check holds the plan to the product's roles and
/// never to Gunnar's three machines. Every plan the core prices, lays or lays beside an estate
/// is made here.
pub fn plan_of(
    life: &Lifecycle,
    cfg: &EstateOrderConfig,
    company: &str,
    sizes: &EstateSizes,
    twin_plan: &str,
) -> Result<EstatePlan, String> {
    let mut plan = life.lay_plan(cfg, company, sizes, twin_plan)?;
    plan.shape = Some(shape_of(life));
    Ok(plan)
}

#[cfg(test)]
mod tests {
    #[test]
    fn the_members_of_a_role_laid_n_times_follow_it_and_a_role_laid_once_is_itself() {
        let o = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
        assert_eq!(
            super::members_in(&o(&["twin", "main", "front"]), &["front", "main", "twin"]),
            o(&["twin", "main", "front"])
        );
        assert_eq!(
            super::members_in(
                &o(&["front", "ingestor", "fe", "be"]),
                &[
                    "front",
                    "ingestor",
                    "be",
                    "be-10",
                    "be-2",
                    "fe",
                    "ingestor-2",
                    "graph",
                    "berry"
                ]
            ),
            o(&[
                "front",
                "ingestor",
                "ingestor-2",
                "fe",
                "be",
                "be-2",
                "be-10"
            ])
        );
    }

    use super::*;
    use monetize_cloud::lay::MachinePlan;

    /// A product kind with an estate of ONE machine — not Gunnar's shape.
    fn solo_plan(
        _: &Lifecycle,
        cfg: &EstateOrderConfig,
        company: &str,
        _sizes: &EstateSizes,
        _twin_plan: &str,
    ) -> Result<EstatePlan, String> {
        let m = MachinePlan {
            role: "solo".into(),
            hostname: format!("solo-{company}"),
            title: String::new(),
            plan: "STARTER-1xCPU-1GB".into(),
            boot_order: String::new(),
            labels: Default::default(),
            interfaces: Vec::new(),
            medium: String::new(),
            medium_object: None,
            template: String::new(),
            user_data: Default::default(),
            disks: Vec::new(),
        };
        Ok(EstatePlan {
            reference: monetize_cloud::lay::reference_of(company),
            company: company.into(),
            zone: monetize_cloud::Zone(cfg.zone.clone()),
            machines: vec![m],
            narrate_from: cfg.narrate_source().to_string(),
            shape: None,
        })
    }

    /// The facts of a one-machine kind `kind`.
    fn solo_facts(kind: &str) -> Facts {
        Facts {
            kind: kind.into(),
            update_order: vec!["solo".into()],
            app_medium: "solo".into(),
            front_role: "solo".into(),
            keep_medium: "solo-keep".into(),
            live_slot: false,
            other_medium: "solo".into(),
            backup_roles: vec!["solo".into()],
            ..Facts::default()
        }
    }

    fn solo() -> Lifecycle {
        Lifecycle::new("solo-kind", solo_facts("solo-kind"), Arc::new(solo_plan))
    }

    fn cfg(product: &str) -> EstateOrderConfig {
        EstateOrderConfig {
            product: product.into(),
            apex: "example.com".into(),
            vendor: "holdpay".into(),
            shop_door: "https://shop.example.com".into(),
            ..EstateOrderConfig::default()
        }
    }

    /// ★ **The core lays what the product's lifecycle says**: a kind that registered its own
    /// lifecycle is laid as ITS machines, never as Gunnar's front, main and twin.
    #[test]
    fn the_core_lays_with_the_lifecycle_the_product_registered() {
        register("solo-kind", Arc::new(solo()));
        let plan = lay_plan_of(&cfg("solo-kind"), "acme", &EstateSizes::default(), "")
            .expect("a registered kind is laid");
        assert_eq!(
            plan.machines
                .iter()
                .map(|m| m.role.as_str())
                .collect::<Vec<_>>(),
            ["solo"],
            "its own machines"
        );
        assert_eq!(of(&cfg("solo-kind")).unwrap().update_plan().order, ["solo"]);
        assert_eq!(of(&cfg("solo-kind")).unwrap().backup_roles(), ["solo"]);
    }

    /// ★ **An estate's DNS names are its product's** (A17, the echo bench): a kind that serves no
    /// git gets no `git.<host>`; Gunnar keeps its three names, Holger serves two (A28 M8).
    #[test]
    fn an_estates_names_are_the_ones_its_product_serves() {
        let web = Lifecycle::new(
            "web-kind",
            Facts {
                backup_roles: Vec::new(),
                default_shape: serde_json::json!({ "names": ["", "www."] }),
                ..solo_facts("web-kind")
            },
            Arc::new(solo_plan),
        );
        register("web-kind", Arc::new(web));
        assert_eq!(
            cfg("web-kind").names_of("acme"),
            ["acme.example.com", "www.acme.example.com"],
            "no git. for a product that serves none"
        );
        crate::testing::lifecycles::install();
        assert_eq!(
            cfg("gunnar").names_of("acme"),
            [
                "acme.example.com",
                "www.acme.example.com",
                "git.acme.example.com"
            ],
            "gunnar keeps its three names"
        );
        assert_eq!(
            cfg("holger").names_of("acme"),
            [
                "acme.example.com",
                "www.acme.example.com",
                "repo.acme.example.com"
            ],
            "holger serves no git (M8), and its repository's name"
        );
        assert_eq!(
            crate::testing::lifecycles::holger().served_prefixes(),
            ["", "www.", "repo."]
        );
    }

    /// ★ HOL-REPO-NAME: **every name the product serves under the apex is the serving estate's
    /// own** — a sold Holger estate called `repo` would be `repo.<apex>`, the operator site's
    /// repository name: its records are refused by name, as `www`, `git` and `monetize` are.
    #[test]
    fn a_sold_estate_never_takes_a_served_name_of_the_serving_estate() {
        crate::testing::lifecycles::install();
        let c = EstateOrderConfig {
            product: "holger".into(),
            apex: "holger.rs".into(),
            ..EstateOrderConfig::default()
        };
        let e = c
            .records_of("repo", "198.51.100.1")
            .expect_err("repo.holger.rs is the site's own repository name");
        assert!(
            e.contains("repo.holger.rs") && e.contains("serving estate's own name"),
            "{e}"
        );
        let ok = c
            .records_of("acme", "198.51.100.1")
            .expect("a buyer's names");
        assert_eq!(
            ok.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
            [
                "acme.holger.rs",
                "www.acme.holger.rs",
                "repo.acme.holger.rs"
            ]
        );
    }

    /// ★ **A product with no lifecycle is refused by NAME** — the plan, and so the quote and the
    /// lay, never fall back to Gunnar's rules.
    #[test]
    fn a_product_with_no_lifecycle_is_refused_by_name_never_given_gunnars() {
        for p in ["acme", "zork-rs"] {
            let e = lay_plan_of(&cfg(p), "acme", &EstateSizes::default(), "")
                .err()
                .unwrap_or_else(|| panic!("{p}: laid with someone else's rules"));
            assert!(
                e.contains(&format!("{p:?}")) && e.contains("lifecycle"),
                "{p}: {e}"
            );
            assert!(of(&cfg(p)).is_err(), "{p}");
        }
        let Err(e) = of(&cfg("")) else {
            panic!("no product, no lifecycle")
        };
        assert!(e.contains("[estate_orders] product"), "{e}");
    }

    /// ★ **A Gunnar-kind product is laid by the lifecycle registered for `gunnar`**: `gunnar`, a
    /// site of it (`gunnar-test2`), and a product whose `[products.<id>] plugin` is gunnar.
    #[test]
    fn a_gunnar_kind_product_is_laid_exactly_as_before() {
        crate::testing::lifecycles::install();
        let c = cfg("gunnar-test2");
        assert_eq!(of(&c).unwrap().kind(), "gunnar");
        assert_eq!(
            lay_plan_of(&c, "acme", &EstateSizes::default(), "").unwrap(),
            plan_of(
                &crate::testing::lifecycles::gunnar(),
                &c,
                "acme",
                &EstateSizes::default(),
                ""
            )
            .unwrap()
        );
        let named = EstateOrderConfig {
            lifecycle: "gunnar".into(),
            ..cfg("shop1")
        };
        assert_eq!(of(&named).unwrap().kind(), "gunnar", "named by its plugin");
        let u = of(&cfg("gunnar")).unwrap().update_plan();
        assert_eq!(
            (
                u.order.join(","),
                u.app_medium.as_str(),
                u.keep_medium.as_str()
            ),
            ("twin,main,front".to_string(), "gunnar", "gunnar-keep")
        );
    }

    /// ★ **The kind comes from config**: `[estate_orders] product` → `[products.<id>] plugin`.
    #[test]
    fn the_start_reads_the_kind_off_the_products_plugin() {
        register("solo-kind", Arc::new(solo()));
        let dir =
            std::env::temp_dir().join(format!("monetize-lifecycle-kind-{}", std::process::id()));
        let text = format!(
            "data_dir = {:?}\n[products.shop2]\nplugin = \"solo-kind\"\n[estate_orders]\ncurrency = \"EUR\"\nproduct = \"shop2\"\nshop_door = \"https://shop.example.com\"\n",
            dir.display().to_string()
        );
        let config = crate::config::Config::from_toml(&text).unwrap();
        let orders = super::super::EstateOrders::from_config_with(
            &config,
            &crate::registry::Linked::default(),
        )
        .expect("starts");
        let c = orders.cfg.as_ref().unwrap();
        assert_eq!(of(c).unwrap().kind(), "solo-kind");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// ★ **The START refuses a product with no lifecycle by name**, before anything is held.
    #[test]
    fn the_start_refuses_a_product_with_no_lifecycle_by_name() {
        let dir =
            std::env::temp_dir().join(format!("monetize-lifecycle-start-{}", std::process::id()));
        let mut state = crate::state::AppState::in_memory(crate::registry::Registry::new());
        state.estates = super::super::EstateOrders::with(
            cfg("acme"),
            dir.join("estate-orders"),
            Arc::new(super::super::NoInstaller),
            Arc::new(super::super::NoDns),
            crate::custody::Custody::open(dir.join("custody")).unwrap(),
        );
        let e = super::super::start_for(
            &state,
            "acme",
            "buyer@example.com",
            "",
            "alice",
            &EstateSizes::default(),
        )
        .expect_err("refused");
        let words = e.to_string();
        assert!(
            words.contains("\"acme\"") && words.contains("lifecycle"),
            "{words}"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// A product kind that names its own words, proof, shape and data machines.
    fn own() -> Lifecycle {
        let mut shape = serde_json::to_value(crate::testing::lifecycles::gunnar_shape()).unwrap();
        shape["front"]["hostname"] = "solo-front-{company}".into();
        let facts = Facts {
            words: monetize_product_guest::wire::WireWords {
                name: "solo".into(),
                title: "Solo".into(),
                account_home: "solo.example".into(),
            },
            default_shape: shape,
            data_roles: vec![monetize_product_guest::wire::WireDataRole {
                role: "solo".into(),
                mirrors_primary: false,
                set_suffix: "-data".into(),
            }],
            domain_proof_name: "solo-proof".into(),
            ..solo_facts("own-kind")
        };
        Lifecycle::new("own-kind", facts, Arc::new(solo_plan))
    }

    /// ★ **The type's default shape is a Mini of three machines that names no product** (v2.0
    /// P3): the Gunnar-shaped test lifecycle's plans and disks, named by role alone.
    #[test]
    fn the_types_default_shape_is_a_mini_that_names_no_product() {
        let (d, g) = (Shape::default(), crate::testing::lifecycles::gunnar_shape());
        for (role, a, b) in [
            ("front", &d.front, &g.front),
            ("main", &d.main, &g.main),
            ("twin", &d.twin, &g.twin),
        ] {
            assert_eq!(
                (
                    &a.plan,
                    a.disks
                        .iter()
                        .map(|x| (x.gib, &x.tier, &x.volume))
                        .collect::<Vec<_>>()
                ),
                (
                    &b.plan,
                    b.disks
                        .iter()
                        .map(|x| (x.gib, &x.tier, &x.volume))
                        .collect::<Vec<_>>()
                ),
                "{role}: the Mini's plan and disks"
            );
            assert_eq!(a.hostname, format!("{role}-{{company}}"));
        }
        assert!(
            !serde_json::to_string(&d)
                .unwrap()
                .to_lowercase()
                .contains("gunnar"),
            "the core's default names no product"
        );
        assert_eq!(EstateOrderConfig::default().shape, Shape::default());
    }

    /// ★ **A kind is spoken of, and proven, in its OWN words** — never Gunnar's: what the buyer
    /// reads of an order, the start's refusals, the account sentence, the records an own domain
    /// carries.
    #[test]
    fn a_kind_is_spoken_of_and_proven_in_its_own_words_never_gunnars() {
        let w = own().words();
        assert_eq!(super::super::account::not_proven(&w), "a private Solo is bought, read and changed by its solo.example account: sign in to that account and ask again");
        assert_eq!(super::super::account::not_proven(&crate::testing::lifecycles::gunnar_words()), "a private Gunnar is bought, read and changed by its gunnar.rs account: sign in to that account and ask again", "Gunnar's sentence as before");
        assert_eq!(super::super::no_install_path(&crate::testing::lifecycles::gunnar_words()), "this shop has no way to install a private Gunnar yet ([estate_orders] names no pairing, installer or install worker)");
        assert!(super::super::no_install_path(&w).contains("a private Solo yet"));
        assert!(super::super::reserved_account_as("kit-1", &[], &w)
            .unwrap()
            .contains("the new Solo keeps"));
        assert!(
            super::super::reserved_account_as("solo-ui", &["solo-ui".to_string()], &w)
                .unwrap()
                .contains("the new Solo keeps"),
            "the product's own machine principal"
        );
        assert!(
            super::super::reserved_account_as("gunnar-ui", &[], &w).is_none(),
            "another product's machine names are no business of this one's"
        );
        let r = super::super::domain::required_as(
            &own().proof_name(),
            "x.example.org",
            "acme",
            "example.com",
            "t",
        );
        assert_eq!(
            (r[1].name.as_str(), r[1].value.as_str()),
            ("_solo-proof.x.example.org", "solo-proof=t")
        );
        assert_eq!(
            crate::services::estate_order::not_sold_here(&w),
            "A private Solo is not sold here right now."
        );
        assert_eq!(
            crate::services::estate_order::not_sold_here(
                &crate::testing::lifecycles::gunnar_words()
            ),
            "A private Gunnar is not sold here right now."
        );
        for line in [
            super::super::account::not_proven(&w),
            super::super::no_install_path(&w),
        ] {
            assert!(!line.to_lowercase().contains("gunnar"), "{line}");
        }
    }

    /// ★ **A kind that names nothing is spoken of by its kind**, proves with `<kind>-proof`,
    /// grows no data and has no shape of its own — never Gunnar's facts by default.
    #[test]
    fn a_kind_that_names_no_facts_gets_its_kinds_never_gunnars() {
        assert_eq!(
            solo().words(),
            ProductWords {
                name: "solo-kind".into(),
                title: "Solo-kind".into(),
                account_home: String::new()
            }
        );
        assert_eq!(solo().proof_name(), "solo-kind-proof");
        assert!(solo().data_roles().is_empty() && solo().default_shape().is_none());
        assert_eq!(
            words_of(None),
            ProductWords::neutral(),
            "no estates sold here: no product named"
        );
    }

    /// ★ **An unset `[estate_orders.shape]` is the product's lifecycle's**; a named one is the
    /// file's.
    #[test]
    fn an_unset_shape_is_the_lifecycles_and_a_named_one_the_files() {
        register("own-kind", Arc::new(own()));
        let dir =
            std::env::temp_dir().join(format!("monetize-lifecycle-shape-{}", std::process::id()));
        let base = format!("data_dir = {:?}\n[products.shop3]\nplugin = \"own-kind\"\n[estate_orders]\ncurrency = \"EUR\"\nproduct = \"shop3\"\nshop_door = \"https://shop.example.com\"\n", dir.display().to_string());
        let orders = super::super::EstateOrders::from_config_with(
            &crate::config::Config::from_toml(&base).unwrap(),
            &crate::registry::Linked::default(),
        )
        .expect("starts");
        assert_eq!(
            orders.cfg.as_ref().unwrap().shape.front.hostname,
            "solo-front-{company}",
            "the lifecycle's shape"
        );
        let mut named = crate::testing::lifecycles::gunnar_shape();
        named.front.hostname = "named-{company}".into();
        let mut config = crate::config::Config::from_toml(&base).unwrap();
        config.estate_orders.as_mut().unwrap().shape_given = Some(named);
        let orders = super::super::EstateOrders::from_config_with(
            &config,
            &crate::registry::Linked::default(),
        )
        .expect("starts");
        assert_eq!(
            orders.cfg.as_ref().unwrap().shape.front.hostname,
            "named-{company}",
            "the file's shape"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }
}