Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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"));
    }
}