guests/toy/src/lib.rs
//! **The toy guest**: the six ops of the product contract, every answer checkable. It is a TEST
//! guest (`kinds::TEST`, owner decision 6, 2026-10-26): a products block loads it, and its grants
//! are the test kind's own, never a product's. Its one door is the typed `endpoint` (the block's
//! address, and nothing else: the door takes no name): its refusing mode reads it, and company
//! `where` answers it back. A product guest that ships has no grants; the toy holds the typed door
//! only to prove it. (It held the generic `env` door until that was retired.)
use std::collections::BTreeMap;
use monetize_product_guest::{
kinds, Backing, Enforcement, Guest, Host, Manifest, ProductError, WireMeter,
};
#[derive(Default)]
pub struct ToyGuest;
pub const PRODUCT: &str = "toy";
/// **The refusing mode** (lane L16, 2026-10-18): a block whose `endpoint` is
/// `lifecycle-of:<kind>` makes the toy bring the lifecycle facts of `<kind>` and REFUSE to make
/// a lay plan — as Holger's own guest does (its estates are laid by monetize's native
/// `lifecycle::Holger`). What proves the core falls back to the native lifecycle of that kind.
pub const REFUSING: &str = "lifecycle-of:";
/// The kind the toy stands in for, when its block puts it in the refusing mode.
fn refusing_kind(host: &dyn Host) -> Option<String> {
host.endpoint()
.ok()
.and_then(|e| e.strip_prefix(REFUSING).map(str::to_string))
.filter(|k| !k.trim().is_empty())
}
impl Guest for ToyGuest {
fn manifest(&self) -> Manifest {
// A TEST guest (owner decision 6): a products block loads it, and its grants are the test
// kind's own, never a product's.
Manifest::of_kind(
kinds::TEST,
PRODUCT,
&[monetize_product_guest::grants::ENDPOINT],
)
.with_meters(vec![
WireMeter::new(
"things",
"how many things the tenant holds",
Enforcement::Measured,
),
WireMeter::new("bytes", "bytes the things take", Enforcement::Enforced)
.with_backing(Backing::DiskBytes),
WireMeter::cap_only("cpu_millicores", "bought iron", Enforcement::Provisioned)
.with_backing(Backing::CpuMillicores)
.per_product(),
])
}
/// An estate of ONE machine (`solo`), from its own media (`toy-media`): what proves a
/// product guest brings its own estate lifecycle across the ABI (2026-10-17). Its main mints
/// actor tickets (the host checks them), no test buttons, no guardian.
fn lifecycle_facts(
&self,
host: &dyn Host,
) -> Result<monetize_product_guest::wire::LifecycleFacts, ProductError> {
Ok(monetize_product_guest::wire::LifecycleFacts {
kind: refusing_kind(host).unwrap_or_else(|| PRODUCT.into()),
update_order: vec!["solo".into()],
app_medium: "toy-media".into(),
front_medium: "toy-media".into(),
front_role: "solo".into(),
keep_medium: "toy-keep".into(),
front_keep_medium: "toy-keep".into(),
upgraded_by_shop: Vec::new(),
medium_of: BTreeMap::from([("solo".to_string(), "toy-media".to_string())]),
other_medium: "toy-media".into(),
backup_roles: vec!["solo".into()],
words: monetize_product_guest::wire::WireWords {
name: "toy box".into(),
title: "Toy box".into(),
account_home: "toy.example".into(),
},
default_shape: serde_json::Value::Null,
data_roles: vec![monetize_product_guest::wire::WireDataRole {
role: "solo".into(),
mirrors_primary: false,
set_suffix: "-data".into(),
}],
domain_proof_name: "toy-box-proof".into(),
// One machine, updated from its keep medium: no live slot (B4 proves the wire carries it).
live_slot: false,
tickets: true,
..Default::default()
})
}
/// The plan: `solo-{company}` in the config's zone, its system disk in the template's tier
/// when one is handed in, the config's labels on it. Company `where` answers the typed
/// `endpoint` door back (see the crate's notes).
fn lifecycle_lay_plan(
&self,
host: &dyn Host,
req: &monetize_product_guest::wire::LayPlanReq,
) -> Result<serde_json::Value, ProductError> {
if let Some(kind) = refusing_kind(host) {
return Err(ProductError::Refused(format!("the toy lays nothing for {}: a {kind} estate is laid by monetize's native lifecycle", req.company)));
}
let mut labels = req
.config
.get("labels")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
labels["role"] = serde_json::json!("solo");
// Where this deployment's box is: a guest is built once and signed once, so it cannot be
// baked in. The typed door answers the block's address; there is no name to ask it for
// anything else.
if req.company == "where" {
return Err(ProductError::Refused(format!(
"endpoint={:?}",
host.endpoint().map_err(ProductError::Refused)?
)));
}
let tier = req
.template_tiers
.get("toy-media")
.cloned()
.unwrap_or_else(|| "standard".into());
Ok(serde_json::json!({
"reference": format!("{}-estate", req.company),
"company": req.company,
"zone": req.config.get("zone").cloned().unwrap_or_default(),
"machines": [{
"role": "solo", "hostname": format!("solo-{}", req.company), "title": "", "plan": "STARTER-1xCPU-1GB",
"boot_order": "disk,cdrom", "labels": labels, "interfaces": [], "medium": "",
"disks": [{ "title": "system", "gib": 10, "tier": tier, "labels": {} }]
}]
}))
}
}
monetize_product_guest::export_product!(ToyGuest, grants = [endpoint]);