crates/monetize-product-guest/src/lib.rs
//! **The guest side of a monetize product** — what a plugin author compiles
//! against, and the one contract the open server's guest host
//! (`monetize-product-wasm`) speaks.
//!
//! A product plugin used to be a Rust crate linked into the deployment binary.
//! It is now a **signed `.wasm`** the server loads at start behind the same
//! [`monetize_product::ProductFactory`] a linked crate registers through — the
//! server cannot tell the two apart, and the deployment binary links no
//! product. This crate is the half the guest sees:
//!
//! | piece | what it is |
//! |---|---|
//! | [`Guest`] | the trait a plugin implements — [`monetize_product::Product`] with the host handed in, because a guest has no socket, no clock and no key of its own |
//! | [`Host`] | the doors the host lends a guest: `http`, `grpc_unary`, `endpoint`, `state`, `hmac`, `clock`, `log` (`sign` and `identity` retired in 0.2: the host mints no identity; the generic `env` door retired by owner decision 6). Each behind a **grant** the operator wrote in the guest's block (`grants = […]`), and each kind of guest may hold only its own part of them ([`grants::of_kind`]); an ungranted door is a named refusal, and the import is not even defined on the linker |
//! | [`ops`] | the op table: one number per `Product` method the server actually calls |
//! | [`wire`] | the request and answer shapes, JSON, and the frame host doors carry bytes in |
//! | [`dispatch`] | the op switch — one implementation, used by the wasm export AND by a native test that drives a `Guest` without a runtime |
//! | [`export_product!`] | the `alloc`/`result_len`/`plugin_abi`/`plugin_manifest`/`plugin_call` exports and the host bridge, generated per module, emitting **only** the imports the module's grants unlock |
//!
//! # What the guest is not
//!
//! The guest sells nothing and signs nothing. The quote is computed by monetize
//! core from the guest's meters and the deployment's price list; the bearer and
//! the API key are presented by the host's doors, never read by the guest.
//!
//! # Payloads are JSON
//!
//! Every op takes a JSON request and answers a JSON [`wire::Answer`]. Reads are
//! per tenant per poller tick (five minutes); nothing here is on a request
//! path, and a `serde_json` round trip through a guest is nowhere near the
//! budget. Host doors that carry bytes (a gRPC body, an HTTP body, a signature)
//! use [`wire::frame`]: a JSON head plus a raw body, so a protobuf is never
//! spelled as a JSON array.
/// The estate guardian's types (`EstateView`, `Finding`, `Remedy`, `Step`, `StepState`), as a
/// guest's `guardian.*` ops speak them (lane W3).
pub use monetize_product::guardian;
/// The product types every guest kind compiles against (the vendor, cloud, signup and DNS
/// guests take them from here too); the product contract itself speaks only a few of them.
pub use monetize_product::{
bytes, Absorb, Activity, ActivityDay, ActorVerdict, Backing, Enforcement, EntitlementFact, Growth,
MeteredDoor, ProductError, Scope, Servable, State, TenantId, TenantUsage, Usage, UsagePage,
TenantOwner, UsagePageReq, UsersAnswer, UsersAsk, TestAction, TestActionKind,
};
/// **The core's order kinds**, the one list (A28 M25: `monetize_product::pull`), for a
/// guest's [`facts::Facts::order_kinds`].
pub use monetize_product::pull::{ask as ask_kind, is_core, order as order_kind};
/// The ABI this crate speaks. A guest exports `plugin_abi() -> u64` packed
/// `min << 32 | max`; the host loads a module whose range overlaps its own and
/// refuses the rest by name.
pub mod abi {
pub const ABI_MIN: u32 = 1;
pub const ABI_MAX: u32 = 1;
/// `min << 32 | max`.
pub const fn pack(min: u32, max: u32) -> u64 {
((min as u64) << 32) | (max as u64)
}
/// `(min, max)`.
pub const fn unpack(packed: u64) -> (u32, u32) {
((packed >> 32) as u32, (packed & 0xffff_ffff) as u32)
}
/// The highest ABI both sides speak, or why there is none.
pub fn negotiate(host: (u32, u32), guest: (u32, u32)) -> Result<u32, String> {
let lo = host.0.max(guest.0);
let hi = host.1.min(guest.1);
if lo > hi {
return Err(format!(
"the guest speaks ABI {}..={} and this host {}..={}; they share nothing. Rebuild \
the guest against monetize-product-guest {}, or upgrade the server.",
guest.0, guest.1, host.0, host.1, host.1
));
}
Ok(hi)
}
}
/// **The op table: the six ops of the product guest's contract** (monetize v2.0 P2, T13;
/// A24 §2.2). Numbers are the wire; names are for logs and refusals.
///
/// | op | name | what |
/// |---|---|---|
/// | 1 | `manifest` | the id, the ABI, the grants (none for a product) and the meter table |
/// | 90 | `lifecycle.facts` | what an estate of the product IS, incl. `tickets`, `test_actions`, `guardian` |
/// | 91 | `lifecycle.lay_plan` | the plan of one order |
/// | 100-102 | `guardian.diagnose`, `.plan`, `.observe` | the estate guardian, pure over the reports |
///
/// Every op is pure over its request: a product guest opens no door. What the push ops (10-14,
/// 20, 30-32, 50-53, 61-64) used to ask a product's box, the ONE box reads from the estate's
/// reports or queues as an order; the actor ticket (41) is checked on the host; ops 2, 40, 54
/// and 60 ride in op 1's manifest and op 90's facts. Those numbers are [`RETIRED`]: frozen,
/// never reused, and the SDK's dispatch answers them [`UNKNOWN_OP_PHRASE`].
pub mod ops {
/// **Op 1, `manifest`**: the product id, the ABI, the grants and the meter table.
pub const MANIFEST: u32 = 1;
/// Op 1 by the name every guest kind's table uses for it (a vendor, cloud, signup or DNS
/// guest answers its id there).
pub const ID: u32 = MANIFEST;
/// **The product's ESTATE LIFECYCLE** (owner 2026-10-17): what a sold estate of this
/// product is — its update plan, its media per role, what a terminate keeps, whether its
/// main mints actor tickets, its test buttons, whether it ships a guardian — as facts
/// ([`wire::LifecycleFacts`]). A guest with no lifecycle answers no-lane; the server then
/// sells no estate of its kind.
pub const LIFECYCLE_FACTS: u32 = 90;
/// The machines an estate is laid as ([`wire::LayPlanReq`] → the plan, as JSON).
pub const LIFECYCLE_LAY_PLAN: u32 = 91;
/// **The product's ESTATE GUARDIAN** (lane W3, 2026-10-19): a box that links no product
/// (the ONE box) repairs the guest product's estates. Pure over the estate as its boxes
/// reported ([`monetize_product::guardian::EstateView`]). Declared by the facts' `guardian`.
pub const GUARDIAN_DIAGNOSE: u32 = 100;
/// [`wire::GuardianPlanReq`] → the steps, or why the remedy may not run now.
pub const GUARDIAN_PLAN: u32 = 101;
/// [`wire::GuardianObserveReq`] → the `Await`'s state.
pub const GUARDIAN_OBSERVE: u32 = 102;
pub const ALL: &[(u32, &str)] = &[
(MANIFEST, "manifest"),
(LIFECYCLE_FACTS, "lifecycle.facts"),
(LIFECYCLE_LAY_PLAN, "lifecycle.lay_plan"),
(GUARDIAN_DIAGNOSE, "guardian.diagnose"),
(GUARDIAN_PLAN, "guardian.plan"),
(GUARDIAN_OBSERVE, "guardian.observe"),
];
pub fn name(op: u32) -> Option<&'static str> {
ALL.iter().find(|(n, _)| *n == op).map(|(_, s)| *s)
}
/// **The numbers no product guest answers any more**, frozen so none is ever reused: the
/// 17 push ops (10-14, 20, 30-32, 50-53, 61-64: the box answers them from its reports, or
/// queues them as orders), the four merged into op 1's manifest and op 90's facts (2
/// `meters`, 40 `tenant_renewal`, 54 `test_actions`, 60 `lanes`), op 41 `may_act_for` (the
/// host checks the ticket), and the four retired in 5.4 (70, 71, 80, 81). A guest built
/// before answers them harmlessly; a host never asks.
pub const RETIRED: &[(u32, &str)] = &[
(2, "meters"),
(10, "list_tenants"),
(11, "read_usage"),
(12, "read_activity"),
(13, "read_usage_page"),
(14, "tenant_owners"),
(20, "push_entitlement"),
(30, "can_absorb"),
(31, "servable"),
(32, "metered_door"),
(40, "tenant_renewal"),
(41, "may_act_for"),
(50, "purge_tenant"),
(51, "twins"),
(52, "users_fit"),
(53, "add_users"),
(54, "test_actions"),
(60, "lanes"),
(61, "grow.flush"),
(62, "grow.start"),
(63, "grow.status"),
(64, "grow.resume"),
(70, "restore.start"),
(71, "restore.status"),
(80, "twin.health"),
(81, "twin.adopt"),
];
/// **The words every guest ever built answers an op it does not have
/// with** (`dispatch`'s last arm, unchanged since the ABI's first version).
/// A host asks an op a guest may predate and reads THIS as "the guest is
/// older", never as a failure — the guests already deployed are the
/// contract, so the phrase is frozen.
pub const UNKNOWN_OP_PHRASE: &str = "is not in this guest's op table";
/// Is this refusal a guest saying it predates the op?
pub fn is_unknown_op(message: &str) -> bool {
message.contains(UNKNOWN_OP_PHRASE)
}
}
/// **The grants** — what an operator writes in `[products.<id>] grants = […]`
/// and what each unlocks. `clock` and `log` need no grant.
pub mod grants {
pub const HTTP: &str = "http";
pub const GRPC: &str = "grpc";
/// **The block's own address, typed** (owner decision 6, 2026-10-26: it replaces the generic
/// `env` door's one remaining read). `host_endpoint` takes no name, so it can answer this
/// one value and nothing else: no environment variable, no key, no secret crosses the ABI.
/// Only the test kind holds it (the toy, standing in for a product told where it is).
pub const ENDPOINT: &str = "endpoint";
/// **The host's memory for this product.** A guest is a fresh instance per
/// call and can remember nothing; a nonce it has already honoured has to live somewhere that outlives the call.
/// It lives on the HOST — which is also what stops a guest forging its own
/// history.
pub const STATE: &str = "state";
/// **Verify a message against a secret the guest never sees** — a vendor's
/// webhook signature. The block names which secrets (`hmac_secrets`).
pub const HMAC: &str = "hmac";
/// **The host's whole door vocabulary**: every grant some kind of guest may hold. No guest is
/// granted from it directly; each kind is granted only its own part ([`of_kind`]).
pub const VOCABULARY: &[&str] = &[HTTP, GRPC, ENDPOINT, STATE, HMAC];
// ── The vocabulary, split by guest kind (owner decision 6, 2026-10-26) ──
// A block that grants, or a manifest that asks for, a door outside its kind's set is
// refused at load, by name (`[guest-grant-outside-kind]`).
/// **A product guest (Gunnar, Holger, the Lake) opens no door.** The six-op contract
/// dials nothing: the ONE box pulls every product from its estate and checks actor tickets
/// on the host (A24: product grants 6 → 0).
pub const PRODUCT: &[&str] = &[];
/// A payment vendor (`[vendors.<kind>.<instance>]`): the provider's API, its webhook
/// signature, and the nonces and sessions it has seen.
pub const VENDOR: &[&str] = &[HTTP, HMAC, STATE];
/// A cloud (`[clouds.<name>]`): the provider's API and the pool ledger.
pub const CLOUD: &[&str] = &[HTTP, STATE];
/// A DNS writer (`[dns_writers.<name>]`): the zone's API.
pub const DNS: &[&str] = &[HTTP];
/// A signup (`[signups.<product>]`): Holger's base plan is created over gRPC (T13 M3: the
/// gRPC door stays for this kind alone), and the nonces it has honoured.
pub const SIGNUP: &[&str] = &[GRPC, STATE];
/// **A test guest** (the toy product guest): loaded only by the host's own tests, under a
/// `[products.<id>]` block. Its set is the typed door it proves; no shipping kind reads its
/// block's address from inside the guest.
pub const TEST: &[&str] = &[ENDPOINT];
/// **The grants a guest of `kind` may hold**; `None` for a kind the host does not know.
pub fn of_kind(kind: &str) -> Option<&'static [&'static str]> {
Some(match kind {
super::kinds::PRODUCT => PRODUCT,
super::kinds::VENDOR => VENDOR,
super::kinds::CLOUD => CLOUD,
super::kinds::DNS => DNS,
super::kinds::SIGNUP => SIGNUP,
super::kinds::TEST => TEST,
_ => return None,
})
}
/// **Grant names that are no longer doors**, frozen so a block that still writes one is
/// told why: `sign` (`host_sign`, `host_identity`, `host_credential`) went in monetize v2.0
/// P2 T13 (A28 M3) — the host mints no identity, for any kind of guest; `env` (`host_env`)
/// went by owner decision 6 (2026-10-26) — no generic door, each read typed or gone.
pub const RETIRED: &[&str] = &["sign", "env"];
/// **The grant a RETIRED import belonged to**, so a module still importing one is told
/// which door went, not that the name means nothing.
pub fn retired_import(import: &str) -> Option<&'static str> {
match import {
"host_sign" | "host_identity" | "host_credential" => Some("sign"),
"host_env" => Some("env"),
_ => None,
}
}
/// Why a [`RETIRED`] grant went, for the host's refusal.
pub fn retired_why(grant: &str) -> &'static str {
match grant {
"sign" => "gone in monetize v2.0 T13: the host mints no identity, for any kind of guest",
"env" => "retired by owner decision 6 (2026-10-26): no generic door; a guest's block address is the typed `endpoint` door, and a credential never crosses the ABI (the host's http_auth presents it)",
_ => "retired",
}
}
/// The `env` imports this grant unlocks.
pub fn imports(grant: &str) -> &'static [&'static str] {
match grant {
HTTP => &[super::imports::HTTP],
GRPC => &[
super::imports::GRPC_UNARY,
super::imports::GRPC_STREAM_OPEN,
super::imports::GRPC_STREAM_SEND,
super::imports::GRPC_STREAM_CLOSE,
super::imports::GRPC_STREAM_CANCEL,
super::imports::GRPC_SERVER_STREAM,
super::imports::GRPC_STREAM_RECV,
],
ENDPOINT => &[super::imports::ENDPOINT],
STATE => &[super::imports::STATE_GET, super::imports::STATE_PUT],
HMAC => &[super::imports::HMAC_VERIFY],
_ => &[],
}
}
/// The grant an import needs; `Some("")` for the two that need none;
/// `None` for a name outside the vocabulary.
pub fn for_import(import: &str) -> Option<&'static str> {
match import {
super::imports::HTTP => Some(HTTP),
super::imports::GRPC_UNARY
| super::imports::GRPC_STREAM_OPEN
| super::imports::GRPC_STREAM_SEND
| super::imports::GRPC_STREAM_CLOSE
| super::imports::GRPC_STREAM_CANCEL
| super::imports::GRPC_SERVER_STREAM
| super::imports::GRPC_STREAM_RECV => Some(GRPC),
super::imports::ENDPOINT => Some(ENDPOINT),
super::imports::STATE_GET | super::imports::STATE_PUT => Some(STATE),
super::imports::HMAC_VERIFY => Some(HMAC),
super::imports::CLOCK | super::imports::LOG | super::imports::SLEEP => Some(""),
_ => None,
}
}
}
/// **The host doors**, as `env` imports. Every door but the last two takes a
/// [`wire::frame`] `(ptr, len)` and answers a packed `ptr << 32 | len` to a
/// frame the host wrote through the guest's own `alloc`; **`0` means the host
/// call itself failed** and the reason is in the host's faults — never
/// "absent".
pub mod imports {
pub const MODULE: &str = "env";
/// `host_http(ptr, len) -> u64` — frame: [`wire::HttpHead`] + body → [`wire::HostHead`] + body.
pub const HTTP: &str = "host_http";
/// `host_grpc_unary(ptr, len) -> u64` — frame: [`wire::GrpcHead`] + request → [`wire::HostHead`] + response.
pub const GRPC_UNARY: &str = "host_grpc_unary";
/// `host_grpc_stream_open(ptr, len) -> u64` — frame: [`wire::StreamOpen`] +
/// nothing → [`wire::HostHead`] with `stream` = the handle. A CLIENT-
/// STREAMING call: the guest pushes chunks and the answer comes at the
/// close. Refused before it opens when the bound it asks for would outlast
/// the guest's call deadline — the [`imports::GRPC_UNARY`] rule for a
/// flush, applied to a replay.
pub const GRPC_STREAM_OPEN: &str = "host_grpc_stream_open";
/// `host_grpc_stream_send(ptr, len) -> u64` — frame: [`wire::StreamRef`] +
/// the chunk's bytes → [`wire::HostHead`]. One prost-encoded message per
/// call, exactly as a unary request is encoded.
pub const GRPC_STREAM_SEND: &str = "host_grpc_stream_send";
/// `host_grpc_stream_close(ptr, len) -> u64` — frame: [`wire::StreamRef`] +
/// nothing → [`wire::HostHead`] + the unary RESPONSE bytes.
pub const GRPC_STREAM_CLOSE: &str = "host_grpc_stream_close";
/// `host_grpc_server_stream(ptr, len) -> u64` — frame: [`wire::StreamOpen`] +
/// the ONE request message → [`wire::HostHead`] with `stream`. The other
/// direction: one request, many answers, read with
/// [`imports::GRPC_STREAM_RECV`].
pub const GRPC_SERVER_STREAM: &str = "host_grpc_server_stream";
/// `host_grpc_stream_recv(ptr, len) -> u64` — frame: [`wire::StreamRef`] +
/// nothing → [`wire::HostHead`] + the next message, with `end` set when
/// the peer has finished. **`end` is the only honest way a stream stops**;
/// a reader that treated an error as the end would truncate silently.
pub const GRPC_STREAM_RECV: &str = "host_grpc_stream_recv";
/// `host_grpc_stream_cancel(ptr, len) -> u64` — frame: [`wire::StreamRef`] +
/// nothing → [`wire::HostHead`]. The peer sees a cancel; nothing is
/// reported as delivered.
pub const GRPC_STREAM_CANCEL: &str = "host_grpc_stream_cancel";
/// `host_endpoint(ptr, len) -> u64` — frame: empty head + nothing → [`wire::HostHead`] with
/// `value` = the block's `endpoint`, verbatim. It takes no name: the one value it answers is
/// the only one it can.
pub const ENDPOINT: &str = "host_endpoint";
/// `host_state_get(ptr, len) -> u64` — frame: [`wire::StateKey`] + nothing
/// → [`wire::HostHead`] + the bytes, with `end` set when there is no such
/// key. The host's memory, per product, for what a guest cannot keep.
pub const STATE_GET: &str = "host_state_get";
/// `host_state_put(ptr, len) -> u64` — frame: [`wire::StateKey`] + the
/// bytes → [`wire::HostHead`]. Bounded: see the host's ceilings.
pub const STATE_PUT: &str = "host_state_put";
/// `host_hmac_verify(ptr, len) -> u64` — frame: [`wire::HmacHead`] + the
/// signed message → [`wire::HostHead`] with `value` = `match` | `mismatch`.
/// The secret is a file on the host named in `hmac_secrets`; it never
/// crosses the ABI.
pub const HMAC_VERIFY: &str = "host_hmac_verify";
/// `host_clock() -> u64` — Unix milliseconds, directly.
pub const CLOCK: &str = "host_clock";
/// `host_log(ptr, len)` — one UTF-8 line; no answer.
pub const LOG: &str = "host_log";
/// `host_sleep(ms)` — wait on the HOST, no answer. Grant-free like the
/// clock: a guest polling a provider for a disk to come online must not
/// spin a hot loop of requests at it, and has no clock of its own to wait
/// on. The host bounds it by the call's own deadline, so a guest cannot
/// sleep past the point where it would be cut off anyway.
pub const SLEEP: &str = "host_sleep";
pub const ALL: &[&str] = &[
STATE_GET,
STATE_PUT,
HTTP,
GRPC_UNARY,
GRPC_STREAM_OPEN,
GRPC_STREAM_SEND,
GRPC_STREAM_CLOSE,
GRPC_STREAM_CANCEL,
GRPC_SERVER_STREAM,
GRPC_STREAM_RECV,
ENDPOINT,
HMAC_VERIFY,
CLOCK,
LOG,
SLEEP,
];
}
/// **What a guest IS** — the `kind` its manifest declares. A block of one kind
/// refuses a module of another by name: a vendor module loaded as a product
/// would be asked for meters it does not have, and the other way round a
/// product would be asked to take money.
pub mod kinds {
pub const PRODUCT: &str = "product";
pub const VENDOR: &str = "vendor";
/// `[clouds.<name>]` — `monetize-cloud-guest`'s `CloudGuest`, ops 200...
pub const CLOUD: &str = "cloud";
/// `[signups.<product>]` — `monetize-signup-guest`'s `SignupGuest`, ops 300..: the
/// sink that CREATES the paying customer's account.
pub const SIGNUP: &str = "signup";
/// `[dns_writers.<name>]` — `monetize-dns-guest`'s `DnsGuest`, ops 400..: a sold estate's
/// names written at the zone's provider.
pub const DNS: &str = "dns";
/// **A test guest**: a toy that stands in for a product in the host's own tests (the toy
/// product guest). A `[products.<id>]` block loads it ([`loads`]) only under the test-only
/// flag `test-guests`; its grants are its own
/// ([`super::grants::TEST`]), never a product's.
pub const TEST: &str = "test";
pub const ALL: &[&str] = &[PRODUCT, VENDOR, CLOUD, SIGNUP, DNS, TEST];
/// **Does a block of `block` kind load a module whose manifest says `manifest`?** Its own
/// kind only (RA2-12). A products block also loads a test guest (the toy) ONLY under the
/// test-only flag `test-guests`, which the host's own tests set on a dev-dependency and a
/// release build never sets: a production product slot never carries the test kind's
/// `endpoint` grant.
pub fn loads(block: &str, manifest: &str) -> bool {
block == manifest || (cfg!(feature = "test-guests") && block == PRODUCT && manifest == TEST)
}
}
pub mod wire;
/// **The product guest's conformance table** (A28 H8): each op's fate in the 6-op design, the
/// request it is asked with, and the SDK's own default answers, for a host test over a signed guest.
pub mod conformance;
/// **The product's facts** (A24 §2.2, v2.0 P3): [`facts::Facts`], the one answer to op 90 (the
/// lifecycle, the shop contract, the sale proof [`facts::SaleProof`], the order form
/// [`facts::OrderForm`]); `wire::LifecycleFacts` is its old name.
pub mod facts;
pub use wire::{Answer, Manifest, WireError, WireMeter};
/// **What the host lends a guest.** Every method defaults to a named refusal,
/// so a guest that calls a door its grants did not unlock gets an `Err` that
/// says which grant — the same answer the host gives, because the host never
/// defined the import in the first place.
pub trait Host {
fn http(&self, req: &wire::HttpRequest) -> Result<wire::HttpResponse, String> {
let _ = req;
Err(not_granted(grants::HTTP, imports::HTTP))
}
fn grpc_unary(&self, path: &str, request: &[u8]) -> Result<Vec<u8>, wire::GrpcError> {
self.grpc_unary_with(path, request, &[])
}
/// [`Host::grpc_unary`] with metadata on this one call — how a guest
/// presents a credential to a control plane that has no bearer.
fn grpc_unary_with(&self, path: &str, request: &[u8], headers: &[(String, String)]) -> Result<Vec<u8>, wire::GrpcError> {
self.grpc_unary_at(path, request, headers, None)
}
/// [`Host::grpc_unary_with`] against one of the OTHER addresses the block
/// allows. A restore asks the twin for its log head.
fn grpc_unary_at(&self, path: &str, request: &[u8], headers: &[(String, String)], endpoint: Option<&str>) -> Result<Vec<u8>, wire::GrpcError> {
let _ = (path, request, headers, endpoint);
Err(wire::GrpcError { code: "Unimplemented".into(), message: not_granted(grants::GRPC, imports::GRPC_UNARY) })
}
/// **Open a client-streaming call.** `expect_secs` is what the guest
/// believes the whole stream needs; the host refuses a stream it could not
/// carry to the end rather than opening one it will cut in half — a
/// truncated replay is a box that looks restored and is not.
///
/// The handle lives for THIS guest call only. A guest is a fresh instance
/// per call, so a handle that outlived one could never be closed by the
/// guest that opened it.
fn grpc_stream_open(&self, path: &str, headers: &[(String, String)], expect_secs: u32, endpoint: Option<&str>) -> Result<u64, wire::GrpcError> {
let _ = (path, headers, expect_secs, endpoint);
Err(wire::GrpcError { code: "Unimplemented".into(), message: not_granted(grants::GRPC, imports::GRPC_STREAM_OPEN) })
}
/// **Open a server-streaming call**: one request, many answers.
fn grpc_server_stream(&self, path: &str, headers: &[(String, String)], request: &[u8], endpoint: Option<&str>) -> Result<u64, wire::GrpcError> {
let _ = (path, headers, request, endpoint);
Err(wire::GrpcError { code: "Unimplemented".into(), message: not_granted(grants::GRPC, imports::GRPC_SERVER_STREAM) })
}
/// The next message, or `None` when the peer has FINISHED. `None` is the
/// only honest end; an error is an error and must not be read as one.
fn grpc_stream_recv(&self, stream: u64) -> Result<Option<Vec<u8>>, wire::GrpcError> {
let _ = stream;
Err(wire::GrpcError { code: "Unimplemented".into(), message: not_granted(grants::GRPC, imports::GRPC_STREAM_RECV) })
}
/// One prost-encoded message onto an open stream.
fn grpc_stream_send(&self, stream: u64, chunk: &[u8]) -> Result<(), wire::GrpcError> {
let _ = (stream, chunk);
Err(wire::GrpcError { code: "Unimplemented".into(), message: not_granted(grants::GRPC, imports::GRPC_STREAM_SEND) })
}
/// Close the stream and read the peer's single answer.
fn grpc_stream_close(&self, stream: u64) -> Result<Vec<u8>, wire::GrpcError> {
let _ = stream;
Err(wire::GrpcError { code: "Unimplemented".into(), message: not_granted(grants::GRPC, imports::GRPC_STREAM_CLOSE) })
}
/// Abandon the stream. The peer sees a cancel, and nothing that was pushed
/// is reported as delivered.
fn grpc_stream_cancel(&self, stream: u64) {
let _ = stream;
}
/// **The block's `endpoint`**, through the typed door (the test kind's). It takes no name,
/// so it answers this one value and nothing else.
fn endpoint(&self) -> Result<String, String> {
Err(not_granted(grants::ENDPOINT, imports::ENDPOINT))
}
/// **What this product remembered.** `None` is a key nobody has written;
/// it is never an error, because "nothing was remembered" is a real answer
/// (a first restore, a nonce never seen).
fn state_get(&self, key: &str) -> Result<Option<Vec<u8>>, String> {
let _ = key;
Err(not_granted(grants::STATE, imports::STATE_GET))
}
/// Remember it. The host bounds how many keys and how large; a guest that
/// exceeds either is refused by name rather than growing the server.
fn state_put(&self, key: &str, value: &[u8]) -> Result<(), String> {
let _ = (key, value);
Err(not_granted(grants::STATE, imports::STATE_PUT))
}
/// **Does any of `candidates_hex` equal HMAC(secret, message)?** The
/// secret is named, never held: the host reads it from its own file and
/// compares in constant time. `sha256` only.
fn hmac_verify(&self, secret: &str, message: &[u8], candidates_hex: &[String]) -> Result<bool, String> {
let _ = (secret, message, candidates_hex);
Err(not_granted(grants::HMAC, imports::HMAC_VERIFY))
}
/// **Is `message` the named secret itself?** For a vendor that signs
/// nothing and authenticates its push with a shared credential. Constant
/// time on the host; the guest learns one bit.
fn secret_equals(&self, secret: &str, message: &[u8]) -> Result<bool, String> {
let _ = (secret, message);
Err(not_granted(grants::HMAC, imports::HMAC_VERIFY))
}
fn clock_unix_ms(&self) -> u64 {
0
}
fn log(&self, line: &str) {
let _ = line;
}
/// Wait `ms` on the host. The default waits for nothing, which is what a
/// native test wants: its fake answers at once.
fn sleep_ms(&self, ms: u64) {
let _ = ms;
}
}
fn not_granted(grant: &str, import: &str) -> String {
format!("`{import}` is not granted to this guest: its block's `grants` do not name `{grant}`")
}
/// **The product, as a guest writes it: the six ops** (A24 §2.2). Every op is pure: a product
/// guest is granted no door (`export_product!(…, grants = [])`). The facts and the plan take
/// the [`Host`] only for the grant-free doors (`clock`, `log`) and a test guest's own.
pub trait Guest {
/// **Op 1**: the product id, the ABI range, the grants (none) and the meter table
/// ([`Manifest::with_meters`]).
fn manifest(&self) -> Manifest;
/// **Op 90, the estate lifecycle's facts** ([`ops::LIFECYCLE_FACTS`]): incl. `tickets`
/// (the product's main mints actor tickets, which the host checks), `test_actions` and
/// `guardian`. Default: no lifecycle.
fn lifecycle_facts(&self, host: &dyn Host) -> Result<wire::LifecycleFacts, ProductError> {
let _ = host;
Err(no_lane(self, "lifecycle"))
}
/// **Op 91, the plan an estate is laid as** ([`ops::LIFECYCLE_LAY_PLAN`]): `monetize_cloud::lay::
/// EstatePlan` as JSON. Default: no lifecycle.
fn lifecycle_lay_plan(&self, host: &dyn Host, req: &wire::LayPlanReq) -> Result<serde_json::Value, ProductError> {
let _ = (host, req);
Err(no_lane(self, "lifecycle"))
}
/// **Op 100, what is wrong with the estate now** ([`ops::GUARDIAN_DIAGNOSE`],
/// `EstateGuardian::diagnose`). Default: no guardian.
fn guardian_diagnose(&self, view: &guardian::EstateView) -> Result<Vec<guardian::Finding>, ProductError> {
let _ = view;
Err(no_lane(self, "guardian"))
}
/// **Op 101, the steps that carry out `remedy`** ([`ops::GUARDIAN_PLAN`], `EstateGuardian::plan`):
/// `Ok(Err(why))` is the product refusing to plan it now. Default: no guardian.
fn guardian_plan(&self, remedy: guardian::Remedy, view: &guardian::EstateView) -> Result<Result<Vec<guardian::Step>, String>, ProductError> {
let _ = (remedy, view);
Err(no_lane(self, "guardian"))
}
/// **Op 102, is the `Await` met?** ([`ops::GUARDIAN_OBSERVE`], `EstateGuardian::observe`).
/// Default: no guardian.
fn guardian_observe(&self, role: &str, what: &str, view: &guardian::EstateView) -> Result<guardian::StepState, ProductError> {
let _ = (role, what, view);
Err(no_lane(self, "guardian"))
}
}
/// An op of a lane the guest does not have. Refused by name, because the host should never
/// have asked: the facts said so.
fn no_lane(guest: &(impl Guest + ?Sized), lane: &str) -> ProductError {
ProductError::Refused(format!("the {} guest declares no `{lane}` lane: its facts say so, and the host never asks it", guest.manifest().product))
}
/// **The op switch** — one implementation, used by the wasm export and by a
/// native test. Answers the JSON bytes of a [`wire::Answer`]; an unknown op or
/// an undecodable request is an `Err` in that envelope, never a trap.
pub fn dispatch(guest: &dyn Guest, host: &dyn Host, op: u32, payload: &[u8]) -> Vec<u8> {
fn req<T: serde::de::DeserializeOwned>(op: u32, payload: &[u8]) -> Result<T, WireError> {
wire::decode(payload).map_err(|e| WireError::Plain(format!("op {op} ({}): undecodable request: {e}", ops::name(op).unwrap_or("?"))))
}
fn ans<T: serde::Serialize>(r: Result<T, WireError>) -> Vec<u8> {
wire::encode(&r)
}
match op {
ops::MANIFEST => ans::<Manifest>(Ok(guest.manifest())),
ops::LIFECYCLE_FACTS => ans(guest.lifecycle_facts(host).map_err(WireError::from)),
ops::LIFECYCLE_LAY_PLAN => ans(req::<wire::LayPlanReq>(op, payload).and_then(|r| guest.lifecycle_lay_plan(host, &r).map_err(WireError::from))),
ops::GUARDIAN_DIAGNOSE => ans(req::<guardian::EstateView>(op, payload).and_then(|v| guest.guardian_diagnose(&v).map_err(WireError::from))),
ops::GUARDIAN_PLAN => ans(req::<wire::GuardianPlanReq>(op, payload).and_then(|r| guest.guardian_plan(r.remedy, &r.view).map_err(WireError::from))),
ops::GUARDIAN_OBSERVE => ans(req::<wire::GuardianObserveReq>(op, payload).and_then(|r| guest.guardian_observe(&r.role, &r.what, &r.view).map_err(WireError::from))),
other => ans::<()>(Err(WireError::Plain(format!("op {other} {}", ops::UNKNOWN_OP_PHRASE)))),
}
}
/// A host that grants nothing: what a native test hands a `Guest` whose
/// answers need no door, and what proves the refusals name the grant.
pub struct NoHost;
impl Host for NoHost {}
pub mod guest_export;
/// **One host answer from a streaming door**, decoded the same way by every
/// one of the four. `#[doc(hidden)]` — it exists so [`export_product!`] does
/// not spell the same eight lines four times, and a guest never calls it.
#[doc(hidden)]
pub fn __stream_answer(raw: Option<Vec<u8>>, door: &str) -> Result<(wire::HostHead, Vec<u8>), wire::GrpcError> {
let fail = |m: String| wire::GrpcError { code: "Unknown".into(), message: m };
let raw = raw.ok_or_else(|| fail(format!("{door} failed on the host side")))?;
let (h, body): (wire::HostHead, &[u8]) = wire::frame::decode_json(&raw).map_err(fail)?;
if h.ok {
return Ok((h, body.to_vec()));
}
Err(h.grpc.clone().unwrap_or_else(|| fail(h.error.clone().unwrap_or_default())))
}
#[cfg(test)]
mod tests {
use super::*;
struct Toy;
impl Guest for Toy {
fn manifest(&self) -> Manifest {
Manifest::new("toy", &[]).with_meters(vec![WireMeter::new("things", "how many", Enforcement::Measured)])
}
fn lifecycle_facts(&self, _: &dyn Host) -> Result<wire::LifecycleFacts, ProductError> {
Err(ProductError::Unavailable("facts off".into()))
}
}
#[test]
fn every_op_round_trips_through_dispatch_and_errors_keep_their_kind() {
let m: Answer<Manifest> = wire::decode(&dispatch(&Toy, &NoHost, ops::MANIFEST, &[])).unwrap();
let m = m.unwrap();
assert_eq!((m.product.as_str(), m.meters[0].name.as_str()), ("toy", "things"));
let facts: Answer<wire::LifecycleFacts> = wire::decode(&dispatch(&Toy, &NoHost, ops::LIFECYCLE_FACTS, &[])).unwrap();
assert!(matches!(ProductError::from(facts.unwrap_err()), ProductError::Unavailable(m) if m == "facts off"));
let unknown: Answer<()> = wire::decode(&dispatch(&Toy, &NoHost, 999, &[])).unwrap();
assert!(unknown.unwrap_err().to_string().contains("999"));
let bad: Answer<serde_json::Value> = wire::decode(&dispatch(&Toy, &NoHost, ops::LIFECYCLE_LAY_PLAN, b"not json")).unwrap();
assert!(bad.unwrap_err().to_string().contains("undecodable"));
let plan: Answer<serde_json::Value> = wire::decode(&dispatch(&Toy, &NoHost, ops::LIFECYCLE_LAY_PLAN, &wire::encode(&wire::LayPlanReq::default()))).unwrap();
assert!(plan.unwrap_err().to_string().contains("declares no `lifecycle` lane"));
}
#[test]
fn an_ungranted_door_names_the_grant() {
let err = NoHost.grpc_unary("/x", &[]).unwrap_err();
assert!(err.message.contains("grpc") && err.message.contains("host_grpc_unary"), "{}", err.message);
assert!(NoHost.endpoint().unwrap_err().contains("`endpoint`") && NoHost.endpoint().unwrap_err().contains("host_endpoint"));
}
/// ★ Owner decision 6 (2026-10-26): **`env` is no door any more**, for any kind. Its one
/// reader left (the toy) reads its block's address through the typed `endpoint` door, which
/// takes no name and so can read nothing else; a guest or a block that still names `env` is
/// told it is retired.
#[test]
fn env_is_retired_and_the_endpoint_door_is_typed() {
assert!(!grants::VOCABULARY.contains(&"env"), "env is not a host door");
assert!(grants::RETIRED.contains(&"env") && grants::retired_why("env").contains("decision 6"));
assert_eq!(grants::for_import("host_env"), None, "no grant unlocks host_env");
assert_eq!(grants::retired_import("host_env"), Some("env"));
assert_eq!(grants::retired_import("host_sign"), Some("sign"));
assert_eq!(grants::retired_import("host_http"), None);
assert!(!imports::ALL.contains(&"host_env"));
assert_eq!(grants::imports(grants::ENDPOINT), &[imports::ENDPOINT]);
assert_eq!(grants::for_import(imports::ENDPOINT), Some(grants::ENDPOINT));
for k in kinds::ALL {
assert!(!grants::of_kind(k).unwrap().contains(&"env"), "{k} has no env door");
}
assert_eq!(grants::of_kind(kinds::TEST), Some(&[grants::ENDPOINT][..]), "the toy proves the typed door");
}
/// ★ Owner decision 6 (2026-10-26): **the vocabulary is split by guest kind.** Each kind's
/// set is part of the host's vocabulary, and a kind outside the table has no set at all.
#[test]
fn each_kind_has_its_own_part_of_the_vocabulary() {
assert_eq!(grants::of_kind(kinds::PRODUCT), Some(&[][..]), "a product guest opens no door");
assert_eq!(grants::of_kind(kinds::VENDOR), Some(&[grants::HTTP, grants::HMAC, grants::STATE][..]));
assert_eq!(grants::of_kind(kinds::CLOUD), Some(&[grants::HTTP, grants::STATE][..]));
assert_eq!(grants::of_kind(kinds::DNS), Some(&[grants::HTTP][..]));
assert_eq!(grants::of_kind(kinds::SIGNUP), Some(&[grants::GRPC, grants::STATE][..]));
assert!(grants::of_kind(kinds::TEST).is_some_and(|s| !s.is_empty()), "the toy proves a door");
assert_eq!(grants::of_kind("nonsense"), None);
for k in kinds::ALL {
for g in grants::of_kind(k).unwrap() {
assert!(grants::VOCABULARY.contains(g), "{k}: `{g}` is not a host door");
}
}
// A products block loads a product guest; a test guest only under the test-only flag.
assert!(kinds::loads(kinds::PRODUCT, kinds::PRODUCT));
assert_eq!(kinds::loads(kinds::PRODUCT, kinds::TEST), cfg!(feature = "test-guests"));
assert!(!kinds::loads(kinds::CLOUD, kinds::TEST) && !kinds::loads(kinds::PRODUCT, kinds::VENDOR) && !kinds::loads(kinds::TEST, kinds::PRODUCT));
}
#[test]
fn every_import_has_a_grant_and_every_grant_has_imports() {
for imp in imports::ALL {
assert!(grants::for_import(imp).is_some(), "{imp} has no grant");
}
for g in grants::VOCABULARY {
assert!(!grants::imports(g).is_empty(), "{g} unlocks nothing");
}
assert_eq!(grants::for_import("host_fs_read"), None);
assert_eq!(abi::negotiate((1, 1), (1, 3)), Ok(1));
assert!(abi::negotiate((1, 1), (2, 3)).is_err());
}
/// ★ RA2-12: **a `[products]` block loads ONLY product guests**, so the test kind's
/// `endpoint` grant never rides in a production product slot. The host's own tests load
/// the toy under the explicit test-only flag `test-guests`, which this crate's own build
/// does not set.
#[cfg(not(feature = "test-guests"))]
#[test]
fn a_products_block_loads_no_test_guest_without_the_test_only_flag() {
assert!(!kinds::loads(kinds::PRODUCT, kinds::TEST), "a products block loads a test-kind guest, which holds `endpoint`");
assert!(kinds::loads(kinds::PRODUCT, kinds::PRODUCT) && kinds::loads(kinds::TEST, kinds::TEST));
}
/// ★ RA2-12: **no build that ships sets `test-guests`**: in monetize's workspace the flag
/// appears only on a DEV-dependency (`[dev-dependencies]`), never on a dependency or a
/// feature a binary can be built with.
#[test]
fn release_builds_never_set_the_test_only_flag() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let mut bad = Vec::new();
let mut seen = 0;
let mut stack = vec![root.join("crates"), root.join("guests"), root.join("Cargo.toml")];
while let Some(p) = stack.pop() {
if p.is_dir() {
if p.file_name().is_some_and(|n| n == "target" || n == ".git") {
continue;
}
stack.extend(std::fs::read_dir(&p).into_iter().flatten().flatten().map(|e| e.path()));
continue;
}
if p.file_name().is_none_or(|n| n != "Cargo.toml") {
continue;
}
seen += 1;
let text = std::fs::read_to_string(&p).unwrap_or_default();
let mut section = String::new();
for (i, line) in text.lines().enumerate() {
let l = line.trim();
if l.starts_with('[') {
section = l.to_owned();
}
if l.starts_with('#') || !l.contains("test-guests") {
continue;
}
let ours = p.ends_with("monetize-product-guest/Cargo.toml") && section == "[features]" && l.starts_with("test-guests");
if !ours && !section.contains("dev-dependencies") {
bad.push(format!("{}:{}: {l} (in {section})", p.display(), i + 1));
}
}
}
assert!(seen > 10, "the walk read the manifests: {seen}");
assert!(bad.is_empty(), "`test-guests` outside a dev-dependency:\n{}", bad.join("\n"));
}
}