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

nordisk/monetize

.nornir/01-products-as-guests.md

Products as guests — the plugin seam, and the road from linked crates to WASM

Design note, 2026-09-22. Steps 1–3 of the migration order below are DONE and in main, and step 4's holger half with them; the owner decides the rest. What each step actually shipped is noted against it.

What is true today (after this note's step 1)

Before 2026-09-22. Every product plugin was a Rust crate compiled into monetize-server: crates/monetize-server/Cargo.toml named monetize-product-gunnar and monetize-product-holger as dependencies, and registry.rs matched on the strings "gunnar" and "holger" to construct them (build_product, mint_identity). The server knew every product by name. There was no WASM anywhere in monetize except the Leptos console, which is WASM for the browser and has nothing to do with plugins.

Now. monetize-product has a registration seam, factory.rs:

ProductFactory { name, build: fn(&ProductBuild) -> Box<dyn Product>, mint_identity }

the same shape monetize_pay::factory gives a vendor. Linked on the server carries a products: Vec<ProductFactory>; build_product looks the configured [products.<id>] up in that list and refuses an id nobody registered, by name. The open monetize-server binary registers no product. The private vetra binary (monetize-impl/crates/monetize-server-vetra) registers gunnar, holger and its private plugins next to the paid vendors and clouds. The two open plugin crates still exist under products/, still native Rust, now reached only by registration; the server's tests keep them as dev-dependencies.

So the owner's requirement — monetize must have no compile-time dependency on its plugins — is met for the server and not yet met for the deployment: the vetra binary still links each plugin at compile time. The plugins are not WASM guests. That is what the rest of this note is about.

What holger already has, and what of it is reusable

holger runs guests. holger/plugin/host (holger-plugin-host) loads .wasm modules with wasmtime 48 behind its wasm-plugins feature, verifies a detached CMS signature over the bytes before compiling (verify.rs), bounds every call with fuel, epoch deadlines, a memory ceiling and a table ceiling (limits.rs), and takes a module census by digest. The ABI (holger-plugin-abi) is small and stable: the guest exports plugin_abi(), plugin_manifest(), plugin_call(op, ptr, len), alloc(size) and result_len(); arguments and answers are bytes in the guest's own memory; the host offers a handful of imports (host_store_get/put/list/size, and for the meter kind a clock and a grant table). Two guest kinds exist: the package handler (PackageHandler, holger's storage backends) and the meter (Meter, a verdict-answering guest with declared capabilities). monetize's own closed guest for holger, monetize-impl/crates/monetize-plugin-holger, is a meter: it verifies monetize's signed facts inside holger's appliance. That is the other direction (a product hosting a monetize guest) and stays as it is.

Can monetize load holger's host as-is? No, not directly. The host crate is tied to holger's traits (holger-traits, RepositoryBackendTrait, the meter vocabulary) and to holger's error registry. What is reusable is the mechanics — the export names and the alloc/result_len convention, the limits, the signature gate, the census — and the right home for them is a small crate in edda (the shared-libs monorepo, where circular dependencies are avoided by design) that both holger and monetize depend on. Roughly limits.rs + verify.rs + the calling convention, with no holger types. holger keeps its ABI crates on top; monetize adds its own.

The proposal — each product a WASM guest behind the same factory

The factory seam is the whole trick: a WASM host is one more ProductFactory, and the server cannot tell it from a linked crate.

Host — crates/monetize-product-wasm (open): at start it scans [products.<id>] guest = "<path>.wasm" (or a directory, /etc/monetize/products/), verifies each module's signature against the deployment's trusted root, reads plugin_manifest() for the product id and ABI range, and registers a ProductFactory named after that id whose build wraps the module in one Box<dyn Product>. Every Product method becomes one plugin_call(op, …) with a JSON request and a JSON answer — the same wire style holger's v2 meter uses — under fuel and a deadline.

