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

nordisk/monetize

docs/v2/03-write-a-plugin.md

3. Write a plugin

Every trait below is copied from monetize main c9b473a5, with the file:line it stands at. One compiling skeleton per kind lives under examples/ in the repository and is built by cargo xtask build-guests (the wasm kinds) and the workspace build (the native kinds), so they cannot drift from main.

3.0 What every wasm plugin shares

The five wasm kinds (product, vendor, cloud, DNS, signup) are one mechanism (crates/monetize-product-guest, the SDK every kind re-exports):

piece what it is where
the module a cdylib built for wasm32-unknown-unknown, with [lib] crate-type = ["cdylib", "rlib"] (the rlib lets a native test drive the same code) xtask/src/guests.rs:15,161
the ABI ABI_MIN = ABI_MAX = 1; plugin_abi() packs min << 32 | max; the host takes the highest common version or refuses by name crates/monetize-product-guest/src/lib.rs:54-77
the exports alloc, dealloc, result_len, plugin_abi, plugin_manifest, plugin_call(op, ptr, len) -> ptr << 32 | len, generated by the kind's export_*! macro; nothing else may be exported guest_export.rs:224-334
the imports only from module env: host_http, host_grpc_*, host_endpoint, host_state_get/put, host_hmac_verify, host_clock, host_log, host_sleep; a return of 0 means the host call failed lib.rs:302-386
the manifest (op 1) Manifest { abi_min, abi_max, product (the id), grants, identity (frozen false), kind, meters }; built with Manifest::new(id, grants) for a product or Manifest::of_kind(kind, id, grants) for the others wire.rs:17-71
the wire JSON (serde_json) in, a JSON Answer<T> = Result<T, WireError> out; host doors that carry bytes use a frame [u32 LE head_len][JSON head][raw body] wire.rs:7-14, 167, 399-447
the dispatch one dispatch(guest, host, op, payload) per kind, used by the wasm export and by a native test; an unknown op answers "op N is not in this … op table" and never traps lib.rs:655-689
grants the doors a block unlocks: VOCABULARY = http, grpc, endpoint, state, hmac; per kind: product = none, vendor = http hmac state, cloud = http state, DNS = http, signup = grpc state; clock, log, sleep need no grant; sign and env are retired lib.rs:199-240, 296
the signature a detached CMS file <module>.wasm.p7s over the BLAKE3-256 of the bytes, chained to a DER root in the block's guest_trust_roots; verified before the module is compiled; a block with no roots is [guest-unsigned], a digest alone is not enough crates/monetize-product-wasm/src/load.rs:155-195

The Host trait is what a guest calls (lib.rs:435-581). Every method defaults to the refusal "<import> is not granted to this guest: its block's grants do not name <grant>":

fn http(&self, req: &wire::HttpRequest) -> Result<wire::HttpResponse, String>                 // grant http
fn grpc_unary(&self, path: &str, request: &[u8]) -> Result<Vec<u8>, wire::GrpcError>           // grant grpc
fn grpc_unary_with(&self, path: &str, request: &[u8], headers: &[(String, String)]) -> Result<Vec<u8>, wire::GrpcError>
fn grpc_unary_at(&self, path: &str, request: &[u8], headers: &[(String, String)], endpoint: Option<&str>) -> Result<Vec<u8>, wire::GrpcError>
fn grpc_stream_open / grpc_server_stream / grpc_stream_recv / grpc_stream_send / grpc_stream_close / grpc_stream_cancel   // grant grpc
fn endpoint(&self) -> Result<String, String>                                                   // grant endpoint (test guests only)
fn state_get(&self, key: &str) -> Result<Option<Vec<u8>>, String>                              // grant state
fn state_put(&self, key: &str, value: &[u8]) -> Result<(), String>                             // grant state
fn hmac_verify(&self, secret: &str, message: &[u8], candidates_hex: &[String]) -> Result<bool, String>   // grant hmac
fn secret_equals(&self, secret: &str, message: &[u8]) -> Result<bool, String>                  // grant hmac
fn clock_unix_ms(&self) -> u64        // no grant
fn log(&self, line: &str)             // no grant
fn sleep_ms(&self, ms: u64)           // no grant; wired for the cloud kind only

