crates/monetize-cloud-wasm/src/lib.rs
//! **The cloud guest host** — a signed `.wasm` behind the same
//! [`monetize_cloud::factory::CloudFactory`] a linked cloud registers through.
//!
//! ```text
//! [clouds.<name>] guest = "<name>.wasm" ──▶ factory(name).build(&CloudBuild)
//! guest_digest / guest_trust_roots ──▶ refuse by name unless the bytes are vouched for
//! grants = ["http", "state"] ──▶ the linker defines exactly those doors
//! http_hosts = ["api.upcloud.com"] ──▶ the only hosts the HTTP door reaches
//! every other key ──▶ the guest's own settings, sent with each call
//! ▼
//! GuestCloud: Box<dyn Cloud>
//! every verb = one plugin_call(op, JSON) under fuel + a deadline
//! ▼
//! the server wraps it in SpendCap — the ceiling stays HERE
//! ```
//!
//! The load order is the product guest host's (`monetize_product_wasm::LoadedGuest::load_kind`):
//! somebody must vouch, the digest, the signature before compilation, the
//! kind's ceilings narrowed by the block, the doors gate, the ABI, the
//! manifest — which must say `kind = "cloud"` and name this block. Then the
//! guest proves its credential ([`monetize_cloud_guest::ops::VERIFY`]) and
//! answers its provider name and zones once ([`monetize_cloud_guest::ops::ABOUT`]).
//!
//! # What the host keeps
//!
//! * **The spend ceiling**, always: `monetize-server`'s registry wraps this in
//! `SpendCap` like any cloud that can buy. A guest cloud is never trusted to
//! say it cannot spend — [`factory`] declares `spends: true`, so a block
//! with no `cap` is refused before the module is even read.
//! * **The state**, durably: a cloud remembers its pool ledger (grants,
//! plan-change intents) between calls, and a restart must not forget one.
//! The doors' state map is loaded from `<data_dir>/<name>-guest-state.json`
//! at build and written back atomically after any call that changed it.
//! * **One call at a time**: the calls into one cloud are serialised, which is
//! what a linked provider's ledger mutex guaranteed.
//!
//! # The journaled growth
//!
//! `grow_set` and `resume_grow` cross (2026-09-26): the guest's journal writes
//! THROUGH the state door (`monetize_cloud_guest::JOURNAL_KEY`) to the host's
//! journal file, fsync'd before the guest's put returns. The pure growth ops
//! (`grow_gate`, `price_grow`, `survey_set`) are forwarded.
//!
//! # Laying a sold estate (2026-10-08)
//!
//! test2's shop answered every step of its ladder with "the upcloud cloud
//! cannot lay an estate": its cloud is this host, and the estate verbs were
//! never forwarded, so the trait's default refusal answered for a guest whose
//! provider lays one. Now `lay_estate` and `change_plan` cross journaled, the
//! way a growth does (the job's own journal file, written through); and
//! `plan_catalog`, `price_estate` and `set_power` cross plainly. A guest built
//! before them still answers by name: the estate verbs refuse as the trait
//! does, and `price_estate` prices row by row as it did.
pub mod dns;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use guest_host::module::{Instantiated, LoadedModule};
use guest_host::wasmtime::Linker;
use monetize_cloud::cloud::{
Boot, Booted, Catalog, Change, Changed, Cloud, CloudManifest, Give, Listed, Listing, Priced,
RoomLeft,
};
use monetize_cloud::factory::{CloudBuild, CloudFactory};
use monetize_cloud::{grow, CloudError, Price, Reclaimed, Zone};
use monetize_cloud_guest::ops;
use monetize_cloud_guest::wire::{About, Call, EnsureReq, WireCloudError};
use monetize_product::GuestSpec;
use monetize_product_wasm::doors::{self, CallState, DoorSpec, Doors};
use monetize_product_wasm::{Block, LoadedGuest};
/// The digest `guest_digest =` pins (blake3-256, hex) — the gate's own.
pub use monetize_product_wasm::digest_hex;
/// The cloud kind's fuel ceiling: 2e10 instructions per call. An `ensure`
/// parses a price list, a label search and a few state polls; a `release_for`
/// walks an account. An operator narrows it with `guest_fuel`.
pub const CLOUD_FUEL_CEILING: u64 = 20_000_000_000;
/// The cloud kind's wall-clock ceiling: 15 minutes. A server create waits for
/// `started` (UpCloud's own poll timeout is 10 minutes by default); a call cut
/// off mid-wait leaves the resource labelled for the retry, never bought twice.
pub const CLOUD_CALL_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// **A journaled growth's wall-clock deadline**: the kind's whole ceiling, whatever the
/// block's everyday `guest_call_timeout_ms` says. A growth detaches, resizes, waits
/// for `online` (37 s per member measured on UpCloud 2026-09-14) and re-attaches
/// every member in ONE call; cut off at an everyday 30 s it would stop mid-member
/// every time (MEASURED loki 2026-09-26, the signed upcloud.wasm against the mock).
/// A call cut off anyway leaves the epoch open and `resume_grow` puts the volumes back.
pub const GROW_CALL_TIMEOUT: Duration = CLOUD_CALL_TIMEOUT;
/// **A keep boot's wall-clock deadline: 60 minutes.** The UpCloud guest waits up to 45 min for
/// the keep medium (`KEEP_BUDGET`) and then puts the box back; the host must not cut it mid-wait,
/// so it gets that wait plus 15 min. It stays under the 2 h the fronts wait on `Upgrade`
/// ([`monetize_cloud::lay::KEEP_BOOT_WITHIN`]), so the host gives up before the front does.
pub const KEEP_BOOT_CALL_TIMEOUT: Duration = Duration::from_secs(60 * 60);
/// **The deadline of one journaled call `op`**: [`KEEP_BOOT_CALL_TIMEOUT`] for a keep boot,
/// [`GROW_CALL_TIMEOUT`] for every other one (a growth, a lay, a plan change, a restore).
pub fn journaled_deadline(op: u32) -> Duration {
if op == ops::KEEP_BOOT {
KEEP_BOOT_CALL_TIMEOUT
} else {
GROW_CALL_TIMEOUT
}
}
/// How long one HTTP request through the door may take — and the longest a
/// guest may wait on the host in one `host_sleep`.
pub const CLOUD_DOOR_TIMEOUT: Duration = Duration::from_secs(60);
/// The largest value one state key may hold for a cloud: 4 MiB. The pool
/// ledger is ONE key growing by a row per sold resource; the product kind's
/// 64 KiB would stop a deployment after a few hundred sales.
pub const CLOUD_STATE_MAX_BYTES: usize = 4 * 1024 * 1024;
/// **The keys of a `[clouds.<name>]` block that are the HOST's**: which module,
/// who vouches for it, which doors, which hosts, which credential the HTTP
/// door presents to which host (`[[clouds.<name>.http_auth]]`, read from
/// `secrets_dir` — the server fills that one in), which bounds. They are never
/// sent to the guest; everything else in the block is the guest's settings.
pub const HOST_KEYS: &[&str] = &[
"guest",
"guest_digest",
"guest_trust_roots",
"grants",
"http_hosts",
"http_auth",
"http_resolve",
"secrets_dir",
"guest_fuel",
"guest_call_timeout_ms",
"guest_max_memory_bytes",
];
/// Does this block name a guest?
pub fn is_guest_block(settings: &serde_json::Value) -> bool {
settings.get("guest").is_some_and(|v| !v.is_null())
}
/// **The block's guest keys, typed** — `None` when it names no guest.
pub fn guest_spec(name: &str, settings: &serde_json::Value) -> Result<Option<GuestSpec>, String> {
if !is_guest_block(settings) {
return Ok(None);
}
let err = |k: &str, e: String| format!("[clouds.{name}] `{k}` does not parse: {e}");
fn get<T: serde::de::DeserializeOwned>(
s: &serde_json::Value,
k: &str,
) -> Result<Option<T>, String> {
match s.get(k) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(v) => serde_json::from_value(v.clone())
.map(Some)
.map_err(|e| e.to_string()),
}
}
let module: PathBuf = get(settings, "guest")
.map_err(|e| err("guest", e))?
.unwrap_or_default();
Ok(Some(GuestSpec {
module,
digest: get(settings, "guest_digest").map_err(|e| err("guest_digest", e))?,
trust_roots: get(settings, "guest_trust_roots")
.map_err(|e| err("guest_trust_roots", e))?
.unwrap_or_default(),
grants: get(settings, "grants")
.map_err(|e| err("grants", e))?
.unwrap_or_default(),
http_hosts: get(settings, "http_hosts")
.map_err(|e| err("http_hosts", e))?
.unwrap_or_default(),
grpc_hosts: Vec::new(),
fuel: get(settings, "guest_fuel").map_err(|e| err("guest_fuel", e))?,
call_timeout_ms: get(settings, "guest_call_timeout_ms")
.map_err(|e| err("guest_call_timeout_ms", e))?,
max_memory_bytes: get(settings, "guest_max_memory_bytes")
.map_err(|e| err("guest_max_memory_bytes", e))?,
// The provider's credential: the host sets the header per host from a
// secret file, and the guest never sees it (the money lane's door).
http_auth: get(settings, "http_auth")
.map_err(|e| err("http_auth", e))?
.unwrap_or_default(),
// A cloud verifies no webhook.
hmac_secrets: Vec::new(),
secrets_dir: get(settings, "secrets_dir").map_err(|e| err("secrets_dir", e))?,
plugin: None,
}))
}
/// The block as the GUEST sees it: every key but [`HOST_KEYS`].
pub fn guest_settings(settings: &serde_json::Value) -> serde_json::Value {
let mut out = serde_json::Map::new();
if let Some(obj) = settings.as_object() {
for (k, v) in obj {
if !HOST_KEYS.contains(&k.as_str()) {
out.insert(k.clone(), v.clone());
}
}
}
serde_json::Value::Object(out)
}
/// **A block's identity as far as a live reload is concerned**: the module's
/// bytes and its signature (by digest) and every key of the block. Two equal
/// fingerprints build the same cloud.
pub fn block_fingerprint(name: &str, settings: &serde_json::Value) -> Result<String, String> {
let mut parts = format!("{name}|{settings}");
if let Some(spec) = guest_spec(name, settings)? {
let bytes = std::fs::read(&spec.module).map_err(|e| {
format!(
"[clouds.{name}] guest {}: could not be read: {e}",
spec.module.display()
)
})?;
parts.push('|');
parts.push_str(&guest_host::digest_hex(&bytes));
let mut sig = spec.module.clone().into_os_string();
sig.push(".p7s");
if let Ok(p7s) = std::fs::read(PathBuf::from(sig)) {
parts.push('|');
parts.push_str(&guest_host::digest_hex(&p7s));
}
for root in &spec.trust_roots {
if let Ok(der) = std::fs::read(root) {
parts.push('|');
parts.push_str(&guest_host::digest_hex(&der));
}
}
}
Ok(guest_host::digest_hex(parts.as_bytes()))
}
/// **The factory for `[clouds.<name>] guest = …`.** `name` is the block's
/// name, leaked once per configured guest block ([`leak_name`]) because a
/// factory's name is `&'static` and a config file is not. `spends: true`,
/// always: whether a guest can buy is not the guest's to say, so the server
/// demands a ceiling for every one of them.
pub fn factory(name: &'static str) -> CloudFactory {
CloudFactory {
name,
config_envs: &[],
spends: true,
build,
}
}
/// A configured name as the `&'static str` a factory name is.
pub fn leak_name(name: &str) -> &'static str {
Box::leak(name.to_string().into_boxed_str())
}
fn build(ctx: &CloudBuild<'_>) -> Result<Box<dyn Cloud>, String> {
Ok(Box::new(GuestCloud::build(
ctx.name,
ctx.data_dir,
ctx.settings.0,
)?))
}
/// **A cloud guest, as the server holds it.**
///
/// Every op BLOCKS the calling thread for as long as the provider takes (a
/// lay: up to [`GROW_CALL_TIMEOUT`]); call it from a thread that may block —
/// `spawn_blocking`, never an async worker ([`doors::register`] says why).
pub struct GuestCloud {
name: &'static str,
module: LoadedModule,
doors: Arc<Doors>,
manifest: monetize_product_guest::Manifest,
abi: u32,
/// The block minus [`HOST_KEYS`], sent with every call.
settings: serde_json::Value,
zones: Vec<Zone>,
ceilings: BTreeMap<String, u64>,
/// The guest's grow switch for this block (op 230), asked once at load: the block's
/// settings decide it, and a changed block is a new guest.
grow_refused: Option<String>,
/// Where the doors' state map is kept between calls and restarts.
state_file: PathBuf,
/// The map as last written, so an unchanged call writes nothing.
saved: Mutex<BTreeMap<String, Vec<u8>>>,
/// The last state write that failed, until one succeeds (see `call_in`).
unwritten: Mutex<Option<String>>,
/// One call at a time into this cloud — its everyday lane (prices,
/// purchases, growths, reads).
serial: Mutex<()>,
/// **A sold estate's own lane** (wiring trace A32, 2026-10-08): a lay, a
/// plan change and a power step wait minutes on the provider (servers
/// created, stopped, started); on the everyday lane they held every room
/// purchase of the shop back for up to 15 minutes. They run here instead,
/// one at a time, beside the everyday lane. They touch none of the state
/// the everyday lane writes (the pool ledger): their journal is written
/// THROUGH to its own file, and the state map is shared key by key.
estate_serial: Mutex<()>,
}
/// **A call's write-through slot, held for exactly that call** (code-smell
/// review 2026-10-10, row 17): installed under the call's own number and
/// taken out when this guard drops — at the end of the call, on an early
/// return, or on a panic — so a slot can never outlive its journaled call.
struct ThroughSlot<'a> {
doors: &'a doors::Doors,
call: u64,
}
impl<'a> ThroughSlot<'a> {
fn hold(doors: &'a doors::Doors, call: u64, through: doors::WriteThrough) -> Self {
doors
.write_through
.lock()
.unwrap_or_else(|p| p.into_inner())
.insert(call, through);
ThroughSlot { doors, call }
}
}
impl Drop for ThroughSlot<'_> {
fn drop(&mut self) {
self.doors
.write_through
.lock()
.unwrap_or_else(|p| p.into_inner())
.remove(&self.call);
}
}
/// Which lane a call takes ([`GuestCloud::estate_serial`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Lane {
Everyday,
Estate,
}
impl std::fmt::Debug for GuestCloud {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GuestCloud")
.field("name", &self.name)
.field("module", &self.module.path())
.field("digest", &self.module.digest_hex())
.field("signer", &self.module.signer())
.field("abi", &self.abi)
.field("grants", &self.manifest.grants)
.finish()
}
}
/// The durable state file: every key, as text when it is text (a pool ledger
/// is JSON and an operator should be able to read it), else as bytes.
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct StateFile {
version: u32,
keys: BTreeMap<String, serde_json::Value>,
}
fn state_path(data_dir: &Path, name: &str) -> PathBuf {
data_dir.join(format!("{name}-guest-state.json"))
}
fn load_state(data_dir: &Path, name: &str) -> Result<BTreeMap<String, Vec<u8>>, String> {
let path = state_path(data_dir, name);
match std::fs::read(&path) {
Ok(bytes) => {
let f: StateFile = serde_json::from_slice(&bytes).map_err(|e| format!("[clouds.{name}] guest state {} is unreadable: {e}; refused rather than started from nothing, which could sell the same resource twice", path.display()))?;
let mut out = BTreeMap::new();
for (k, v) in f.keys {
let b = match v {
serde_json::Value::String(s) => s.into_bytes(),
other => serde_json::from_value::<Vec<u8>>(other).map_err(|e| {
format!(
"[clouds.{name}] guest state {} key {k:?}: {e}",
path.display()
)
})?,
};
out.insert(k, b);
}
Ok(out)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// **Continuity from a linked provider.** The linked UpCloud kept its
// pool ledger at `<data_dir>/<name>-pool.json`; a deployment moving
// the same cloud to a guest must not forget a grant or an intent.
// Only a ledger that is NOT THERE means "start empty": one that cannot be
// read would otherwise start empty and sell again what was sold.
let mut out = BTreeMap::new();
let pool = data_dir.join(format!("{name}-pool.json"));
match std::fs::read(&pool) {
Ok(bytes) => {
out.insert("pool".to_string(), bytes);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("[clouds.{name}] the linked provider's pool ledger {} cannot be read: {e}; refused rather than started with an empty ledger", pool.display())),
}
Ok(out)
}
Err(e) => Err(format!(
"[clouds.{name}] guest state {}: {e}",
path.display()
)),
}
}
fn save_state(path: &Path, state: &BTreeMap<String, Vec<u8>>) -> Result<(), CloudError> {
let f = StateFile {
version: 1,
keys: state
.iter()
.map(|(k, v)| {
let j = match std::str::from_utf8(v) {
Ok(s) => serde_json::Value::String(s.to_string()),
// Bytes that are not UTF-8 are kept as an array of numbers, built
// directly so no failure can turn them into `null` (row 23).
Err(_) => serde_json::Value::Array(
v.iter().map(|b| serde_json::Value::from(*b)).collect(),
),
};
(k.clone(), j)
})
.collect(),
};
let bytes = serde_json::to_vec_pretty(&f)
.map_err(|e| CloudError::Unavailable(format!("guest state encode: {e}")))?;
monetize_cloud::pool::write_atomically(path, &bytes, "cloud guest state")
}
impl GuestCloud {
/// The whole load order, the doors, the state, then `verify` and `about`.
pub fn build(
name: &str,
data_dir: &Path,
settings: &serde_json::Value,
) -> Result<GuestCloud, String> {
let spec = guest_spec(name, settings)?.ok_or_else(|| format!("[clouds.{name}] is built by the cloud guest host but names no `guest = \"<path>.wasm\"`"))?;
let block_err = |what: String| format!("[clouds.{name}] {what}");
let block = Block {
section: "clouds",
id: name,
spec: Some(&spec),
kind: monetize_product_guest::kinds::CLOUD,
};
let loaded = LoadedGuest::load_block_within(block, CLOUD_FUEL_CEILING, CLOUD_CALL_TIMEOUT)?;
if loaded.manifest.identity {
return Err(block_err(format!(
"its guest ({}) declares an identity (the `sign` grant); a cloud signs nothing, and a key minted for it would be a key nobody verifies",
loaded.module.path().display()
)));
}
let call_timeout = loaded.module.limits().call_timeout.min(CLOUD_CALL_TIMEOUT);
let secrets_dir = spec
.secrets_dir
.clone()
.unwrap_or_else(|| data_dir.join("secrets"));
// `http_resolve` (a HOST key): where the door connects for a host name —
// the bench's vm1 reaches its mock cloud (a loopback name to the guest)
// at the mock network's relay address.
let resolve: Vec<String> = match settings.get("http_resolve") {
None | Some(serde_json::Value::Null) => Vec::new(),
Some(v) => serde_json::from_value(v.clone())
.map_err(|e| format!("[clouds.{name}] `http_resolve` does not parse: {e}"))?,
};
monetize_product_wasm::doors::resolve_pairs(&resolve)
.map_err(|e| format!("[clouds.{name}] {e}"))?;
let mut doors = Doors::open_with(DoorSpec {
product: name,
endpoint: "",
bearer: None,
twin: None,
tls_ca: None,
tls_client: None,
grants: &loaded.spec.grants,
http_hosts: &loaded.spec.http_hosts,
grpc_hosts: &[],
call_timeout: CLOUD_DOOR_TIMEOUT.min(call_timeout),
http_auth: &loaded.spec.http_auth,
hmac_secrets: &[],
secrets_dir: Some(&secrets_dir),
http_resolve: &resolve,
})
.map_err(&block_err)?;
doors.state_max_bytes = CLOUD_STATE_MAX_BYTES;
let initial = load_state(data_dir, name)?;
*doors.state.lock().unwrap_or_else(|p| p.into_inner()) = initial.clone();
let mut g = GuestCloud {
name: leak_name(name),
module: loaded.module,
doors: Arc::new(doors),
manifest: loaded.manifest,
abi: loaded.abi,
settings: guest_settings(settings),
zones: Vec::new(),
ceilings: BTreeMap::new(),
grow_refused: None,
state_file: state_path(data_dir, name),
saved: Mutex::new(initial),
unwritten: Mutex::new(None),
serial: Mutex::new(()),
estate_serial: Mutex::new(()),
};
g.call::<(), ()>(ops::VERIFY, &()).map_err(|e| {
block_err(format!(
"its guest could not prove it can reach the provider: {e}"
))
})?;
let about: About = g
.call(ops::ABOUT, &())
.map_err(|e| block_err(e.to_string()))?;
if about.provider != name {
return Err(block_err(format!(
"its guest's provider answers `{}`, not `{name}`: its purchases would be recorded under another cloud's name and ceiling; refused",
about.provider
)));
}
g.zones = about.zones;
g.ceilings = about.ceilings;
g.grow_refused = g
.call::<_, ()>(ops::GROW_GATE, &())
.err()
.map(|e| e.to_string());
Ok(g)
}
pub fn digest_hex(&self) -> &str {
self.module.digest_hex()
}
pub fn signer(&self) -> Option<&str> {
self.module.signer()
}
pub fn abi(&self) -> u32 {
self.abi
}
/// Exactly the doors defined on every linker this guest makes.
pub fn linker_defines(&self) -> &[&'static str] {
&self.doors.granted_imports
}
pub fn state_file(&self) -> &Path {
&self.state_file
}
fn instantiate(&self) -> Result<Instantiated<CallState>, String> {
let mut linker = Linker::new(self.module.engine());
doors::register(&mut linker, &self.doors.granted_imports)?;
let state = CallState::new(Arc::clone(&self.doors), self.module.limits().limiter());
self.module
.instantiate(state, &linker)
.map_err(|e| e.to_string())
}
/// **One op**: serialised, the block's settings with it, under the bounds;
/// the state written back when it moved. A HOST-side failure (a bound, a
/// trap, a door fault, a shape) is `Unavailable` naming the module and the
/// op — nothing is known to have been bought, and the retry's label search
/// finds anything that was. The guest's own answer keeps its kind.
fn call<A: serde::Serialize, T: serde::de::DeserializeOwned>(
&self,
op: u32,
args: &A,
) -> Result<T, CloudError> {
self.call_within(op, args, None)
}
/// [`GuestCloud::call`] with a longer wall-clock deadline for THIS call (never a
/// shorter one): the store's epoch deadline is raised to cover `deadline`.
fn call_within<A: serde::Serialize, T: serde::de::DeserializeOwned>(
&self,
op: u32,
args: &A,
deadline: Option<Duration>,
) -> Result<T, CloudError> {
self.call_in(Lane::Everyday, op, args, deadline)
}
/// [`GuestCloud::call_within`] on a named lane.
fn call_in<A: serde::Serialize, T: serde::de::DeserializeOwned>(
&self,
lane: Lane,
op: u32,
args: &A,
deadline: Option<Duration>,
) -> Result<T, CloudError> {
self.try_in(lane, op, args, deadline)
.map_err(CloudError::from)
}
/// [`GuestCloud::call_in`], a guest that predates `op` answered as [`Fault::Predates`].
fn try_in<A: serde::Serialize, T: serde::de::DeserializeOwned>(
&self,
lane: Lane,
op: u32,
args: &A,
deadline: Option<Duration>,
) -> Result<T, Fault> {
let asked = std::time::Instant::now();
let _one = self.lane(lane);
self.call_held(op, args, deadline, None, asked)
}
/// The one-at-a-time lock of `lane`.
fn lane(&self, lane: Lane) -> std::sync::MutexGuard<'_, ()> {
match lane {
Lane::Everyday => self.serial.lock().unwrap_or_else(|p| p.into_inner()),
Lane::Estate => self.estate_serial.lock().unwrap_or_else(|p| p.into_inner()),
}
}
/// [`GuestCloud::call_in`] with its lane already held (since `asked`), and
/// `through` installed as THIS call's write-through slot for its length.
fn call_held<A: serde::Serialize, T: serde::de::DeserializeOwned>(
&self,
op: u32,
args: &A,
deadline: Option<Duration>,
through: Option<doors::WriteThrough>,
asked: std::time::Instant,
) -> Result<T, Fault> {
let queued = asked.elapsed();
// A slow op is said with where its time went — queued behind another
// op on this guest's one lock (a growth holds it for minutes), or at
// the provider (each of its HTTP calls says its own line). test2
// 2026-09-30: a signup waited 25 s on ops nobody could see.
let _said = SlowOp {
name: self.name,
op,
asked,
queued,
};
let unavailable = |e: String| {
CloudError::Unavailable(format!(
"[clouds.{}] guest {}: op {op} ({}): {e}",
self.name,
self.module.path().display(),
ops::name(op).unwrap_or("?")
))
};
let payload = monetize_cloud_guest::wire::encode(&Call {
name: self.name.to_string(),
settings: self.settings.clone(),
args,
});
let limits = self.module.limits();
let raw = self
.instantiate()
.map_err(unavailable)
.and_then(|mut inst| {
if let Some(d) = deadline.filter(|d| *d > limits.call_timeout) {
let (ticks, period) = limits.epoch_schedule();
let want = (d.as_millis() / period.as_millis().max(1)) as u64;
inst.store.set_epoch_deadline(want.max(ticks));
}
let call = inst.store.data().call;
let _slot = through.map(|w| ThroughSlot::hold(&self.doors, call, w));
monetize_product_wasm::counters::timed(
monetize_product_wasm::counters::kind::CLOUD,
&monetize_product_wasm::counters::guest_label(self.module.path()),
op,
ops::name(op).unwrap_or("?"),
|| self.module.call_op(&mut inst, op, &payload),
)
.map_err(|e| unavailable(e.to_string()))
.map(|raw| (raw, call))
});
// Only THIS call's streams: another lane's call may be running beside it.
let left = match &raw {
Ok((_, call)) => self.doors.sweep_streams_of(*call),
Err(_) => 0,
};
let raw = raw.map(|(r, _)| r);
if left > 0 {
eprintln!("[clouds.{}] guest: {left} stream(s) were still open when the call ended; each was cancelled", self.name);
}
// The state is written back whatever the answer: a ledger row written
// before a failure is exactly the write-ahead intent a retry needs.
// A write that fails NEVER replaces the guest's answer: "server
// created" turned into Unavailable would tell the caller nothing was
// bought while the provider bills it. The failure is said loudly and
// held in `state_unwritten` until a later write succeeds; `saved` is
// left as it was, so the next call writes the state again.
match self.persist() {
Ok(()) => *self.unwritten.lock().unwrap_or_else(|p| p.into_inner()) = None,
Err(e) => {
eprintln!(
"[clouds.{}] ALERT cloud-guest.state-unwritten guest state NOT written after op {} ({}); the guest's answer is returned as it was, the write is retried on the next call: {e}",
self.name,
op,
ops::name(op).unwrap_or("?")
);
*self.unwritten.lock().unwrap_or_else(|p| p.into_inner()) = Some(e.to_string());
}
}
let raw = raw?;
let answer: Result<T, WireCloudError> = monetize_cloud_guest::wire::decode(&raw)
.map_err(|e| unavailable(format!("the guest answered undecodable bytes: {e}")))?;
answer.map_err(|e| {
if Self::reads_as_predating(op, &e) {
Fault::Predates(op)
} else {
Fault::Cloud(e.into())
}
})
}
/// **Why the guest's state is not on disk**, if the last write failed. A
/// call whose state write failed still returns the guest's answer; this is
/// how a caller (or a health check) sees that the file is behind.
pub fn state_unwritten(&self) -> Option<String> {
self.unwritten
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone()
}
fn persist(&self) -> Result<(), CloudError> {
// The written-back lock is taken BEFORE the map is read: with two lanes
// (A32) a persist that read an older map could otherwise write it over
// a newer one written in between — the file would lose a grant.
let mut saved = self.saved.lock().unwrap_or_else(|p| p.into_inner());
let now = self
.doors
.state
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone();
if *saved != now {
save_state(&self.state_file, &now)?;
*saved = now;
}
Ok(())
}
/// **A journaled growth across the ABI.** The guest runs the provider's own
/// `grow_set` / `resume_grow` against the journal as `journal` holds it; each
/// record it journals arrives as a `state_put` of
/// [`monetize_cloud_guest::JOURNAL_KEY`], which the doors hand THROUGH to
/// `journal`'s own file (append + fsync) before the guest's put returns —
/// the write-ahead rule a linked provider keeps, kept across the boundary.
/// After the call `journal` re-reads its file, so the caller sees every step.
/// A journal with no file (nothing to write through to) is refused.
fn journaled_call(
&self,
op: u32,
set: &grow::DataSet,
journal: &grow::Journal,
) -> Result<grow::GrowOutcome, CloudError> {
self.journaled_op(op, journal, |records| monetize_cloud_guest::wire::GrowReq {
set: set.clone(),
records,
})
}
/// **Any journaled op across the ABI** — a growth, a lay, a plan change:
/// the request is built over the journal's records as `journal` holds
/// them, every record the guest journals is written THROUGH to
/// `journal`'s file before the guest's put returns, and the call may run
/// the kind's whole deadline. `journal` re-reads its file afterwards.
fn journaled_op<R: serde::Serialize, T: serde::de::DeserializeOwned>(
&self,
op: u32,
journal: &grow::Journal,
req: impl FnOnce(Vec<grow::GrowRecord>) -> R,
) -> Result<T, CloudError> {
self.journaled_in(Lane::Everyday, op, journal, req)
}
/// [`GuestCloud::journaled_op`] on a named lane.
fn journaled_in<R: serde::Serialize, T: serde::de::DeserializeOwned>(
&self,
lane: Lane,
op: u32,
journal: &grow::Journal,
req: impl FnOnce(Vec<grow::GrowRecord>) -> R,
) -> Result<T, CloudError> {
self.try_journaled_in(lane, op, journal, req)
.map_err(CloudError::from)
}
/// [`GuestCloud::journaled_in`], a guest that predates `op` answered as [`Fault::Predates`].
fn try_journaled_in<R: serde::Serialize, T: serde::de::DeserializeOwned>(
&self,
lane: Lane,
op: u32,
journal: &grow::Journal,
req: impl FnOnce(Vec<grow::GrowRecord>) -> R,
) -> Result<T, Fault> {
if journal.path().as_os_str().is_empty() {
return Err(self.journaled(ops::name(op).unwrap_or("?")).into());
}
// The lane is held across the whole call — the journal's records read,
// the call, the reload — so two journaled calls on one lane never
// interleave; a call on the other lane runs beside it, with its own
// write-through slot.
let asked = std::time::Instant::now();
let _one = self.lane(lane);
let file = Arc::new(grow::Journal::open(journal.path())?);
let name = self.name;
let sink = {
let file = Arc::clone(&file);
move |bytes: &[u8]| -> Result<(), String> {
let rec: grow::GrowRecord = serde_json::from_slice(bytes).map_err(|e| {
format!("[clouds.{name}] a journal record that does not parse: {e}")
})?;
file.append(rec).map_err(|e| e.to_string())
}
};
let through = doors::WriteThrough {
key: monetize_cloud_guest::JOURNAL_KEY.to_string(),
sink: Box::new(sink),
};
let out = self.call_held(
op,
&req(journal.records()),
Some(journaled_deadline(op)),
Some(through),
asked,
);
journal.reload()?;
out
}
/// **Does the guest's answer to `op` say it predates `op`?** Its SDK's
/// typed [`WireCloudError::NotInTable`] for this op — or, from a guest
/// built before that variant, the SDK's own sentence for this op, EXACTLY
/// (stinky upcloud #21, 5.4). A refusal that merely quotes the words is the
/// guest's own.
fn reads_as_predating(op: u32, e: &WireCloudError) -> bool {
match e {
WireCloudError::NotInTable(n) => *n == op,
WireCloudError::Refused(m) => *m == ops::not_in_table(op),
_ => false,
}
}
/// The refusal of an estate verb by a guest that predates it: the trait's
/// own words (so every reader that knows them still does), and which
/// module said it.
fn estate_refusal(&self, verb: &str) -> CloudError {
let base = if verb == "lay_estate" || verb == "plan_catalog" {
monetize_cloud::lay::unsupported(self.name)
} else {
monetize_cloud::lay::unsupported_verb(self.name, verb)
};
let CloudError::Refused(words) = base else {
return base;
};
CloudError::Refused(format!(
"{words} (its guest {} predates `{verb}`; a newer signed guest carries it)",
self.module.digest_hex()
))
}
fn journaled(&self, verb: &str) -> CloudError {
CloudError::Refused(format!(
"[clouds.{}] `{verb}` is not carried across the cloud guest ABI here: a \
growth, a lay or a plan change crosses only with a journal FILE the guest's steps are written through to (this journal has none). \
Nothing was touched",
self.name
))
}
}
/// **What a guest call failed with**: the guest's answer that it predates the
/// op (read by kind, [`GuestCloud::reads_as_predating`]), or any other error.
/// Never leaves this crate: [`CloudError::from`] says a `Predates` in the SDK's
/// own words, as a refusal, where a caller asked with no fallback of its own.
enum Fault {
Predates(u32),
Cloud(CloudError),
}
impl From<CloudError> for Fault {
fn from(e: CloudError) -> Self {
Fault::Cloud(e)
}
}
impl From<Fault> for CloudError {
fn from(f: Fault) -> Self {
match f {
Fault::Predates(op) => CloudError::Refused(ops::not_in_table(op)),
Fault::Cloud(e) => e,
}
}
}
/// An op past this is said when it ends.
pub const SLOW_OP: Duration = Duration::from_secs(1);
struct SlowOp {
name: &'static str,
op: u32,
asked: std::time::Instant,
queued: Duration,
}
impl Drop for SlowOp {
fn drop(&mut self) {
let took = self.asked.elapsed();
if took >= SLOW_OP {
eprintln!(
"[clouds.{}] op {} took {} ms ({} ms of it queued behind another op on this guest)",
self.name,
ops::name(self.op).unwrap_or("?"),
took.as_millis(),
self.queued.as_millis()
);
}
}
}
/// **The nine ops over the guest's op table** (A24 §2.5) — the host-side adapter, ONCE.
/// Every kind keeps the op number, the lane and the deadline it always had, so a signed
/// guest built before keeps serving; a guest that predates an op is read by KIND: priced
/// row by row (`price` of a plan), no address of its own (`catalog.ipv4`), no media to give
/// back, or refused by name (the rest).
impl Cloud for GuestCloud {
/// The grow switch is the guest's own answer for this block's settings (op 230, asked
/// at load), so a manifest is no guest call.
fn manifest(&self) -> CloudManifest {
let settings = self
.settings
.as_object()
.map(|o| o.keys().cloned().collect())
.unwrap_or_default();
CloudManifest {
provider: self.name.to_string(),
zones: self.zones.clone(),
settings,
grow_refused: self.grow_refused.clone(),
}
}
fn catalog(&self, zone: &Zone) -> Result<Catalog, CloudError> {
let plans = match self.try_in(Lane::Everyday, ops::PLAN_CATALOG, zone, None) {
Err(Fault::Predates(_)) => Err(self.estate_refusal("plan_catalog")),
other => other.map_err(CloudError::from),
}?;
// A guest built before PRICE_IPV4 (the signed upcloud.wasm of 2026-10-05 and
// older) prices no address: the room price base counts its stated fallback.
let ipv4 = self.price_ipv4(zone)?;
Ok(Catalog { plans, ipv4 })
}
/// A plan: the provider's own estate price (UpCloud: every disk at its TIER, read from
/// a price list fetched now); a guest that predates the op is priced row by row. A
/// growth and a resource: the guest's own list (ops 231, 210).
fn price(&self, what: &Priced<'_>) -> Result<Price, CloudError> {
match what {
Priced::Plan(plan) => {
match self.try_in::<_, Price>(Lane::Everyday, ops::PRICE_ESTATE, *plan, None) {
Err(Fault::Predates(_)) => monetize_cloud::price_estate_by_rows(
plan,
|r| self.call(ops::PRICE, r),
self.price_ipv4(&plan.zone)?,
),
other => other.map_err(CloudError::from),
}
}
Priced::Grow(set) => self.call(ops::PRICE_GROW, *set),
Priced::Resource(r) => self.call(ops::PRICE, *r),
}
}
/// **Journaled, like a growth**: every disk and server the guest creates is an intent
/// in the job's journal before the provider is asked, and confirmed with its id after
/// — written through to the file. The call may run the kind's whole deadline (8 disks
/// and 3 servers, each waited for); one cut off is `Unavailable`, and the job's retry
/// ADOPTS by label.
fn lay(
&self,
plan: &monetize_cloud::lay::EstatePlan,
journal: &grow::Journal,
) -> Result<monetize_cloud::lay::LaidEstate, CloudError> {
match self.try_journaled_in(Lane::Estate, ops::LAY_ESTATE, journal, |records| {
monetize_cloud_guest::wire::LayReq {
plan: plan.clone(),
records,
}
}) {
Err(Fault::Predates(_)) => Err(self.estate_refusal("lay_estate")),
other => other.map_err(CloudError::from),
}
}
fn change(&self, change: &Change, journal: &grow::Journal) -> Result<Changed, CloudError> {
match change {
Change::Plan {
server,
plan,
delta,
} => match self.try_journaled_in::<_, ()>(
Lane::Estate,
ops::CHANGE_PLAN,
journal,
|records| monetize_cloud_guest::wire::PlanReq {
server: server.clone(),
plan: plan.clone(),
delta: *delta,
records,
},
) {
Err(Fault::Predates(_)) => Err(self.estate_refusal("change_plan")),
other => other.map(|()| Changed::Done).map_err(CloudError::from),
},
// A SOLD estate's growth: the same guest op, on the estate's lane — its detach,
// resize and attach never hold the shop's purchases back.
Change::Grow { set, estate: true } => self
.journaled_in(Lane::Estate, ops::GROW_SET, journal, |records| {
monetize_cloud_guest::wire::GrowReq {
set: set.clone(),
records,
}
})
.map(Changed::Grown),
Change::Grow { set, estate: false } => self
.journaled_call(ops::GROW_SET, set, journal)
.map(Changed::Grown),
Change::Settle { set } => self
.journaled_call(ops::RESUME_GROW, set, journal)
.map(Changed::Grown),
// **Journaled**: each clone ejected and deleted is written through as it goes. A
// guest that predates the op laid no medium clone, so there is nothing to give back.
Change::ForgetMedia { laid } => match self.try_journaled_in::<_, Vec<String>>(
Lane::Estate,
ops::FORGET_MEDIA,
journal,
|records| monetize_cloud_guest::wire::ForgetReq {
laid: laid.clone(),
records,
},
) {
Err(Fault::Predates(_)) => Ok(Changed::Forgotten(Vec::new())),
other => other.map(Changed::Forgotten).map_err(CloudError::from),
},
Change::Ensure {
reference,
resource,
room,
} => {
let op = if room.is_some() {
ops::ENSURE_WITH
} else {
ops::ENSURE
};
self.call(
op,
&EnsureReq {
reference: reference.clone(),
resource: resource.clone(),
room: *room,
},
)
.map(Changed::Ensured)
}
// On the estate lane. A guest that predates the op labels nothing: refused by name (the
// adopt says so and goes on; the inventory looks the machines up by uuid).
Change::LabelAdopted { laid } => match self.try_in::<_, Vec<String>>(
Lane::Estate,
ops::LABEL_ADOPTED,
laid,
Some(CLOUD_CALL_TIMEOUT),
) {
Err(Fault::Predates(_)) => Err(self.estate_refusal("label_adopted")),
other => other.map(Changed::Labelled).map_err(CloudError::from),
},
// The fence: what stands, each given back through the guest's own destroy.
Change::Retire { reference, machine } => {
monetize_cloud::cloud::retire_by_rows(self, reference, machine)
.map(Changed::Retired)
}
}
}
/// On the estate lane. A keep boot and a restore are **journaled** (an upgrade waits
/// on the medium for minutes); a stop waits for `stopped` the kind's whole deadline,
/// never the block's everyday one.
fn boot(&self, boot: &Boot, journal: &grow::Journal) -> Result<Booted, CloudError> {
match boot {
Boot::Keep(keep) => match self.try_journaled_in::<_, monetize_cloud::lay::LaidMachine>(
Lane::Estate,
ops::KEEP_BOOT,
journal,
|records| monetize_cloud_guest::wire::KeepBootReq {
keep: (**keep).clone(),
records,
},
) {
Err(Fault::Predates(_)) => Err(self.estate_refusal("keep_boot")),
other => other.map(Booted::Machine).map_err(CloudError::from),
},
Boot::Power { server, on } => match self.try_in::<_, ()>(
Lane::Estate,
ops::SET_POWER,
&monetize_cloud_guest::wire::PowerReq {
server: server.clone(),
on: *on,
},
Some(CLOUD_CALL_TIMEOUT),
) {
Err(Fault::Predates(_)) => Err(self.estate_refusal("set_power")),
other => other.map(|()| Booted::Powered).map_err(CloudError::from),
},
Boot::Restore(restore) => match self
.try_journaled_in::<_, Option<monetize_cloud::lay::Restored>>(
Lane::Estate,
ops::RESTORE_FROM_BACKUP,
journal,
|records| monetize_cloud_guest::wire::RestoreFromBackupReq {
restore: (**restore).clone(),
records,
},
) {
Err(Fault::Predates(_)) => Err(self.estate_refusal("restore_from_backup")),
other => other.map(Booted::Restored).map_err(CloudError::from),
},
}
}
/// On the estate lane. A guest that predates the op writes no firewall: refused by name
/// (the caller says so and goes on — the estate keeps the rules it has).
fn firewall(&self, laid: &monetize_cloud::lay::LaidEstate) -> Result<(), CloudError> {
match self.try_in::<_, ()>(
Lane::Estate,
ops::SET_ESTATE_FIREWALL,
laid,
Some(CLOUD_CALL_TIMEOUT),
) {
Err(Fault::Predates(_)) => Err(self.estate_refusal("set_estate_firewall")),
other => other.map_err(CloudError::from),
}
}
/// **One resource: a destroy waits for the provider, the kind's whole deadline, never
/// the block's everyday one** (F4, the ONE box 2026-10-20). UpCloud deletes no STARTED
/// server, so the guest stops it hard, waits for `stopped`, then deletes it with its
/// disks — tens of seconds. Cut off at the everyday 30 s, test3-holger-rs's main was left
/// STOPPED and billed. On the estate's lane, as a stop is, so a slow give-back holds no
/// purchase of the shop back.
fn destroy(&self, what: &Give<'_>) -> Result<Reclaimed, CloudError> {
match what {
Give::References(references) => self.call(ops::RELEASE_FOR, references),
Give::Resource(id) => {
self.call_in::<_, ()>(Lane::Estate, ops::DESTROY, *id, Some(CLOUD_CALL_TIMEOUT))?;
Ok(Reclaimed {
destroyed: vec![(*id).clone()],
..Reclaimed::default()
})
}
}
}
fn inventory(&self, what: &Listing<'_>) -> Result<Listed, CloudError> {
Ok(match what {
Listing::Account => Listed::Account(self.call(ops::INVENTORY, &())?),
Listing::Set(set) => Listed::Set(self.call(ops::SURVEY_SET, *set)?),
Listing::Status(id) => Listed::Status(self.call(ops::STATUS, *id)?),
Listing::Under(references) => Listed::Under(self.call(ops::ORPHANS_FOR, references)?),
// A guest has no admission of its own across the wire; the host's cap answers.
Listing::Admits(_) => Listed::Admitted,
// The guest's own word on whether it can serve (op 216); its ceilings were
// asked once at load (a declaration, not a measurement); a guest cloud buys,
// so it has no byte wall of its own.
Listing::Room { delta, .. } => Listed::Room(RoomLeft {
// An empty delta asks only the figures: no guest call.
serve: if delta.is_empty() {
Ok(())
} else {
self.call::<_, ()>(ops::CAN_SERVE, delta)
.map_err(|e| match e {
CloudError::Refused(s) => s,
other => other.to_string(),
})
},
left_bytes: None,
ceilings: self.ceilings.clone(),
}),
})
}
}
impl GuestCloud {
/// A guest built before PRICE_IPV4 prices no address of its own.
fn price_ipv4(&self, zone: &Zone) -> Result<Option<Price>, CloudError> {
match self.try_in::<_, Option<Price>>(Lane::Everyday, ops::PRICE_IPV4, zone, None) {
Err(Fault::Predates(_)) => Ok(None),
other => other.map_err(CloudError::from),
}
}
}
#[cfg(test)]
mod older_guest_tests {
use super::*;
/// ★ F-U-01 (A17): a keep boot is never cut by the host before the guest's own wait for the
/// keep medium (UpCloud's 45 min) has run out, plus a margin for its put-back; and the host
/// still gives up before the fronts stop waiting on `Upgrade` (`KEEP_BOOT_WITHIN`, 2 h).
/// Every other journaled call keeps the kind's 15 min.
#[test]
fn a_keep_boot_outlasts_the_guests_wait_for_the_keep_medium_and_the_rest_keep_fifteen_minutes()
{
let guest_wait = Duration::from_secs(45 * 60);
let keep = journaled_deadline(ops::KEEP_BOOT);
assert!(
keep >= guest_wait + Duration::from_secs(10 * 60),
"keep boot deadline {keep:?} leaves the guest's 45 min no margin"
);
assert!(
keep < monetize_cloud::lay::KEEP_BOOT_WITHIN,
"the host gives up before the front does: {keep:?}"
);
for op in [
ops::GROW_SET,
ops::LAY_ESTATE,
ops::CHANGE_PLAN,
ops::FORGET_MEDIA,
ops::RESTORE_FROM_BACKUP,
] {
assert_eq!(
journaled_deadline(op),
Duration::from_secs(15 * 60),
"op {op}"
);
}
}
/// ★ Row 23 (code-smell review 2026-10-10): state bytes that are not UTF-8
/// are written as an array built directly — never a fallible conversion
/// that could leave `null` — and read back to the same bytes.
#[test]
fn guest_state_that_is_not_utf8_survives_a_save_and_a_load() {
let dir = std::env::temp_dir().join(format!(
"cloud-wasm-state-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&dir).unwrap();
let mut state = BTreeMap::new();
state.insert("bin".to_string(), vec![0xff, 0x00, 0xfe, 0x80]);
state.insert("text".to_string(), b"plain".to_vec());
save_state(&state_path(&dir, "c"), &state).unwrap();
let back = load_state(&dir, "c").unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(back, state);
}
/// ★ Row 17 (code-smell review 2026-10-10): a call's write-through slot
/// goes when its call goes, even when the call panics — a slot left behind
/// would hand a later call's journal writes to a dead sink.
#[test]
fn a_write_through_slot_is_gone_after_a_panic_in_its_call() {
let d = Doors::open(
"cloud",
"",
None,
None,
None,
&["state".to_string()],
&[],
&[],
Duration::from_secs(5),
)
.unwrap();
let hit = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _slot = ThroughSlot::hold(
&d,
42,
doors::WriteThrough {
key: "grow-journal".into(),
sink: Box::new(|_: &[u8]| Ok(())),
},
);
assert!(d.write_through.lock().unwrap().contains_key(&42));
panic!("the call dies");
}));
assert!(hit.is_err());
assert!(
d.write_through
.lock()
.unwrap_or_else(|p| p.into_inner())
.is_empty(),
"the slot went with its call"
);
d.state_put_for(42, "grow-journal", b"x".to_vec()).unwrap();
assert_eq!(
d.state_get("grow-journal").unwrap().1,
b"x",
"an ordinary state write again"
);
}
/// ★ A29 (wiring trace, 2026-10-08): a guest built before ops 240-244
/// answers an estate op with its SDK's "not in this cloud guest's op
/// table"; the host reads exactly that as "predates" — the estate verbs
/// then refuse with the trait's own words, and the price falls back to
/// row-by-row — while any other refusal (the provider's, the guest's own
/// "no `set_power`") stays what it said.
#[test]
fn an_estate_op_an_older_guest_lacks_is_read_as_predating_it_and_nothing_else_is() {
for op in [
ops::PRICE_ESTATE,
ops::PLAN_CATALOG,
ops::LAY_ESTATE,
ops::SET_POWER,
ops::CHANGE_PLAN,
] {
assert!(
GuestCloud::reads_as_predating(
op,
&WireCloudError::Refused(format!("op {op} {}", ops::NOT_IN_TABLE))
),
"op {op}"
);
assert!(
GuestCloud::reads_as_predating(op, &WireCloudError::NotInTable(op)),
"op {op}, typed"
);
}
assert!(
!GuestCloud::reads_as_predating(
ops::SET_POWER,
&WireCloudError::Refused("the toycloud cloud has no `set_power`".into())
),
"the guest's own refusal is its own"
);
assert!(
!GuestCloud::reads_as_predating(
242,
&WireCloudError::Unavailable(format!("op 242 {}", ops::NOT_IN_TABLE))
),
"an outage is never a missing op"
);
}
/// ★ **"Predates" is a TYPED answer, never a sentence a guest happens to
/// say** (stinky upcloud #21, 5.4): a guest's own refusal that quotes the
/// op-table words is its refusal; only the SDK's typed `NotInTable` — or an
/// OLD guest's exact SDK sentence for the op asked — is "predates".
#[test]
fn predates_is_the_typed_answer_or_an_old_guests_exact_sentence_and_nothing_that_quotes_it() {
let op = ops::SET_POWER;
let quoting = WireCloudError::Refused(format!(
"the provider says: op {op} {} — and more",
ops::NOT_IN_TABLE
));
assert!(
!GuestCloud::reads_as_predating(op, "ing),
"a sentence quoting the words is the guest's refusal"
);
assert!(
GuestCloud::reads_as_predating(
op,
&WireCloudError::Refused(format!("op {op} {}", ops::NOT_IN_TABLE))
),
"an old guest's exact SDK sentence"
);
assert!(
!GuestCloud::reads_as_predating(
ops::LAY_ESTATE,
&WireCloudError::Refused(format!("op {op} {}", ops::NOT_IN_TABLE))
),
"another op's sentence"
);
}
}