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 = [...]):
host_http(method, url, headers, body)— the only way out. The host does the HTTPS with rustls and enforces a host allow-list from config (the endpoint). A guest over a plain HTTP API is only this plus JSON.host_grpc_unary(path, request_bytes) -> response_bytes— the same, for a gRPC control plane (gunnar, holger): the guest carries its product's vendored protos and prost-encodes; the host only frames HTTP/2. This is why "the product's own proto for its appliance" stays with the guest.host_sign(bytes) -> signature— the identity key never enters the guest. gunnar's sshsig credential and the appliance-key verification split here: signing on the host, verifying (a public key, pure) in the guest.host_env(name)— only the names[products.<id>] api_key_env(andbearer_token) listed; anything else is refused by name.host_clock,host_log.
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
-
The factory seam — done in this lane (
monetize-product/src/factory.rs,Linked::product, the vetra binary registers gunnar/holger and the private plugins). -
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. -
monetize-product-guest(SDK) andmonetize-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 oneProductFactoryper[products.<id>] guest = "<path>.wasm", verifies a pinned digest and/or a CMS signature through edda'sguest-hostBEFORE compiling, and defines exactly the imports the block'sgrantsunlock — 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). -
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 importwasi_snapshot_preview1, which the host does not define and refuses by name. It declaresgrants = ["grpc"]and calls exactly three RPCs. The vendoredholger.protoand its drift guard MOVED here with it, andproducts/holger/build.rsnow 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.rsasserts the meter table, the enumerate-or-refuse decision, the usage fold and every refusal's KIND againstmonetize_product_holgeritself. 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).
-
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. -
Delete
products/gunnarandproducts/holgeras server-side crates; their code lives on as guest crates (guests/gunnar, …), and the private guests stay inmonetize-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:
- The host runs the lane.
restore.starthands 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 withoutaccept_lost), which is where the product knowledge actually is. - A
host_grpc_streamdoor. 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. - The restore lane stays native.
Product::restore()answersNonefrom the guest, a growth that fails parks infailedas 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 host owns the deadline, and refuses before it opens. A stream is opened only if the call's remaining deadline can plausibly carry it; a replay that would outlast it is refused BY NAME with both numbers, exactly as a too-long
flushis. What the host must never do is open a stream it will cut in half — a truncated replay is a box that looks restored and is not. - The host owns the fuel. Bytes pushed through the door are the host's work, not the guest's; the guest's fuel bounds the decisions it makes, and a
sendthat blocks does not burn fuel while it waits. - The host owns the credential. Every stream carries the same host-minted single-use credential a unary call does (
host_credential), bound to the streaming method. - Nothing is ever a silent truncation. A peer that dies mid-stream, a cancelled call and a deadline reached all come back as the SAME
ProductErrorkinds the linked crate produces today —Unavailablefor a peer that went away (the poller re-asks; the restore resumes from the box's head + 1, which gunnar documents) andRefusedfor an answer that will not change. A partial replay that reported success would be the one failure this whole lane exists to prevent.
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
- Long-running verbs — DECIDED, 2026-09-23 (owner: "ja kör på"). Poll-style, and the section below is the shape.
- Tests that use the plugin crates directly —
crates/monetize-server/tests/purchase_end_to_end.rsdrivesGunnarProductagainstMockGunnar;poller.rsused the holger plugin until this lane replaced it with a down double. These move to the guest crates (where the mock appliances already live) or drive the guest through the host. - The identity key's home. Today
Signer::load_or_mintlives in the gunnar crate; withhost_signit moves to the host, andmint_identitybecomes the host's for every guest that declares it needs one. - Performance. Reads are per-tenant per tick (five minutes); the JSON round trip through a guest is nowhere near the budget. Nothing here is on a request path.
- Which direction stays native.
monetize-plugin-holger(monetize's guest inside holger) is already WASM and unchanged. Theservercloud and the sandbox stay native: they are monetize's own, not a product's.
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:
guest ADDED [products.<id>] blake3 …— a new id, served from now on, priced as written;guest REPLACED [products.<id>] blake3 A → B— calls in flight finish on A, new calls go to B;guest REFUSED [<clause>] [products.<id>]: … — whatever served before still serves.
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.