The load order, the same for every kind (crates/monetize-product-wasm/src/load.rs:142-311):

  1. guest_trust_roots must be non-empty, else [guest-unsigned].
  2. If guest_digest is set, it must equal the blake3 of the file.
  3. The .p7s is verified, before compilation.
  4. The limits are set: fuel and the call deadline are the kind's ceiling, narrowed by the block's guest_fuel / guest_call_timeout_ms (never widened); memory is guest_max_memory_bytes or 256 MiB; a zero is refused.
  5. Compile; the export set must be exactly the six; no import outside env; every import must be in the vocabulary and unlocked by a grant (an ungranted import is not even defined on the linker); a retired door is [guest-door-retired].
  6. plugin_abi is negotiated and plugin_manifest read through a linker with no doors. The kind must be the block's ([guest-wrong-kind]); the id must equal the block's id or its plugin =; gate A: every manifest grant is granted; gate B: every real import is declared by the manifest.
  7. The kind's first ops are asked once (a product's facts, a vendor's pay-manifest, a cloud's verify and about, a DNS writer's and a signup guest's check).

The sandbox per kind (every number from the code):

kind fuel per call call deadline ceiling door timeout state value max where
product, vendor 2 000 000 000 60 s (default 30 s) deadline / 2 64 KiB load.rs:44,49; vendor.rs:147; doors.rs:33
cloud 20 000 000 000 15 min; journaled ops 15 min, a keep boot 60 min 60 s 4 MiB crates/monetize-cloud-wasm/src/lib.rs:82-120
DNS 5 000 000 000 120 s 30 s 64 KiB crates/monetize-cloud-wasm/src/dns.rs:66-68
signup 5 000 000 000 120 s 30 s 64 KiB crates/monetize-product-wasm/src/signup.rs:43-47

Other door caps: state keys 4096, HTTP body 16 MiB, gRPC decode 64 MiB, redirects 5, HMAC candidates 8, open streams 2 (doors.rs:25-51). The http door dials only hosts in the block's http_hosts, and adds the credential itself from [[http_auth]] (a secret named, read from <secrets_dir>/secret.<name>): a credential never crosses the ABI.

The block, in monetize.toml (crates/monetize-server/src/config.rs:1018-1041): guest, guest_digest, guest_trust_roots, grants, http_hosts, [[http_auth]], guest_grpc_hosts, guest_fuel, guest_call_timeout_ms, guest_max_memory_bytes, plus the kind's own keys. On a ONE box the kit writes it (chapter 2 §2.4); you never edit it.

Hot replacement: the guest watcher stats the module, its .p7s, the roots and the block every 2 s (crates/monetize-server/src/guests.rs:95); a changed guest goes through the whole load order with no lock held and is swapped in with one pointer swap, in-flight calls finishing on the old instance; a refused replacement keeps the serving one (crates/monetize-product-wasm/src/hot.rs:138-158).

3.1 A product guest (<product>_monetize_guest.wasm)

What it is: the product's facts, in code the ONE box can run without the product linked. It holds no grant and sells nothing: every estate verb (usage, entitlement, grow) is answered from the main's reports or queued as a signed order (crates/monetize-product/src/pulled.rs:141-143); the guest is asked only for its manifest, its facts, a lay plan and the guardian's three ops.

The op table

(crates/monetize-product-guest/src/lib.rs:95-125, ops::ALL.)

op name asked when meaning
1 manifest once at load the id, the ABI range, the grants (none), the meter table
90 lifecycle.facts at load, and again at a lay what an estate of this product IS (facts::Facts): roles, media, update order, tickets, test buttons, guardian; "no lane" means the box sells no estate of the product
91 lifecycle.lay_plan each lay LayPlanReq { config, company, sizes, twin_plan, template_tiers } in, monetize_cloud::lay::EstatePlan as JSON out
100 guardian.diagnose each guardian tick EstateView in, Vec<Finding> out
101 guardian.plan when a remedy is approved GuardianPlanReq { remedy, view } in, the steps out, or why not now
102 guardian.observe while an Await step runs GuardianObserveReq { role, what, view } in, StepState out

