Gunnar

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 = [...]`):

* `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` (and
  `bearer_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

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).
5. **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.
6. 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

```text
  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 `flush` is. 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 `send` that 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
  `ProductError` kinds the linked crate produces today — `Unavailable` for a
  peer that went away (the poller re-asks; the restore resumes from the box's
  head + 1, which gunnar documents) and `Refused` for 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.rs` drives
  `GunnarProduct` against `MockGunnar`; `poller.rs` used 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_mint` lives in the
  gunnar crate; with `host_sign` it moves to the host, and `mint_identity`
  becomes 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. The `server` cloud 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.