crates/monetize-server/src/estate_order/guest_lifecycle.rs
//! **A product guest's own estate lifecycle** (owner 2026-10-17): a product whose signed guest
//! answers `lifecycle.facts` (op 90) brings the rules its estates are laid, updated and ended
//! by; the core asks its facts and its plan ([`super::lifecycle::Lifecycle`]).
//!
//! ```text
//! [products.<id>] guest = "<id>.wasm" ──▶ loaded, signed, its manifest gated (as ever)
//! lifecycle.facts → the update plan (live slot or keep-only), each role's medium, the roles a terminate keeps
//! lifecycle.lay_plan → the machines an estate is laid as (the config's shape, labels,
//! zone and each medium's template tier handed in)
//! ──▶ registered for its kind (`gunnar`): the only lifecycle of that kind
//! a guest that brings none (an older one, a product with no estates): none
//! a guest that REFUSES lay_plan: refused by name (no fall-back onto a native lifecycle)
//! ```
//!
//! The registration follows the guest live ([`follow`], on every watcher look and at start): a
//! replaced module's facts are read again, a removed one's lifecycle goes.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use monetize_cloud::lay::EstatePlan;
use monetize_product_guest::facts::Facts;
use monetize_product_guest::wire::LayPlanReq;
use super::lifecycle::{self, Lifecycle};
use super::{EstateOrderConfig, EstateSizes};
/// Pure: **the request a lay sends the guest** — the config's shape, labels and zone, and the
/// tier each of the guest's medium kinds' templates is in (a system disk cloned from a template
/// is laid in the template's tier).
pub fn request(
facts: &Facts,
cfg: &EstateOrderConfig,
company: &str,
sizes: &EstateSizes,
twin_plan: &str,
) -> LayPlanReq {
let mut kinds: Vec<&String> = facts
.medium_of
.values()
.chain(std::iter::once(&facts.other_medium))
.collect();
kinds.sort();
kinds.dedup();
let template_tiers = kinds
.into_iter()
.filter_map(|k| super::template_of(cfg, k).map(|t| (k.clone(), t.tier().to_string())))
.collect();
LayPlanReq {
config: serde_json::json!({ "shape": cfg.shape, "labels": cfg.labels, "zone": cfg.zone }),
company: company.to_string(),
sizes: serde_json::to_value(sizes).unwrap_or_default(),
twin_plan: twin_plan.to_string(),
template_tiers,
}
}
/// **The lifecycle a product guest brings**: its facts (op 90), read once per module, and its
/// plan asked of the guest at each lay (op 91). **A guest that refuses to make one** is refused
/// by name: the product lays its own estates or none. A host-side failure is said as such.
pub fn of_guest(
product: &str,
guest: Arc<monetize_product_wasm::GuestProduct>,
facts: Facts,
) -> Lifecycle {
let id = product.to_string();
let lay: lifecycle::LayPlan = Arc::new(
move |life: &Lifecycle,
cfg: &EstateOrderConfig,
company: &str,
sizes: &EstateSizes,
twin_plan: &str|
-> Result<EstatePlan, String> {
let req = request(&life.facts, cfg, company, sizes, twin_plan);
let plan = match guest.lifecycle_lay_plan(&req) {
Ok(plan) => plan,
Err(monetize_product::ProductError::Refused(why)) => {
return Err(format!(
"[products.{id}] its lifecycle's lay plan: product refused: {why}"
))
}
Err(e) => return Err(format!("[products.{id}] its lifecycle's lay plan: {e}")),
};
let mut plan: EstatePlan = serde_json::from_value(plan).map_err(|e| {
format!("[products.{id}] its lifecycle answered a plan that is not one: {e}")
})?;
// DEV-NARRATE's source is the shop's to say (lane N2), never a product guest's.
plan.narrate_from = cfg.narrate_source().to_string();
Ok(plan)
},
);
Lifecycle::new(product, facts, lay)
}
/// Pure: **the mail words a guest brings** — its kind's native ones (`base`; none: nothing
/// said) with each line the guest names in `given`'s `mail_words` in its place. Lines it does
/// not name keep the native words, so a guest that names none speaks as its kind always did.
pub fn mail_words_over(
base: Option<lifecycle::MailWords>,
given: &serde_json::Value,
) -> lifecycle::MailWords {
let base = base.unwrap_or_default();
match given
.get("mail_words")
.map(|w| serde_json::from_value::<lifecycle::MailWords>(w.clone()))
{
Some(Ok(w)) => base.overlaid(&w),
Some(Err(e)) => {
eprintln!("monetize-server: lifecycle: a guest's mail words are not mail words ({e}); its kind's own are said");
base
}
None => base,
}
}
/// Pure: **the names a guest serves under its host** — the prefixes its `given` facts'
/// `names` key lists (`["", "www."]`), else its kind's native ones (`base`), else Gunnar's three.
/// A guest that names its own needs no native under it (v2.0 P2 12.1, A28 H5/M8).
pub fn names_over(base: Option<Vec<String>>, given: &serde_json::Value) -> Vec<String> {
match given
.get("names")
.map(|n| serde_json::from_value::<Vec<String>>(n.clone()))
{
Some(Ok(v)) if !v.is_empty() => v,
Some(Err(e)) => {
eprintln!("monetize-server: lifecycle: a guest's names are not a list of prefixes ({e}); its kind's own are served");
base.unwrap_or_else(|| vec![String::new(), "www.".into(), "git.".into()])
}
_ => base.unwrap_or_else(|| vec![String::new(), "www.".into(), "git.".into()]),
}
}
/// The rider of a guest's `default_shape` that names **the served prefixes its front routes to
/// its primary's repository** (HOL-REPO-NAME: Holger's `["repo."]`, the front's `repo_hosts`).
pub const RIDER_REPO_NAMES: &str = "repo_names";
/// The rider of a guest's `default_shape` that names **the served prefixes its front serves on
/// :443 as a redirect only** (HOL-REPO-NAME: Gunnar's `["git."]`, whose forge is ssh: on the
/// certificate, every path sent to the site; the front's `web_callback_only`).
pub const RIDER_REDIRECT_NAMES: &str = "redirect_names";
/// Pure: **the prefixes `given`'s `rider` lists**, as the ladder and `names` ride — `None` when
/// the guest names no such rider (a guest built before it), or names one that is not a list of
/// prefixes (said, and read as absent).
pub fn prefixes_named(rider: &str, given: &serde_json::Value) -> Option<Vec<String>> {
match given
.get(rider)
.map(|n| serde_json::from_value::<Vec<String>>(n.clone()))
{
Some(Ok(v)) => Some(
v.into_iter()
.map(|p| p.trim().to_ascii_lowercase())
.collect(),
),
Some(Err(e)) => {
eprintln!("monetize-server: lifecycle: a guest's {rider} is not a list of prefixes ({e}); read as not named");
None
}
None => None,
}
}
/// What [`follow`] registered: kind → (product, the module's digest).
static FOLLOWED: Mutex<BTreeMap<String, (String, String)>> = Mutex::new(BTreeMap::new());
/// **Every product guest's lifecycle, followed**: a guest that brings one is registered for its
/// kind (again when its module changed), one that no longer does — or is gone — is
/// unregistered. Answers the lines to log.
pub fn follow(registry: &crate::registry::Registry) -> Vec<String> {
let mut lines = Vec::new();
let mut followed = FOLLOWED.lock().unwrap_or_else(|e| e.into_inner());
let mut seen = Vec::new();
for id in registry.guest_ids() {
let Some(guest) = registry.guest(&id) else {
continue;
};
let digest = guest.digest_hex().to_string();
if let Some((kind, _)) = followed
.iter()
.find(|(_, (p, d))| *p == id && *d == digest)
.map(|(k, v)| (k.clone(), v.clone()))
{
seen.push(kind);
continue;
}
match guest.lifecycle_facts() {
Ok(Some(facts)) if !facts.kind.trim().is_empty() => {
let kind = facts.kind.clone();
lifecycle::register(&kind, Arc::new(of_guest(&id, guest.clone(), facts)));
lines.push(format!("lifecycle {kind}: taken from the guest of [products.{id}] (blake3 {digest:.12})"));
followed.insert(kind.clone(), (id.clone(), digest));
seen.push(kind);
}
Ok(_) => {}
Err(e) => {
// Refused: what was registered for this guest stands.
seen.extend(
followed
.iter()
.filter(|(_, (p, _))| *p == id)
.map(|(k, _)| k.clone()),
);
lines.push(format!("lifecycle REFUSED [products.{id}] its guest's lifecycle.facts: {e} — what it brought before still stands"));
}
}
}
let gone: Vec<String> = followed
.keys()
.filter(|k| !seen.contains(k))
.cloned()
.collect();
for kind in gone {
let (id, _) = followed.remove(&kind).unwrap_or_default();
lifecycle::unregister(&kind);
lines.push(format!(
"lifecycle {kind}: the guest of [products.{id}] brings it no more"
));
}
lines
}