Twenty-six older numbers are retired and frozen (ops::RETIRED, lib.rs:137-164).

The trait

(lib.rs:590-641.) manifest is required, the rest default to "no lane":

pub trait Guest {
    fn manifest(&self) -> Manifest;
    fn lifecycle_facts(&self, host: &dyn Host) -> Result<wire::LifecycleFacts, ProductError> { … Err(no_lane(self, "lifecycle")) }
    fn lifecycle_lay_plan(&self, host: &dyn Host, req: &wire::LayPlanReq) -> Result<serde_json::Value, ProductError> { … Err(no_lane(self, "lifecycle")) }
    fn guardian_diagnose(&self, view: &guardian::EstateView) -> Result<Vec<guardian::Finding>, ProductError> { … Err(no_lane(self, "guardian")) }
    fn guardian_plan(&self, remedy: guardian::Remedy, view: &guardian::EstateView) -> Result<Result<Vec<guardian::Step>, String>, ProductError> { … }
    fn guardian_observe(&self, role: &str, what: &str, view: &guardian::EstateView) -> Result<guardian::StepState, ProductError> { … }
}

In guardian_plan, Ok(Err(why)) is the product refusing to plan the remedy now. The refusal text of a default is "the guest declares no <lane> lane: its facts say so, and the host never asks it" (lib.rs:645-650); the host reads it as "no lifecycle" (crates/monetize-product-wasm/src/product.rs:227-236).