Guest SDK — crates/monetize-product-guest (open): what a plugin author compiles against. It carries the op table, the wire types (mirrors of Usage, EntitlementFact, Meter, Absorb, Servable, MeteredDoor …), the alloc/result_len/plugin_call scaffolding as a macro (holger's guest_meter.rs is the model), and the host-import shims below.

What the guest must answer (the ops — one per Product method the server actually calls):

op answers who needs it
id, meters the product id, the meter table with backings and enforcement everything
list_tenants, read_usage, read_activity the poller's reads the ladder, the console
push_entitlement acknowledge a signed fact the transaction, the ladder
can_absorb, servable, metered_door the capacity questions and the proof the box is a paid build quote / start, signup
tenant_renewal, may_act_for whether tenants may renew themselves, and the ticket check the console's renew path
purge_tenant, twins, grow.*, twin_grow.* the appliance verbs (gunnar only today) reclaim, growth

The price is not the guest's: the quote is computed by monetize core from the guest's meters (backing + amount) and the deployment's price list, exactly as now. The guest sells nothing and signs nothing.

Host imports (what a guest may ask the host for; each behind a capability the operator granted in [products.<id>] grants = [...]):

What stays in monetize core, untouched: Stripe/Klarna/Swish (monetize-pay-impl), the signup sink, the ledger and the transaction, the ladder, the price lists and policy, the purchase page (kop), the poller, the console and its BFF. None of them change a line; they call dyn Product as they do now.

The vendored-proto drift tests (products/*/tests/vendored_protos.rs, the hash pins over each product's .proto files) move with the protos into the guest crate. They are a property of the guest, not of the server: a guest that drifts from its appliance is a guest that fails its own test and does not get signed and shipped. The host adds one check of its own — the manifest's ABI range against the host's — and the census records each module's digest, so a deployment can say which guest build is running.

Migration order

  1. The factory seam — done in this lane (monetize-product/src/factory.rs, Linked::product, the vetra binary registers gunnar/holger and the private plugins).

  2. Lift holger's guest mechanics into edda (edda/crates/guest-host, or a name the owner picks): limits, signature gate, census, the calling convention. holger switches to it first, since it is the only user and has the tests that pin the behaviour.

  3. monetize-product-guest (SDK) and monetize-product-wasm (host). DONE. The op table is the thirteen ops the server actually calls (id, meters, list_tenants, read_usage, read_activity, push_entitlement, can_absorb, servable, metered_door, tenant_renewal, may_act_for, purge_tenant, twins); the appliance verbs (grow) are NOT in it and still need the poll-style shape below. The host registers one ProductFactory per [products.<id>] guest = "<path>.wasm", verifies a pinned digest and/or a CMS signature through edda's guest-host BEFORE compiling, and defines exactly the imports the block's grants unlock — so an ungranted door is refused at load, by name, not at the first poll. The wire is JSON, and the four host doors carry bytes in a JSON-head frame. The identity key stays on the host (host_sign, host_identity).

  4. Port holger's plugin first — DONE as guests/holger (monetize-guest-holger), a cdylib for wasm32-unknown-unknown, not wasip1: the guest opens no file, owns no socket and reads no clock of its own — each is a host door — and a wasip1 module would import wasi_snapshot_preview1, which the host does not define and refuses by name. It declares grants = ["grpc"] and calls exactly three RPCs. The vendored holger.proto and its drift guard MOVED here with it, and products/holger/build.rs now compiles that same directory, so one mirror serves both builds and neither can drift from the other. The linked plugin stays until parity is proven against a live holger; guests/holger/tests/parity_with_the_linked_plugin.rs asserts the meter table, the enumerate-or-refuse decision, the usage fold and every refusal's KIND against monetize_product_holger itself. Still open: the poller against the real holger.rs box through the guest.

The private HTTP-and-JSON plugin was DONE too, in the private repo (a third product's guest, grants = ["http", "env"], cut with its product in A25 2.7), driven against the SAME local HTTP server as the linked plugin so the parity claim is a measurement rather than two transcriptions of one intention. It is what made the env door grow two PSEUDO-names beside bearer_token: endpoint (the block's address) and api_key (the VALUE of the variable api_key_env names). A guest is built once and signed once; where it is pointed and which variable a box's key rides in are the operator's lines, and a module cannot know either. The config file still never crosses the ABI. Then gunnar, which is the big one: sshsig via host_sign, actor tickets, and the growth/restore/twin verbs (about fifteen more ops, several of them long-running).

  1. The vetra binary registers the WASM host factory and stops linking the plugin crates. The open server needs no change at all — that is step 1's dividend. Guests ship as signed .wasm + .p7s, like holger's.

  2. Delete products/gunnar and products/holger as server-side crates; their code lives on as guest crates (guests/gunnar, …), and the private guests stay in monetize-impl.

The appliance verbs across the ABI — start → status → done

Written 2026-09-23, before guests/gunnar exists, because the shape is the decision and the code is the consequence.

The rule

No guest call waits for a box. A verb that takes minutes on the appliance becomes two ops: one that ASKS the product to begin and answers the status it has at that instant, and one that READS the status. The host calls the second one again on the next tick. Nothing holds fuel, nothing holds an epoch deadline, and a guest that is cut off mid-wait cannot lose a growth — because it was never waiting.

This is not a shape invented for WASM. It is the shape gunnar's own control plane already has: Admin.GrowStart answers the status after the call, not after the growth, and Admin.GrowStatus is the poll (products/gunnar/src/ grow.rs:108, :121). monetize's traits are already written that way — ApplianceGrow::start (and the retired ApplianceRestore::restore) answer "the status after the call, never when it has finished". The ABI copies the traits; it does not reinterpret them.

The op table

op trait method holds
grow.flush ApplianceGrow::flush(timeout_secs) the one exception — see below
grow.start ApplianceGrow::start(target_sectors_per_member, go_ahead) one round trip
grow.status ApplianceGrow::status() one round trip
grow.resume ApplianceGrow::resume(give_up) one round trip
lanes whether this guest offers the grow lane none

RETIRED (5.4, 2026-10-10): restore.start/restore.status (ops 70/71) and twin.health/twin.adopt (80/81) — and with them ApplianceRestore and ApplianceTwinship. Monetize never calls into an estate: a repair is a SIGNED order the estate pulls on the Fleet channel and runs through gunnar-repair (monetize-guardian, Product::guardian). The op numbers are never reused.

lanes exists because Product::grow() answers Option<&dyn …> — the server asks a product whether it HAS a lane before it drives one, and the host must be able to answer that without calling into a box. The guest declares it once, at load, beside id and meters.

flush is the exception, and it is bounded rather than poll-style

ApplianceGrow::flush(timeout_secs) is "flush replication to every standby, bounded by timeout_secs" — the bound is an ARGUMENT, and gunnar honours it (Mirror.FlushReplication). So this one call may legitimately take up to what the caller asked for, and turning it into start/status would mean inventing a flush job on gunnar's side that gunnar does not have.

The host therefore does the only honest thing: it refuses a flush whose timeout_secs would outlast the guest's own call deadline, by name and before the call, naming both numbers. An operator then either raises guest_call_timeout_ms in the block or asks for a shorter flush. What the host must never do is start a flush it will cut off half way — the answer would be "the twin is not caught up" for a twin that is, and the growth that follows would be refused for the wrong reason.

The credential is the HOST's, and host_sign is not enough for gunnar

gunnar's control plane has no bearer: every call carries a fresh single-use sshsig over a challenge naming the key fingerprint, the moment, a nonce and the Service.Method being called, and gunnar refuses a (fingerprint, nonce) pair it has seen (products/gunnar/src/signer.rs:14-19).

A guest cannot mint that. It has no RNG — a WASM module has no entropy source and must not have one — and host_sign(bytes) would hand back a raw signature that still has to be armoured as an SshSig PEM around a public key the guest does not hold. Building it in the guest would also put the nonce under the guest's control, and a nonce a guest can choose is a nonce a guest can repeat.

So the door the gunnar guest needs is host_credential(method) -> header: the guest names the Service.Method it is about to call, and the host mints the whole gunnar-authorization header — key, fingerprint, nonce, clock and armour all on the host side. The key never enters the module, which is the law this note has followed since step 2, and the guest gains nothing it could misuse. It rides the existing sign grant.

What a guest CANNOT do, and what would have to change

(Historical — the restore lane is retired, see above.) The restore replay is a client-streaming RPC. ApplianceRestore::restore does not only start something: it opens Bootstrap.Restore, which takes a stream LogChunk, and feeds it from Mirror.LogFetch pages pulled off the twin, on a thread the plugin owns (products/gunnar/src/restore.rs). The guest ABI has exactly one outward verb, host_grpc_unary, and a guest owns no thread. Three ways out, in the order they should be considered:

  1. The host runs the lane. restore.start hands the host a small instruction — twin address, accept_lost, the head to resume from — and the host's own restore lane does the streaming, exactly as the linked plugin does today. The guest keeps the four refusals that come before a byte moves (no twin named, box not fresh, empty log, would lose data without accept_lost), which is where the product knowledge actually is.
  2. A host_grpc_stream door. Honest, general, and a much larger ABI: a stream needs a handle, a push op and an end op, and every one of them is a new way for a guest to hold the host open.
  3. The restore lane stays native. Product::restore() answers None from the guest, a growth that fails parks in failed as it always has, and an operator drives the restore by hand.

OWNER RULING, 2026-09-23: (2), the streaming door. The guest keeps the lane, so it stays a real plugin; product knowledge does not move into the open host. (1) was the recommendation and it was refused for exactly the right reason.

The streaming door

  host_grpc_stream_open(path, headers)      -> stream id, or a named refusal
  host_grpc_stream_send(id, chunk_bytes)    -> ok, or the peer's status
  host_grpc_stream_close(id)                -> the unary RESPONSE bytes, or the status
  host_grpc_stream_cancel(id)               -> nothing; the peer sees a cancel

Four imports, one grant (grpc, the one the guest already holds to make a unary call). The guest prost-encodes each LogChunk exactly as it does a unary request, and the host frames HTTP/2 and holds the connection. A stream is opened, fed and closed within one guest call — the guest never holds a handle across calls, because a guest is a fresh instance per call and a handle that outlived one would be a handle nothing could clean up.

The rules that make it safe are the ones flush already follows:

The four refusals stay in the guest, where the product knowledge is: no twin named, the box is not fresh, the twin's log is empty, and the twin cannot give everything back without accept_lost. They all come before a byte moves, which is the only moment at which refusing still costs nothing.

The state door, and why three things needed it

A guest is a fresh instance per call. It cannot remember a nonce it has honoured, the phase a restore reached, or the id of a purge job it started — and each of those is something the linked plugin keeps beside its channel.

So the host keeps them: host_state_get / host_state_put, behind a state grant, per product, bounded (4 096 keys, 64 KiB each). In memory and forgotten on a restart, which is exactly the lifetime the linked plugin's own seen_nonces and restore cell have — no better, and deliberately no worse.

It is the host remembering on the product's behalf, not the guest keeping its own history, and that distinction is the security one: a guest cannot forge what it did not do, and a ledger that cannot be written is a refusal rather than a quiet yes.

Three users today: the restore's phase (gunnar has no restore-status RPC, which is why the linked plugin keeps a cell), the purge job's id (so the lane can ask about the job it started rather than starting a second), and the actor-ticket nonces (single-use is what the ledger means).

Progress is polled, not held. restore.start opens the stream, pushes what the deadline allows, and answers the status; restore.status reports where the replay is. A replay too long for one call resumes on the next tick from the box's head + 1 — the resume gunnar already documents and the linked plugin already relies on.

What is built now, and what is not

The ABI half is complete: the op table above, the wire types, the host's ApplianceGrow / ApplianceRestore / ApplianceTwinship implementations over those ops, the lanes declaration, the flush-deadline refusal, and a toy guest that answers every one of them so the lanes are proven end to end rather than described.

(Updated 2026-09-23: the streaming door above is ruled and the gunnar guest is under construction; what follows was written before either.)

The gunnar guest itself is not built. products/gunnar is 4 919 lines with eight vendored protos, the sshsig credential, the grow lane, the restore lane and the twin lane; transcribing it is a lane of its own, and doing it in a hurry is how a meter starts disagreeing with the plugin it replaced. It needs, in this order: the protos and their drift guard moved into guests/gunnar (as holger's were), host_credential, the read verbs with a parity test, then the three lanes.

What breaks, and what to decide

Verdict for the owner's question: NOT MET before this lane (products were linked and matched by name); PARTLY after it (the server links none; the deployment binary still does, natively). MET means steps 2–5.

Guests on the fly — no restart (2026-09-25)

.nornir/02's promise, a signed .wasm is replaced without rebuilding or restarting monetize, is kept (monetize-product-wasm::hot, monetize-server::guests).

The operator's procedure is the install procedure. Put the module (and under guest_trust_roots its .p7s) in place — stage it and mv it, so the watcher never reads a half-written file — and write or edit [products.<id>]: guest, guest_digest and/or guest_trust_roots, grants, http_hosts, guest_grpc_hosts, and for a NEW product its [prices.<id>.<market>]. Within 2 s the server's watcher (a std thread; it stats the config and every file a guest block names) re-reads the config and the journal says one of:

A replacement passes exactly the gates a start does (vouching, digest, CMS signature before compile, limits, ABI, manifest, gates A/B) and is compiled completely before one pointer write swaps it in. The allow-list is the block's, never the module's. The host's memory for the product (nonces, a restore's phase) is shared by the old and the new instance.

Not live (said in the journal on every config change): vendors, clouds, the policy, a price change for a product already served, [products.<id>.grow] / .bundles of a new product, moving an id from a linked plugin to a guest, and retiring a guest block. Those take the next restart. At most 64 products are ADDED per process lifetime (LIVE_PRODUCT_SLOTS); replacements are unbounded.