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()]);
}
}