crates/gunnar-monetize-guest/src/lifecycle.rs
//! **Gunnar's estate lifecycle, from the guest** (owner 2026-10-17): what a sold Gunnar IS —
//! a front (caddy, gunnar-ui, the relay), main (the forge) and a twin (the log-holder) — served
//! to the ONE monetize through `lifecycle.facts` (op 90) and `lifecycle.lay_plan` (op 91), so the
//! shop's core holds no Gunnar rule of its own.
//!
//! These are the rules monetize-server's native `estate_order::lifecycle::Gunnar` has, byte for
//! byte (`tests/lifecycle_parity.rs` asserts it against the server itself): updated twin, main,
//! front; main and twin keep-booted from `gunnar-keep` by the shop, the front from `front-keep`;
//! the front laid from the `front` media, main and twin from `gunnar`; main's disks kept at a
//! terminate; the twin follows main (its plan chosen from main's, its data disk holding main's
//! whole data set).
use std::collections::BTreeMap;
use monetize_cloud::lay::{
default_interfaces, reference_of, DiskPlan, EstatePlan, MachinePlan, NetInterface,
};
use monetize_cloud::Zone;
use monetize_product_guest::facts::{
Billing, Facts, FirstDoorProof, GrowRule, ProofAsk, ProofCheck, ProofExpect, ProofMethod,
RoleFacts, SaleProof, TwinFacts,
};
use monetize_product_guest::wire::{LayPlanReq, WireDataRole, WireWords};
/// The product kind.
pub const KIND: &str = "gunnar";
/// Pure: the medium kind a machine of `role` is laid, seeded and released from.
pub fn medium_kind(role: &str) -> &'static str {
if role == "front" {
"front"
} else {
"gunnar"
}
}
/// Pure: **the facts** — the update plan, each role's medium, what a terminate keeps, and what
/// the shop's pairing speaks for a sold Gunnar (its contract, its sale proof; v2.0 P3: the ONE
/// box's core holds no Gunnar contract of its own).
pub fn facts() -> Facts {
let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
Facts {
kind: KIND.into(),
update_order: s(&["twin", "main", "front"]),
app_medium: "gunnar".into(),
front_medium: "front".into(),
front_role: "front".into(),
keep_medium: "gunnar-keep".into(),
front_keep_medium: "front-keep".into(),
upgraded_by_shop: s(&["main", "twin"]),
// Gunnar takes a release live into its other app slot (as monetize's native lifecycle says).
live_slot: true,
medium_of: BTreeMap::from([("front".to_string(), "front".to_string())]),
other_medium: "gunnar".into(),
backup_roles: s(&["main"]),
words: WireWords {
name: "gunnar".into(),
title: "Gunnar".into(),
account_home: "gunnar.rs".into(),
},
default_shape: carried(default_shape()),
data_roles: vec![
WireDataRole {
role: "main".into(),
mirrors_primary: false,
set_suffix: String::new(),
},
WireDataRole {
role: "twin".into(),
mirrors_primary: true,
set_suffix: "-twin".into(),
},
],
domain_proof_name: "gunnar-proof".into(),
// Gunnar's main mints the actor tickets; the ONE box checks them on the host under the
// appliance key it holds (op 40, merged; whether a key is pinned is the host's business).
tickets: true,
test_actions: crate::test_actions(),
// Gunnar's estate guardian (op 60's guardian lane, merged).
guardian: true,
sold_variant: Some("gunnar-twin-monetize".into()),
site_variant: Some("gunnar-twin-one".into()),
box_kind: Some(KIND.into()),
free_feed: Some("plugin.gunnar.free".into()),
welcome: Some(true),
welcome_readme: Some(WELCOME_README.into()),
// The console's and the guest's own principals on the forge: never a buyer's account.
reserved_accounts: vec!["gunnar-ui".into(), "gunnar-guest".into()],
// The sold console serves its own pay page (`/billing/{tenant}`).
billing: Some(Billing::Console),
// gunnar-front `console::FEED_ADMIN_TOKEN`: the front alone holds the console's bearer.
console_token_feed: Some("console_admin_token".into()),
// Gunnar's off-site copy is the operator's pull of the front's `/backup/stream`.
offsite: Some(false),
boxes_hold_console_token: Some(false),
passkey_rp_feed: None,
// gunnar-repair `order::kind::REFEED_KEYS`: a Gunnar box takes the mail relay's keys and
// the public site's switch again and refuses a refeed with any other key,
// `public_repositories` among them, so the ONE box feeds a Gunnar site's boxes none
// (OX-WIRED 2026-10-28).
public_repositories: Some(false),
// GRS-PUBLIC (live bug 2026-10-28): a Gunnar main takes the site's public-site switch in
// a refeed (`console_public_site`) and stores it as its console's switch, so a site laid
// before its `public_site` declaration (gunnar.rs) is told by `site refeed`.
public_site: Some(true),
proof: Some(sale_proof()),
// ── What a Gunnar estate IS, as data (monetize v2.0 P3-FACTS, A24 §2.2): the core
// decides none of it by Gunnar's shape any more.
// Main holds the tenants: the ticket key, the backups, the report the shop meters by.
primary_role: "main".into(),
roles: ["front", "main", "twin"]
.iter()
.map(|r| RoleFacts {
role: r.to_string(),
..RoleFacts::default()
})
.collect(),
// The twin mirrors main and is grown by its load (the election: the weakest of two).
twin: Some(TwinFacts {
role: "twin".into(),
mirrors: "main".into(),
}),
// Main's data never shrinks; a plan change resizes the three machines, adds none.
grow: Some(GrowRule::sizes()),
..Facts::default()
}
}
/// **The buyer's welcome README** the primary makes `<account>/welcome` from (the shop fills in
/// `{host}`, `{account}` and `{repo}`): thanks, the login, the clone line, a passkey, inviting
/// people (no-dwarf step 7, the owner 2026-10-08).
pub const WELCOME_README: &str = "# Welcome to your own Gunnar\n\n\
Thank you for buying **{host}**. It is yours: the forge, the console and every byte on it.\n\n\
## Start here\n\n\
1. **Sign in** at <https://{host}/login> with the passkey you registered from the link in our mail.\n\
2. **Add a second passkey** (a spare key, or your phone) and an SSH key under *You → Keys & passkeys*.\n\
3. **Clone this repository**:\n\n ```\n git clone ssh://git@git.{host}:2222/{account}/{repo}.git\n ```\n\n\
4. **Invite the people who work with you** under *Account → People*.\n\n\
This repository is yours to keep, rename or delete.\n";
/// gunnar-ui-server's public routes the sale check reads (its router; gunnar-front's
/// `tests/shop_pairing_contract.rs` holds them to it).
pub mod doors {
pub const LOGIN_PATH: &str = "/login";
/// A word only the console's login page carries: its passkey sign-in.
pub const LOGIN_PAGE_MARK: &str = "/auth/passkey/login/start";
pub const AUTH_STATUS_PATH: &str = "/auth/status";
pub const REGISTER_START_PATH: &str = "/auth/passkey/register/start";
/// The repository list, read with the console's break-glass bearer.
pub const REPOS_PATH: &str = "/api/repos";
/// The most repositories one page lists (`?limit=`).
pub const REPOS_MAX: usize = 2000;
/// The buyer's first repository, made by the primary from the welcome README.
pub const WELCOME_REPO: &str = "welcome";
}
/// Pure: **what proves a sale of Gunnar** (S4, A28 H12), checked over the sold console's PUBLIC
/// `https://<host>` before the money is captured: the login page answers (its passkey sign-in on
/// it); the first-login door offers passkeys and is unspent, and the ready mail's own link
/// (`/auth/passkey/register/start?invite=…&user=<account>`) gets a challenge (nothing is spent:
/// the invite is redeemed only at `finish`); and the console lists, to its bearer, exactly ONE
/// repository named `welcome`, the buyer's.
pub fn sale_proof() -> SaleProof {
use doors::*;
let get = |path: String, bearer: bool| ProofAsk {
method: ProofMethod::Get,
path,
body: None,
bearer,
};
let is = |field: &str, value: bool| ProofExpect::Equals {
field: field.into(),
value: serde_json::Value::Bool(value),
};
let start = get(
format!("{REGISTER_START_PATH}?invite={{invite}}&user={{account}}"),
false,
);
let challenge = "/publicKey/challenge";
SaleProof {
checks: vec![
ProofCheck {
what: "the login page".into(),
ask: get(LOGIN_PATH.into(), false),
expect: vec![ProofExpect::Contains {
text: LOGIN_PAGE_MARK.into(),
}],
},
ProofCheck {
what: "the first-login door".into(),
ask: get(AUTH_STATUS_PATH.into(), false),
expect: vec![is("/passkey_enabled", true), is("/bootstrap_spent", false)],
},
ProofCheck {
what: "the first-login door".into(),
ask: start.clone(),
expect: vec![ProofExpect::Text {
field: challenge.into(),
}],
},
ProofCheck {
what: "the welcome repository".into(),
ask: get(format!("{REPOS_PATH}?limit={REPOS_MAX}"), true),
expect: vec![
is("/truncated", false),
ProofExpect::ExactlyOne {
list: "/items".into(),
name_field: "name".into(),
name: WELCOME_REPO.into(),
id_field: "path".into(),
id: format!("{{account}}/{WELCOME_REPO}"),
},
],
},
],
needs_account: true,
first_door: Some(FirstDoorProof {
status: get(AUTH_STATUS_PATH.into(), false),
spent: "/bootstrap_spent".into(),
enabled: "/passkey_enabled".into(),
start,
challenge: challenge.into(),
}),
}
}
/// Pure: **what the buyer's mails say of Gunnar** beyond its name (monetize's `MailWords`): git
/// is at `git.{rp}`, the first repository is `{account}/welcome`, no pay line. `{rp}` is the
/// sign-in host, `{account}` the buyer's account. The ONE box links no Gunnar rule, so the guest
/// says them itself (monetize v2.0 P2 12.1, A28 H5).
pub fn mail_words() -> serde_json::Value {
serde_json::json!({
"noun": "",
"serves": "git is at git.{rp}.",
"own_domain_serves": "git and the web",
"first_step": "Your first repository, {account}/welcome, says how to start.",
"pay_line": "",
})
}
/// The names an estate of Gunnar serves under its host: the host, `www.` and `git.` (A17).
pub const NAMES: [&str; 3] = ["", "www.", "git."];
/// **The served names its front serves on :443 as a redirect only** (HOL-REPO-NAME, kit parity
/// h-front-sans): the forge is ssh (`ssh://git@git.<site>:2222`), so `git.<site>` is on the
/// front's certificate (https there verifies) and every path is sent to the site; the shop's
/// pairing feeds it as `web_callback_only`. Rides in the shape as `names` does.
pub const REDIRECT_NAMES: [&str; 1] = ["git."];
/// Pure: **the shape with what rides in it** — the mail words (`mail_words`), the served
/// names (`names`) and the redirect-only ones (`redirect_names`): the facts wire has no field for either, and a reader of the shape ignores a
/// key it does not name (as Holger's ladder rides).
pub fn carried(mut shape: serde_json::Value) -> serde_json::Value {
if let Some(o) = shape.as_object_mut() {
o.insert("mail_words".into(), mail_words());
o.insert("names".into(), serde_json::json!(NAMES));
o.insert("redirect_names".into(), serde_json::json!(REDIRECT_NAMES));
}
shape
}
/// Pure: **the shape an order that names no size is laid at: Mini** (owner, 2026-10-13) — a
/// 2-core 4 GB front, a 4-core 8 GB main, a 1-core 4 GB twin; the kit's own declaration
/// (`private-gunnar-ops/terraform`), templates over `{company}`.
pub fn default_shape() -> serde_json::Value {
let d = |title: &str, gib: u64, tier: &str, volume: &str| serde_json::json!({ "title": title, "gib": gib, "tier": tier, "volume": volume });
let ifs = serde_json::to_value(default_interfaces()).unwrap_or_default();
let m = |hostname: &str,
title: &str,
plan: &str,
role_label: &str,
disks: Vec<serde_json::Value>| {
serde_json::json!({ "hostname": hostname, "title": title, "plan": plan, "boot_order": "disk,cdrom", "role_label": role_label, "disks": disks, "interfaces": ifs })
};
serde_json::json!({
"front": m("gunnar-front-{company}", "gunnar front — caddy, gunnar-ui, the relay-{company}", "STARTER-2xCPU-4GB", "front", vec![d("gunnar-front-{company} system", 40, "standard", "system")]),
"main": m("gunnar-appliance-{company}", "gunnar appliance — the forge, no shell-{company}", "STARTER-4xCPU-8GB", "appliance", vec![
d("gunnar-appliance-{company} system", 50, "standard", "system"),
d("gunnar-appliance-{company} data 1", 10, "maxiops", "data1"),
d("gunnar-appliance-{company} data 2", 10, "maxiops", "data2"),
d("gunnar-appliance-{company} data 3", 10, "maxiops", "data3"),
d("gunnar-appliance-{company} data 4", 10, "maxiops", "data4"),
]),
"twin": m("gunnar-twin-{company}", "gunnar twin — the log-holder, no shell-{company}", "STARTER-1xCPU-4GB", "twin", vec![
d("gunnar-twin-{company} system", 20, "standard", "system"),
d("gunnar-twin-{company} data", 20, "hdd", "twin"),
]),
})
}
#[derive(serde::Deserialize)]
struct Shape {
front: MachineShape,
main: MachineShape,
twin: MachineShape,
}
#[derive(Clone, serde::Deserialize)]
struct MachineShape {
hostname: String,
title: String,
plan: String,
#[serde(default = "disk_cdrom")]
boot_order: String,
role_label: String,
disks: Vec<DiskShape>,
#[serde(default = "default_interfaces")]
interfaces: Vec<NetInterface>,
}
#[derive(Clone, serde::Deserialize)]
struct DiskShape {
title: String,
gib: u64,
tier: String,
volume: String,
}
#[derive(Default, serde::Deserialize)]
struct Sizes {
#[serde(default)]
front_plan: String,
#[serde(default)]
main_plan: String,
#[serde(default)]
main_data_gib: u64,
}
fn disk_cdrom() -> String {
"disk,cdrom".into()
}
/// Pure: **the plan an estate is laid as** — the config's shape with `{company}` filled in,
/// every resource labelled with its role (and a disk with its volume), the buyer's sizes over
/// the shape's, the twin following main, a system disk cloned from a template in its tier.
pub fn lay_plan(req: &LayPlanReq) -> Result<EstatePlan, String> {
let shape: Shape = serde_json::from_value(req.config.get("shape").cloned().unwrap_or_default())
.map_err(|e| format!("the config's shape: {e}"))?;
let labels: BTreeMap<String, String> = serde_json::from_value(
req.config
.get("labels")
.cloned()
.unwrap_or_else(|| serde_json::json!({})),
)
.map_err(|e| format!("the config's labels: {e}"))?;
let zone = req
.config
.get("zone")
.and_then(|z| z.as_str())
.unwrap_or_default()
.to_string();
let sizes: Sizes = if req.sizes.is_null() {
Sizes::default()
} else {
serde_json::from_value(req.sizes.clone()).map_err(|e| format!("the sizes: {e}"))?
};
let company = req.company.as_str();
let fill = |t: &str| t.replace("{company}", company);
let or = |asked: &str, shape: &str| {
if asked.trim().is_empty() {
shape.to_string()
} else {
asked.trim().to_string()
}
};
let mut front = shape.front.clone();
front.plan = or(&sizes.front_plan, &front.plan);
let mut main = shape.main.clone();
main.plan = or(&sizes.main_plan, &main.plan);
if sizes.main_data_gib > 0 {
main.disks
.iter_mut()
.skip(1)
.for_each(|d| d.gib = sizes.main_data_gib);
}
let mut twin = shape.twin.clone();
if !req.twin_plan.is_empty() {
twin.plan = req.twin_plan.clone();
}
let main_data: u64 = main.disks.iter().skip(1).map(|d| d.gib).sum();
if let Some(d) = twin.disks.get_mut(1) {
d.gib = d.gib.max(main_data);
}
let machine = |role: &str, m: &MachineShape| {
let mut l = labels.clone();
l.insert("role".into(), m.role_label.clone());
MachinePlan {
role: role.into(),
hostname: fill(&m.hostname),
title: fill(&m.title),
plan: m.plan.clone(),
boot_order: m.boot_order.clone(),
labels: l.clone(),
interfaces: m.interfaces.clone(),
medium: String::new(),
medium_object: None,
template: String::new(),
user_data: Default::default(),
disks: m
.disks
.iter()
.map(|d| {
let mut dl = l.clone();
dl.insert("volume".into(), d.volume.clone());
let tier = match req.template_tiers.get(medium_kind(role)) {
Some(t) if d.volume == "system" => t.clone(),
_ => d.tier.clone(),
};
DiskPlan {
title: fill(&d.title),
gib: d.gib,
tier,
labels: dl,
}
})
.collect(),
}
};
Ok(EstatePlan {
reference: reference_of(company),
company: company.into(),
zone: Zone(zone),
machines: vec![
machine("front", &front),
machine("main", &main),
machine("twin", &twin),
],
narrate_from: String::new(),
shape: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ **The facts carry Gunnar's mail words and names** (monetize v2.0 P2 12.1, A28 H5): the
/// ONE box reads them off the shape with no native Gunnar under them; the shape itself is
/// unchanged.
#[test]
fn the_shape_carries_gunnars_mail_words_and_names() {
let s = facts().default_shape;
assert_eq!(s["mail_words"]["serves"], "git is at git.{rp}.");
assert_eq!(
s["mail_words"]["first_step"],
"Your first repository, {account}/welcome, says how to start."
);
assert_eq!(
(
s["mail_words"]["noun"].as_str(),
s["mail_words"]["pay_line"].as_str()
),
(Some(""), Some(""))
);
assert_eq!(s["names"], serde_json::json!(["", "www.", "git."]));
// HOL-REPO-NAME (kit parity, h-front-sans): the forge is ssh, so `git.<site>` is on the
// front's certificate as a redirect-only name (the shop feeds it `web_callback_only`).
assert_eq!(s["redirect_names"], serde_json::json!(["git."]));
let mut bare = s.clone();
bare.as_object_mut()
.unwrap()
.retain(|k, _| k != "mail_words" && k != "names" && k != "redirect_names");
assert_eq!(bare, default_shape(), "the shape itself is the one it was");
}
/// ★ **What ops 40, 54 and 60 answered rides in the facts** (monetize v2.0 P2 T13): Gunnar's
/// main mints tickets, its two test buttons, its guardian.
#[test]
fn the_facts_carry_the_tickets_the_test_buttons_and_the_guardian() {
let f = facts();
assert!(f.tickets && f.guardian);
assert_eq!(
f.test_actions
.iter()
.map(|t| t.id.as_str())
.collect::<Vec<_>>(),
["hosted-10gb", "private-small"]
);
}
/// ★ **A Gunnar box takes no `public_repositories`, and the facts say so** (OX-WIRED
/// 2026-10-28): its refeed takes the mail relay's keys and the public site's switch alone and refuses any other key whole
/// (gunnar-repair `order::kind::REFEED_KEYS`), so the ONE box must feed a Gunnar site's boxes
/// none, at lay or in a refeed. It reads that off these facts (monetize `Facts::public_repositories`).
#[test]
fn the_facts_say_a_gunnar_box_takes_no_public_repositories_as_its_refeed_refuses_them() {
assert!(
!gunnar_repair::order::kind::REFEED_KEYS.contains(&"public_repositories"),
"a Gunnar box's refeed keys: {:?}",
gunnar_repair::order::kind::REFEED_KEYS
);
let f = facts();
assert_eq!(f.public_repositories, Some(false), "the facts say it");
assert!(!f.takes_public_repositories());
}
/// ★ **GRS-PUBLIC (live bug 2026-10-28): a Gunnar main takes the site's public-site switch
/// in a refeed, and the facts say so.** gunnar.rs was laid before its `public_site`
/// declaration and its console was never told (https://gunnar.rs/ asked every reader to log
/// in): the ONE box feeds the declaration again as `console_public_site` (monetize
/// `site refeed --only public_site`) only to a product whose facts say it takes the key, and
/// a Gunnar main stores it as its console's switch (gunnar-repair `order::PUBLIC_SITE_KEY`).
#[test]
fn the_facts_say_a_gunnar_main_takes_the_public_site_switch_as_its_refeed_does() {
assert!(
gunnar_repair::order::kind::REFEED_KEYS
.contains(&gunnar_repair::order::PUBLIC_SITE_KEY),
"{:?}",
gunnar_repair::order::kind::REFEED_KEYS
);
// monetize-embed `fleet::refeed::PUBLIC_SITE`, the word the ONE box sends.
assert_eq!(
gunnar_repair::order::PUBLIC_SITE_KEY,
"console_public_site",
"the ONE box's word and Gunnar's are one"
);
let f = facts();
assert_eq!(f.public_site, Some(true), "the facts say it");
assert!(f.takes_public_site());
}
}