The meter table (wire.rs:76-120): WireMeter::new(name, meaning, enforcement) (reported, per tenant), WireMeter::cap_only(…) (not reported), .with_backing(Backing), .per_product(). Enforcement is Enforced (the product refuses when over), Measured (counted, not policed) or Provisioned (iron monetize bought; the provider's invoice is the enforcement); Backing::DiskBytes prices a delta per GiB per month, CpuMillicores per core per month (crates/monetize-product/src/lib.rs:89-160).

The facts (facts.rs:41-206; every field serde-defaulted): kind, update_order (the front last), front_role, the media per role (app_medium, front_medium, keep_medium, front_keep_medium), upgraded_by_shop, primary_role, roles (RoleFacts { role, many, optional, open: Vec<PortFacts> }), twin ({ role, mirrors }), grow (GrowRule { never_shrinks, may_add, may_remove, drain, moves_data }), tickets (the main mints actor tickets the host checks), test_actions, guardian, billing (Console or SitePayPage), welcome, offsite, public_site, reserved_accounts, orders (the product's own order kinds, each with its role, words and params), the words (name, title, account_home) and the default shape.

The two real guests: Gunnar's (gunnar/crates/gunnar-monetize-guest) sells roles front, main and twin, meters pack_bytes, cache_bytes, lfs_bytes (enforced, disk), counts and caps, billing Console, default shape front STARTER-2xCPU-4GB, main STARTER-4xCPU-8GB + 4 × 10 GiB data, twin STARTER-1xCPU-4GB. Holger's (holger/crates/holger-monetize-guest) sells one meter, disk_bytes (enforced, disk), billing SitePayPage, offsite, the lean shape front STARTER-1xCPU-1GB, main STARTER-1xCPU-2GB + 20 GiB, twin STARTER-1xCPU-1GB, and the ladder Mini … Gigantic (main up to 20xCPU-96GB, 2000 GiB). Both call export_product!(<Guest>, grants = []) and stamp their git ref into a custom section their server checks.

The skeleton: examples/product-skeleton (op 1 only, two meters, a native test that drives dispatch). Build: cargo build --release --target wasm32-unknown-unknown -p monetize-example-product → target/wasm32-unknown-unknown/release/monetize_example_product.wasm.

use monetize_product_guest::{Backing, Enforcement, Guest, Manifest, WireMeter};

#[derive(Default)]
pub struct ExampleGuest;

impl Guest for ExampleGuest {
    fn manifest(&self) -> Manifest {
        Manifest::new("example", &[]).with_meters(vec![
            WireMeter::new("disk_bytes", "bytes the tenant's data takes on disk", Enforcement::Enforced)
                .with_backing(Backing::DiskBytes),
        ])
    }
}

monetize_product_guest::export_product!(ExampleGuest, grants = []);

Sign it with a detached CMS .wasm.p7s chained to a root the box's guest_trust_roots names, and feed it as plugin.<id>.wasm + .p7s + .toml + .prices (chapter 2 §2.4). The manifest's product must equal the block id [products.<id>], or the block names it with plugin = "<id>".

3.2 A payment vendor (stripe.wasm)

What it is: the vendor's API behind the ten Payment ops, as a guest. Grants: http (the vendor's API; the key is added by the host from [[http_auth]]), hmac (the webhook signature, verified on the host against a secret in hmac_secrets the guest never reads), state (the host's durable memory, shared across a live replacement).

The host-side trait every vendor is seen as (crates/monetize-pay/src/payment.rs:133-155):

pub trait Payment: Send + Sync {
    fn manifest(&self) -> PayManifest;
    fn page(&self, charge: &Charge<'_>, back: &Return) -> Result<Page, PayError>;
    fn status(&self, of: &Of<'_>) -> Result<Standing, PayError>;
    fn capture(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError>;
    fn release(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError>;
    fn refund(&self, handle: &ChargeHandle, amount: Money) -> Result<ChargeStatus, PayError>;
    fn callback(&self, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError>;
    fn settlement(&self, handle: &ChargeHandle) -> Option<Settlement>;
    fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<PaidAtVendor>, PayError>;
    fn livemode(&self) -> Option<bool>;
}
op meaning (from the trait's docs)
manifest the name, the markets (Se, Fi, Dk), the setting names, holds (a vendor that holds answers Reserved then Captured; one that cannot answers AwaitingCustomer then Captured), the test payer
page ONE hosted page, minted; nothing drawn; idempotent by the charge's reference. Charge is Order, Signup, CardCheck or TestSignup; Return { paid, cancel } are the ways back
status the vendor's view of a charge, page, sign-up or card check; the ledger reconciles against this, never memory
capture draw the held money; idempotent
release unlock without drawing; idempotent
refund after capture only, partial allowed; a Refunded reversal carries the refunded TOTAL and the host refuses a total below the one it recorded
callback verify and decode a webhook: Unverified → 401, Ignored → 200
settlement the vendor's cut, once drawn; None is an answer
paid_since every final payment since a time (reconcile)
livemode Some(true) live money, Some(false) test, None unknown

The guest trait (crates/monetize-pay-guest/src/lib.rs:353-424) is the same ten with the host and the block's settings handed in; manifest (op 1) names the kind; paid_since and livemode are the two with defaults:

pub trait VendorGuest {
    fn manifest(&self) -> Manifest;
    fn pay_manifest(&self, host: &dyn Host, settings: &serde_json::Value) -> Result<PayManifest, String>;
    fn page(&self, host: &dyn Host, settings: &serde_json::Value, charge: &Charge<'_>, back: &Return) -> Result<Page, PayError>;
    fn status(&self, host: &dyn Host, settings: &serde_json::Value, of: &Of<'_>) -> Result<Standing, PayError>;
    fn capture(&self, host: &dyn Host, settings: &serde_json::Value, handle: &ChargeHandle) -> Result<ChargeStatus, PayError>;
    fn release(&self, host: &dyn Host, settings: &serde_json::Value, handle: &ChargeHandle) -> Result<ChargeStatus, PayError>;
    fn refund(&self, host: &dyn Host, settings: &serde_json::Value, handle: &ChargeHandle, amount: Money) -> Result<ChargeStatus, PayError>;
    fn callback(&self, host: &dyn Host, settings: &serde_json::Value, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError>;
    fn settlement(&self, host: &dyn Host, settings: &serde_json::Value, handle: &ChargeHandle) -> Option<Settlement>;
    fn paid_since(&self, host: &dyn Host, settings: &serde_json::Value, since_unix_ms: i64) -> Result<Vec<PaidAtVendor>, PayError> { … Err(cannot_be_asked()) }
    fn livemode(&self, host: &dyn Host, settings: &serde_json::Value) -> Option<bool> { None }
}

Op numbers: 1 id, 120 manifest, 121 page, 122 status, 123 capture, 124 release, 125 refund, 126 callback, 127 settlement, 128 paid_since, 129 livemode (lib.rs:58-113; 100–118 retired). At load: id must equal the kind, op 120 is asked once (missing: [vendor-old-table]; refused: [vendor-settings-refused]; no market: [vendor-no-market]; crates/monetize-product-wasm/src/vendor.rs:168-192).

The webhook POST /callback/<kind>/<instance> reaches the guest's callback (crates/monetize-server/src/callback.rs): body at most 256 KiB, 32 deliveries in flight; Unverified 401, Ignored or no open attempt 200, a handle mismatch 409. The guest verifies with host.hmac_verify("<secret name>", raw.body, &[signature]); the host does HMAC-SHA256 in constant time over at most 8 candidates (doors.rs:1512-1576).

The block: [vendors.<kind>.<instance>] with enabled, guest, guest_trust_roots, grants = ["http", "hmac", "state"], http_hosts = ["api.stripe.com"], hmac_secrets = ["<name>"], [[http_auth]] { host, prefix = "Bearer ", secret }; a block with no instance is refused. A product names its vendor as vendor = "<kind>/<instance>".

The skeleton: examples/vendor-skeleton answers every op without a vendor (page "awaiting the customer", capture "captured now", callback "unverified") and calls export_vendor!(ExampleVendor, grants = [http, hmac, state]).

3.3 A cloud (upcloud.wasm)

What it is: the provider's API behind the nine Cloud verbs, as a guest. Grants: http, state. The spend cap, the token and the journal file stay on the host; the guest journals through the state door before each step it takes (host_journal, crates/monetize-cloud-guest/src/lib.rs:575-590).

The host-side trait (crates/monetize-cloud/src/cloud.rs:236-256), nine verbs, all required, every one idempotent on what it names (a second lay adopts; an account that cannot be read is an error, never an empty answer):

pub trait Cloud: Send + Sync {
    fn manifest(&self) -> CloudManifest;                                                     // name, zones, setting NAMES, grow switch; pure
    fn catalog(&self, zone: &Zone) -> Result<Catalog, CloudError>;                            // the plans sold in the zone, priced, and the address price
    fn price(&self, what: &Priced<'_>) -> Result<Price, CloudError>;                          // what `what` adds a month, from the provider's own list
    fn lay(&self, plan: &EstatePlan, journal: &Journal) -> Result<LaidEstate, CloudError>;    // every disk, then every server with its disks, labelled and journaled
    fn change(&self, change: &Change, journal: &Journal) -> Result<Changed, CloudError>;      // one change; journaled where it buys or rewrites; resumable
    fn boot(&self, boot: &Boot, journal: &Journal) -> Result<Booted, CloudError>;             // a keep boot, a power change or a restore
    fn firewall(&self, laid: &LaidEstate) -> Result<(), CloudError>;                         // every machine's rules written again, whole
    fn destroy(&self, what: &Give<'_>) -> Result<Reclaimed, CloudError>;                     // give `what` back; never capped
    fn inventory(&self, what: &Listing<'_>) -> Result<Listed, CloudError>;                   // a read of what the cloud holds
}

The arms: Priced::{Plan(&EstatePlan), Grow(&DataSet), Resource(&Resource)}; Change::{Plan { server, plan, delta }, Grow { set, estate }, Settle { set }, ForgetMedia { laid }, Ensure { reference, resource, room }, Retire { reference, machine }, LabelAdopted { laid }}; Boot::{Keep(KeepBoot), Power { server, on }, Restore(RestoreFromBackup)}; Give::{References(&[String]), Resource(&ResourceId)}; Listing::{Account, Set(&DataSet), Status(&ResourceId), Under(&[String]), Room { delta, room }, Admits(&EstatePlan)} (cloud.rs:68-230). CloudError is Refused, OverCap, Unavailable, UnknownResource, Exhausted (lib.rs:137-155). The SDK's grow::unsupported(provider), lay::unsupported(provider) and lay::unsupported_verb(provider, verb) are the named refusals for what a provider does not do.

The guest trait (crates/monetize-cloud-guest/src/lib.rs:332-349):

pub trait CloudGuest {
    fn manifest(&self) -> Manifest;                                                           // Manifest::of_kind(kinds::CLOUD, "<name>", &[grants…])
    fn open(&self, host: HostRef, ctx: &CallContext) -> Result<Box<dyn Cloud>, CloudError>;    // the provider for ONE call; no network call here
    fn verify(&self, host: HostRef, ctx: &CallContext) -> Result<(), CloudError> { self.open(host, ctx).map(|_| ()) }   // the start-path credential proof, once at load
}

CallContext { name, settings } carries the block minus the host's keys on every call. The host's ops (200 about, 201 verify, 210–220 the everyday verbs, 230–234 grow, 240–249 the estate verbs; lib.rs:68-186) each forward to one Cloud verb; an op the module does not know answers NotInTable, which the host reads as "predates the op". Calls run on two lanes (everyday, and estate for lay, plan change, power, firewall, label, destroy); the state file <data_dir>/<name>-guest-state.json is written back atomically after each call (crates/monetize-cloud-wasm/src/lib.rs:270-303, 668-690).

The spend cap wraps every cloud that spends (a guest always does) and is enforced on the host (crates/monetize-cloud/src/cap/mod.rs), in full:

committed_month = (stored month == this YYYY-MM ? month_minor : 0) + Σ reserved
committed_total = total_minor (never reset) + Σ reserved
asking          = the price of what is bought, minor per month, in the ceiling's currency
                  (another currency is REFUSED, never converted)
REFUSE OverCap  if monthly_minor is set and committed_month + asking > monthly_minor
REFUSE OverCap  if total_minor   is set and committed_total + asking > total_minor
admission and reservation happen under ONE hold of the lock; a lay reserves the estate's whole
monthly price and settles it once laid; destroy is never capped and a whole give-back credits
the month; the total is recomputed upward from the ledger at boot

The block: [clouds.<name>] with enabled, cap = { monthly_minor | total_minor, currency } (required; 0 refuses every purchase), guest, guest_trust_roots, grants = ["http", "state"], http_hosts = ["api.upcloud.com"], [[http_auth]], and the provider's own settings (zone, …). about.provider must equal <name>.

The skeleton: examples/cloud-skeleton: one plan in one zone, buys nothing, every other verb refused by name; export_cloud!(ExampleCloud, grants = [http, state]).

3.4 A DNS writer (dns_cloudflare.wasm)

What it is: the zone's API behind the records a lay writes (type A, proxied: false: <company>.<apex> and the product's served prefixes, by default "", www., git.; crates/monetize-server/src/estate_order.rs:656-708). The only grant is http; the token is presented by the host from [[http_auth]] (secret = "dns_cloudflare_token"), so the guest sets no Authorization.

The trait (crates/monetize-dns-guest/src/lib.rs:87-146); manifest, write, remove, listed required:

pub trait DnsGuest {
    fn manifest(&self) -> Manifest;
    fn check(&self, host: &dyn Host, settings: &serde_json::Value) -> Result<(), String> { Ok(()) }
    fn write(&self, host: &dyn Host, settings: &serde_json::Value, records: &[DnsRecord]) -> Result<String, String>;
    fn write_taking_over(&self, host: &dyn Host, settings: &serde_json::Value, records: &[DnsRecord], take_over: &[String]) -> Result<Written, String> { … self.write(…) with nothing replaced }
    fn remove(&self, host: &dyn Host, settings: &serde_json::Value, names: &[String]) -> Result<String, String>;
    fn listed(&self, host: &dyn Host, settings: &serde_json::Value) -> Result<Vec<DnsRecord>, String>;
    fn settled(&self, host: &dyn Host, settings: &serde_json::Value, records: &[DnsRecord]) -> Result<bool, String> { Ok(true) }
    fn zone(&self, host: &dyn Host, settings: &serde_json::Value) -> Result<Option<String>, String> { Ok(None) }
}
op name meaning
1 id the writer's name
400 check at load: the settings are usable
410 write write the records; the line goes into the order's history. A SOLD estate's lay uses this, and the writer refuses any record it did not make
411 remove remove every record of these names (the give-back)
412 listed what the zone holds; checked before and after a removal when the writer lists
413 settled are the records answering yet? the installer starts once they are
414 write_taking_over the operator's OWN site only: A/AAAA/CNAME records of exactly the named names that the writer did not make are replaced (ALERT estate-orders.dns-takeover); MX, TXT, CAA, NS and every other name are untouched
415 zone the apex. Override it: with None the host refuses every write, takeover and removal with [dns-zone-none]; a name outside the zone is [dns-outside-zone]

DnsRecord { name, kind, value, proxied }, Written { line, replaced } (crates/monetize-cloud/src/dns.rs:27-53). The block is [dns_writers.<name>] (or <name>-<zone>, one per product zone), chosen by [estate_orders] dns = "<name>" or per site [estates.<label>.orders] dns.

The skeleton: examples/dns-skeleton: write, remove, listed as one JSON API, zone from the block; export_dns!(ExampleDns, grants = [http]).

3.5 A signup guest

What it is: the sink that creates a paying customer's account on a product that is NOT pulled (a product with [products.<p>] instance gets an add_principal order instead, crates/monetize-server/src/signup_pull.rs). Grants: grpc (the product's door; the bearer is presented by the host and needs an endpoint), state. The guest never holds monetize's signing key: the host computes the EntitlementFact from [signups.<product>.plans] and signs it with that product's key before create.

The trait (crates/monetize-signup-guest/src/lib.rs:87-119):

pub trait SignupGuest {
    fn manifest(&self) -> Manifest;                                                                                       // Manifest::of_kind(kinds::SIGNUP, "<product>", …)
    fn check(&self, host: &dyn Host, settings: &serde_json::Value) -> Result<(), String> { Ok(()) }                      // op 300, once at load
    fn name_verdict(&self, host: &dyn Host, settings: &serde_json::Value, username: &str) -> Result<NameVerdict, SignupError>;   // op 310, before any card form
    fn create(&self, host: &dyn Host, settings: &serde_json::Value, signup: &PaidSignup, fact: &EntitlementFact) -> Result<SignupOutcome, SignupError>;  // op 311, a PAID signup
}

create must answer SignupOutcome::Created { namespace, username } only once the account and the fact are on the product, and must be idempotent on (vendor, session): a retry finishes its own customer. SignupError is Refused, NameTaken, Unavailable (the signup stays pending and is retried), Maintenance. PaidSignup { vendor, session, reference, email, username, product, plan, amount, tax, paid_at_unix_ms, customer_country } (crates/monetize-pay/src/signup.rs:43-102). State lives in <data_dir>/signup-guest-state.<product>.json.

The skeleton: examples/signup-skeleton: the account kept in the host's state; export_signup!(ExampleSignup, grants = [state]).

3.6 A product site: BuySite (native)

What it is: the one trait a product site (Gunnar's console, Holger's front) implements so that crates/monetize-site-buy can serve the whole buy flow (chapter 4) for it. It is native Rust in the site's own binary, not a wasm guest. It is 6 required + 6 defaults (crates/monetize-site-buy/src/site.rs:118-197; the count is held by tests/h15_stepped_up_refuses.rs).

Required:

pub trait BuySite: Send + Sync + 'static {
    fn origin(&self) -> String;                               // "https://holger.rs": every way back from the vendor is on it
    fn product_word(&self) -> String;                         // "Gunnar", "Holger"
    fn who(&self, headers: &HeaderMap) -> impl Future<Output = Result<Who, Refusal>> + Send;    // Visitor | Machine | Person(Buyer), from the request's own session
    fn ticket(&self, headers: &HeaderMap, buyer: &Buyer, purpose: &str, caps: &BTreeMap<String, u64>) -> impl Future<Output = Result<Vec<u8>, Refusal>> + Send;   // an actor ticket (estate | order | renew | read | cancel) the product vouches for over its own key
    fn held_caps(&self, headers: &HeaderMap, buyer: &Buyer) -> impl Future<Output = Result<BTreeMap<String, u64>, Refusal>> + Send;   // the caps the buyer's tenant holds now
    fn signup_account(&self, user: &str) -> impl Future<Output = Result<Account, Refusal>> + Send;   // NoPasskey | HasPasskey, or a 409 the welcome page keeps polling through

Defaulted:

    fn pages(&self) -> Pages { Pages::default() }                       // /signup?plan=account, /signup?plan=private, /signup?plan=private&renew=
    fn allowed(&self, door: &str, ip: &str, key: &str) -> bool { true } // a budget per door and client; false answers 429 before the shop is dialled
    fn trusted_proxies(&self) -> &[std::net::IpAddr] { &[] }            // whose X-Forwarded-For is believed, besides loopback
    fn fixed_buy_url(&self) -> Option<String> { None }                  // a fixed buy link where the shop mints no checkout
    fn stepped_up(&self, headers: &HeaderMap) -> impl Future<Output = Result<(), String>> + Send { Err(STEP_UP_WORDS) }   // a user-verified login in the last 10 minutes; unproven = refused
    fn operator(&self, headers: &HeaderMap) -> impl Future<Output = Result<Operator, Refusal>> + Send { Err(403 NO_OPERATOR_HERE) }   // who may order for a tenant named in the path
}

Buyer { account, tenant, member: Option<Member>, payer }, Who::{Visitor, Machine, Person(Buyer)}, Account::{NoPasskey, HasPasskey}, Refusal { status, words } (site.rs:24-108).

The skeleton: examples/site-skeleton: a site that knows nobody (every request a visitor, every ticket refused).

3.7 An estate guardian: EstateGuardian (native or ops 100–102)

What it is: pure functions over what the estate's boxes reported, run by the ONE box's guardian runner, which does the cloud steps and queues the orders; monetize never calls into an estate. A product guest answers the same three as ops 100–102; a linked product implements the trait. (crates/monetize-product/src/guardian.rs:237-245; all required.)

pub trait EstateGuardian: Send + Sync {
    fn diagnose(&self, view: &EstateView) -> Vec<Finding>;                                   // what is wrong now; empty: nothing
    fn plan(&self, remedy: Remedy, view: &EstateView) -> Result<Vec<Step>, String>;          // the steps that carry out `remedy`, or why it may not run now
    fn observe(&self, role: &str, what: &str, view: &EstateView) -> StepState;               // is the Await met? `heard` the runner answers itself
}

Remedy::{RechargeFromTwin, ReviewTwin, NewTwin, NewServer, RestoreFront}; Step::{Cloud(Fence { role } | Lay { role } | Restore { role }), Order { role, kind, body }, Await { role, what, within_ms }}; StepState::{Waiting, Met, Failed}; Finding { code, role, words, evidence, remedies, auto } (guardian.rs:36-213). The runner's own rules (the fence invariant, auto-fix, cases) are in chapter 5 §5.8.

The skeleton: examples/guardian-skeleton: finds a twin that has not reported, plans fence → lay → await heard.

3.8 The box side: PumpBox (native, in the product's box)

What it is: the product's half of the pull channel every box of a sold estate runs (crates/monetize-estate-side/src/pump/puller.rs:140-158). The pump dials the ONE box (Fleet.Report, Fleet.Orders long-poll ≤ 25 s, Fleet.Ask), verifies every order (monetize's signature, this box's role, a strictly higher seq, the time window) and persists last_seq before it calls run, so an order runs at most once.

pub trait PumpBox {
    fn health(&self) -> BTreeMap<String, String> { BTreeMap::new() }   // serving (yes|no|fenced), data (ok|lost|diverged), serves_at, twin, epoch
    fn product_report(&self) -> Option<String> { None }                // monetize_product::pull::ProductReport as JSON; the serving main sends it
    fn own_review(&self) -> Option<String> { None }                    // a twin's review of itself, as JSON
    fn run(&self, ctx: &Ctx, order: &SignedOrder, ask: &mut dyn Asker) -> OrderAck;   // REQUIRED: run one verified order; a kind not taken is acked not-ok by name
}

SignedOrder { seq, product, instance, role, kind, body (JSON), issued_unix_ms, not_after_unix_ms, … } and OrderAck { seq, ok, detail, receipt_json } (crates/monetize-embed/src/fleet.rs:142-154, 286-292). The core order kinds a box must know are in chapter 5 §5.3. Asker::ask is Fleet.Ask from inside an order (a growth's grow_set, a fetch of a file an order carries).

The skeleton: examples/box-skeleton: reports serving: yes, data: ok and acks every order not-ok by name.