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

nordisk/holger

.nornir/stinky/06-monetize-plugin.md

06 — holger's monetize plugin, AS A PLUGIN (ABI v2)

Stinky lane 6, 2026-09-20, against holger main = 542e296, gunnar as checked out on t14s, monetize / monetize-impl as checked out on t14s, edda as checked out on t14s. Tags: MEASURED = a line or a number read out of the tree today, file:line given. READ = the source says so about itself, not re-proven. DESIGNED = proposed here; none of it exists. No live machine was reached; a new gunnar estate is being stood up, so the local clones are the authority.

NOW — what exists on 2026-09-27 (read this first; §0–§11 below are the 2026-09-20 design and history)

Owner rulings this rests on: monetize is BARE and takes signed WASM plugins at runtime (2026-09-25); the product registers its own plugin (2026-09-25, gunnar first, holger now); money goes through monetize + the signed Stripe vendor plugin on the Danish company's account (Ignalina ApS), never kassa (2026-09-27). Everything below is MEASURED on loki, 2026-09-27.

Where the plugin lives. crates/holger-monetize-guest in THIS repository (it was open monetize guests/holger until e6e51ac there). A wasm32-unknown-unknown cdylib, compiled from holger's OWN proto/holger.proto + proto/holger-metered.proto (no mirror, so no drift), stamped with the custom section holger_build_ref = the same short hash as HOLGER_GIT_HASH. No server tier depends on it. Built and SIGNED by the commercial monetize-impl's Build Thing row holger-guest (manifest_dir = "../holger", sign = { purpose = "plugin" }), in the pinned container: dwarves buildthing ./monetize-impl --suite COMMERCIAL --variant holger-guest (dwarves with --features sign-step). Last build: holger f59edcf → blake3 d5b89710…, signed by nordisk plugin signing. Its parity, live and real-module tests live in monetize products/holger/tests/, beside the commercial crates they link (holger links none).

What it does. grants = ["grpc"] only. Reads: ListRepositories, ReadUsage, ListArtifacts, ArchiveInfo, Admin.Capacity. Delivers a paid fact through holger metered's holger.metered.v1.EntitlementService.Set (since f59edcf/b101299 — before that it refused with "holger has no entitlement door", which was stale since 2026-09-20). The metered-door probe asks EntitlementService.List: answered = enforcing, the box's UNIMPLEMENTED = absent (open build), else unproven. server/lib/tests/the_guest_delivers_what_holger_verifies.rs proves the plugin's bytes, through holger's own metered::to_signed, equal the fact monetize signed and verify.

How it reaches the front. holger-server metered registers it ITSELF (server/lib/src/self_register.rs): the metered build embeds the signed module staged in .plugin-out/ (server/lib/build.rs::self_plugin; a module of another commit stops the build [plugin-other-build]); at run time only the PRIMARY (role:) registers, at the front's plugin door named by the config block plugin_door: (door, name, ca_pem, cert_pem, key_pem), over mutual TLS under the front's installer CA — GET /register/holger, and POST only when the front holds another module or registrar: ADDED once, nothing at the same build, REPLACED at a new build, a twin never. The wire is Skidbladnir crates/installer/plugin-door-wire (moved from gunnar, af77463). The door side is gunnar-front's (POLICIES row holger, gunnar 55e7338): grants ["grpc"], no twin restore hosts, no ticket key, and ONLY on a front whose feed sells holger (plugin.holger.prices). The OPEN holger build embeds nothing, registers nothing and still speaks no money (open_build_speaks_no_money, no_money_word green).

What the ops kit feeds (private-holger-ops branch holger-monetize-feed): [[web.alias]] holger.monetize.ignalina.dk (Stripe's webhook host; the CNAME at one.com is the OWNER's, by hand), [monetize] machine = "web", cargo xtask monetize feed [--bench] (vendor.stripe.* pinned, secret.stripe_key/secret.stripe_webhook front-only and never printed, plugin.holger.prices, web_hosts, web_callback_only — no holger module: holger registers its own), and cargo xtask stripe webhook check | ensure --apply.

The proof (monetize-impl branch holger-pay-proof, guests/stripe/examples/prove_holger.rs): a bare monetize-server with holger's signed plugin as [products.holger], the signed Stripe guest, holger's own disk through the free [clouds.server], against the fake Stripe (test keys only): Reserved → Ensured (OnHand) → Captured → Settled (fee 210 / net 790 SEK minor; forged webhook 401, genuine 200; every request to the fake carried the key the HOST added; no secret in the journal). It then stops: "captured, entitlement not yet pushed".

What is NOT done — the owner's to decide or do:

  1. The meter holger sells. holger metered takes caps only on artifacts and archive_bytes (server/lib/src/metered.rs caps_holger_cannot_honour); the plugin sells and prices the disk meter artifact_bytes. So a paid fact is refused and a buyer is CHARGED and not delivered. Either holger polices artifact_bytes, or the plugin's backed meter becomes archive_bytes. Nothing may be sold for real until this is settled.
  2. holger's prices — none exist ([monetize] prices; the front refuses holger's push [plugin-no-prices] without them). Unit: disk_gib_month.
  3. A front that runs monetize. holger's web machine runs caddy + holger-ui-server only and has no utility interface; holger-server reaches the plugin door at <front utility>:8475. holger has no installer run (nordisk-pairing) either, so the box certificate plugin_door names is the operator's to issue until holger's estate is installed the gunnar way.
  4. The DNS CNAME holger.monetize.ignalina.dk → holger.rs at one.com (by hand), then stripe webhook ensure --apply writes MONETIZE_STRIPE_WEBHOOK_SECRET_DK.
  5. plugin-door-wire 0.1.0 on crates.io, so gunnar drops its own copy (gunnar builds from one clone).

0. What this section overrules, and on what evidence

Lane 5 (05-suite-monetize-mvp.md §B.5) wrote: "It must be a native seam, not a wasm plugin — three measured reasons." The owner overruled that on 2026-09-20: "DU SKALL FUCKING GÖRA plugin liksom att gunnar har plugin — både opensource och icke opensource, vetra/monetize-plugin."

He is right, and one of lane 5's three reasons is now factually stale:

lane 5's reason status today
1. "wasm-plugins is OFF in every tier … wasmtime 27, 17 advisories, 4 sandbox escapes" STALE, MEASURED. plugin/host/Cargo.toml:32 requires wasmtime = "48"; Cargo.lock resolves wasmtime 48.0.2 for it. deny.toml:32-38 says in as many words: "the 27 acceptance is GONE … the bump has now happened: plugin/host requires wasmtime 48". The two remaining wasmtime advisories (deny.toml:57-58) are against 45.0.3, reached as a dev-dependency through nornir-build-thing, in no shipped binary. The prose in server/lib/Cargo.toml:458-461 still says "wasmtime 27 — 17 open advisories" and is out of date with its own workspace — fix that comment as part of this work. wasm-plugins being off in every tier is true and is a tier choice, not a property of the ABI.
2. "monetize itself has no dynamic loading anywhere … the gunnar plugin is native" HALF TRUE, and the half it misses is the one the owner meant. True of monetize's money seams. False of gunnar as a product: gunnar ships a real wasm plugin — §1.3.
3. "the metered half must be ABSENT from the open build, not disabled — a cargo feature achieves absence; a wasm file present-or-not on disk does not" The only surviving reason, and it is a real cost. §3.3 answers it and prices it honestly rather than waving it away.

The conclusion this section reaches: a plugin, with ABI v2, because the plugin model is what the business needs (§3.1) and because the alternative forces holger's own manifest to name a closed crate (§3.2).


1. MEASURED — what gunnar's plugin mechanism ACTUALLY is

Gunnar has three different seams and they get confused with each other. All three are real; only one of them is what most people mean by "plugin".

1.1 The money seam is a CARGO FEATURE, not a plugin

MEASURED. gunnar-server/Cargo.toml:195-200:

metered = [ …, "gunnar-core/metered", "gunnar-common/metered", "gunnar-grpc/metered", "gunnar-ssh/metered" ]

and the crate it pulls in is monetize-embed, optional, behind the same feature (gunnar-server/Cargo.toml:275-277). The metered surfaces are #[cfg(feature="metered")] items: gunnar-server/src/entitlement.rs, gunnar-core/src/entitlement.rs, gunnar-grpc/src/service/billing.rs, gunnar-ssh's plan gate (gunnar-ssh/Cargo.toml:13-21). Absence is proven twice, by two different methods: gunnar-cli/tests/feature_split.rs:394-414 asserts monetize-embed and monetize-product are absent from the resolved dependency graph of a non-metered build, and gunnar-recipe/tests/metered_symbols.rs:41,134 asserts the symbol families (monetize_embed, gunnar_server::entitlement, gunnar_core::entitlement) are absent from the built binary.

So on gunnar's side the open/closed line is drawn with #[cfg], and what crosses it is monetize-embed — a published crates.io crate (monetize/crates/monetize-embed/Cargo.toml, publish = true), so the open build's manifest names nothing private.

1.2 monetize's seam is a const STRUCT WITH A FUNCTION POINTER

MEASURED, and this is the nordisk house style for open/closed. monetize-pay/src/factory.rs:20-22 says it out loud: "A factory is a const: a name, the environment names the vendor reads, and a plain fn that builds the provider … No trait, no generics: three fields and a function pointer are the whole contract, and a private crate's register is list.push(FACTORY)."

// monetize-pay/src/factory.rs
pub struct VendorFactory { pub name: &'static str, pub config_envs: &'static [&'static str], pub build: VendorBuilder }
pub type VendorBuilder = fn(&VendorBuild<'_>) -> Result<Box<dyn PaymentProvider>, String>;

The registry holds a list and looks a configured name up in it — monetize-server/src/registry.rs:39-53 (Linked { vendors, clouds, signup, tax }), :60-71 (Linked::open() registers only invoice, sandbox, server). The open binary is cli::run(Linked::open()) (monetize-server/src/main.rs:7); the vetra binary is a different main in the private repo that starts from Linked::open() and pushes the paid factories on top (registry.rs:14-18). A configured name with no factory is refused by name at boot (registry.rs:1-4). The open tree contains no string "stripe" — a test greps its own source for the names (BASE-MODEL.md, rule 4).

The PRODUCT seam is weaker than the vendor seam, and that matters here. MEASURED: monetize-server/src/registry.rs:321-358 build_product is a hard match id { "gunnar" => …, "holger" => …, other => bail!("[products.{other}]: no plugin for that product is linked into this server") }, over in-tree crates monetize/products/gunnar and monetize/products/holger. A product plugin is Arc<dyn Product> (registry.rs:165) but it is not registrable from outside the tree the way a vendor is. So "monetize's holger plugin" is in-tree, native, and compiled into monetize-server — and it is not the artifact this section designs.

1.3 gunnar's actual PLUGIN is a WASM GUEST — and it is edda's host, not gunnar's

This is the one lane 5 missed, and it is the precedent the owner is pointing at.

MEASURED. gunnar/crates/gunnar-build-thing/tests/wasm_plugin.rs:1-27: "The same GunnarBuildThing is called natively and — compiled to wasm32-unknown-unknown and loaded through edda's generic plugin host, with NO gunnar dependency on the host side, which is the forge's situation — over the ABI, and the two must answer IDENTICALLY." It uses nornir_build_thing::plugin::BuildThing and nornir_build_thing::plugin_host::{HostFacts, Limits, PluginHandle} (wasm_plugin.rs:33-35). The module also travels inside the built artifact, in a linker section .nordisk.buildthing (edda …/plugin.rs:96-98), and dwarves reads it with an object parser rather than executing the artifact.

The host is edda/crates/nornir-build-thing/src/plugin_host.rs (786 lines) over plugin.rs (2302 lines), and four of its properties are exactly what ABI v2 needs:

property where what it does
no WASI, on purpose plugin.rs:26-40 "A wasm32-unknown-unknown module has no imports to deny; it cannot NAME a syscall. The guarantee is structural instead of configured."
the import list IS the permission list plugin_host.rs:277-306 every import is matched against host::NAMESPACE + host::IMPORTS; anything else → bail!("build-thing wasm requests host capabilities that are NOT granted: {…}") — refused at LOAD, by name.
facts, not handles plugin_host.rs:146-170 HostFacts "Manifests the host will read, by repo-relative path. A path not in here is declined — the plugin never gets a path handle, so there is no traversal to defend against, only a lookup that misses."
one call(op, ptr, len) export, an op TABLE as data plugin.rs:1517-1543, :1606 ops 1–10 were the original set; ops 11–13 (OPTIONS, TRY_TOGGLE, ACTIONS) were added later without changing the export set and without bumping ABI_VERSION (still 1, plugin.rs:100). Growth is a new op, not a new export.

There is also a named host exception pattern worth stealing wholesale (plugin_host.rs:53-64,104-145): the reference fulfilment is one thing (a container), a host-side fulfilment is allowed but must carry a REASON, is answered as a distinct variant on the wire (FETCH_OK_HOST_EXCEPTION), and is written to the plugin's log — "so it can never pass as the container one". HostPathFetcher::named refuses an empty reason.

1.4 The answer to "how do gunnar's open and closed halves meet at runtime"

They do not meet at runtime at all. MEASURED: they meet at link time, by cargo feature, and the closed half is on the other side of a gRPC boundary — the appliance carries monetize-embed and a public key and verifies signed facts offline (monetize-embed/src/lib.rs:1-45); the money lives in monetize-server, reached over gRPC, in a different process on a different box, and the paid vendor impls live in a different binary in a private repo. Three separations stacked: #[cfg], process, repository.

The wasm plugin (§1.3) is a fourth mechanism, used for a completely different thing (build declarations), and it is the only one of the four that lets a stranger extend a running binary. That is the one the owner means by "gunnar har plugin".


2. MEASURED — holger's plugin host and what ABI v1 really guarantees

2.1 The shape

piece file:line what
PackageHandler plugin/abi/src/lib.rs:152 6 verbs: manifest, fetch, put, list, coordinate_for_path, http. Pure over a store, so one implementation compiles native and wasm32.
BlobStore plugin/abi/src/lib.rs:129 get / put / list / size. The host owns every byte.
HandlerRuntime plugin/host/src/backend.rs:33 how a verb executes — LinkedHandler (native) or WasmHandler (wasmtime). The router receives Arc<dyn RepositoryBackendTrait> whose concrete type is HandlerBackend in both cases (backend.rs:1-13); there is no second code path for it to take.
host imports plugin/host/src/wasm.rs:483 register_host_functions exactly four, all in module env: host_store_get, host_store_put, host_store_list, host_store_size (abi/src/lib.rs imports::ALL).
wire types plugin/abi/src/wire.rs WireArtifactId {namespace,name,version} (:20), WireArtifactEntry (:54), WireManifest {abi_version,handler,format,writable} (:79), HTTP request/response. Length-prefixed codec, never JSON (abi/src/lib.rs:70-74).
registration plugin/host/src/registry.rs:1-20, catalog.rs:200,235 reads a directory, not Cargo.toml. Absence is loud: ScanError::{Missing,NotADirectory,Unreadable,NoWasmHost,UntrustedModule}.

2.2 The bounding — keep every bit of it

MEASURED, plugin/host/src/limits.rs:

All of that survives ABI v2 unchanged. v2 adds fields and imports; it changes no limit, no signature check and no failure posture.

2.3 What v1 cannot carry (the measurement lane 5 got right)

  1. No principal. Nothing on the wire names who is calling. WireHttpRequest carries the suburl and headers; the authenticated identity lives in server/lib's auth layer and stops there.
  2. No digest. WireArtifactId (wire.rs:20) is {namespace, name, version} — coordinates only. A meter that bills bytes can use size_bytes; a plugin that must decide about these exact bytes cannot.
  3. No usage, no audit, no clock. The four imports are store verbs. A guest cannot read a counter, cannot append an audit record, and cannot learn the time.
  4. One implicit, ungrantable capability. Every module gets all four store imports, always; a module that wants fewer cannot say so and an operator who wants to give fewer cannot.
  5. An exact-match u32. ABI_VERSION: u32 = 1 (wire.rs:15), checked != at wasm.rs:156. And the decoder is strictly positional with expect_end (codec.rs:167, used by every from_bytes), so a manifest with one extra field is a decode error. There is no forward direction at all — not "unknown fields are rejected", but "the concept of an unknown field does not exist".

3. The ruling

3.1 Why the plugin model is the point (business, not taste)

The split the business needs is: the OPEN half ships in the open build and mentions no money; the CLOSED half never needs to exist in holger's tree. A plugin gives that by construction — the closed half is a .wasm + .p7s that a paying deployment is handed and an open deployment simply never receives. holger's manifest, holger's lockfile and holger's build never learn that vetra/monetize-plugin exists.

It also gives the thing a cargo feature cannot: a third party can meter holger without forking it. Today, per lane 4's inventory, the wasm host is the only seam a third party can reach without a rebuild — and no tier opens it. The metering plane is the first use that justifies opening it.

3.2 What the native metered feature costs, honestly

A metered cargo feature CAN express the split, and gunnar proves it works (§1.1). The cost, stated plainly:

Verdict: the feature is the cheaper road and the plugin is the right one. Use both: the metered feature gates the host-side kind (§5.4), and the plugin is where the closed logic lives.

3.3 The surviving objection, answered and priced

Lane 5's reason 3 — absence, not disablement — is real. Answer, in three parts:

  1. The ABI names no money. usage, meter, audit, clock, principal, entitlement, capacity are resource and provenance words. plan, price, invoice, stripe, upcloud, paid, quota, subscription appear nowhere in ABI v2 or in the open guest. The existing guard server/lib/tests/open_build_speaks_no_money.rs (written on both metering branches) is extended to assert the allow-list of resource words and refuse any new one — a stronger test than it is today, because today it only greps for known-bad words.
  2. The kind is compilable out. meter-guests is a cargo feature on plugin/host; with it off the open binary carries no meter op table, no WireVerdict, no entitlement vocabulary — strictly more absence than a metered feature gives, because the wasm route also removes the closed logic itself, which a metered build still compiles in.
  3. The residue, stated: with meter-guests on, the open binary contains the host half — the capability names and the verdict enum. That is the price, and it buys the open allocation plugin (§6.1), which the owner's law requires the open build to ship anyway ("shows and tests how much storage there is"). It is not a leak of the business; it is the shape of the resource plane the open build is supposed to have.

Re-answered 2026-09-20 (see §11): the owner drew the line that dissolves even this residue — the open allocation guest is ITSELF wasm, so the host is carried on its own merits and not for money's sake. "No money in the open build" then means the paid .wasm is simply not installed and not granted, and §11.0 makes that provable on a RUNNING box.


4. DESIGNED — ABI v2

Everything below is new. Nothing in it exists in the tree.

4.1 Versioning that can negotiate

/// The oldest and newest ABI this build speaks. A RANGE, not a point.
pub const HOST_ABI_MIN: u32 = 1;
pub const HOST_ABI_MAX: u32 = 2;

/// What a guest declares.
pub struct AbiRange { pub min: u32, pub max: u32 }

/// The one negotiation. Refuses with BOTH ranges in the message.
pub fn negotiate(host: AbiRange, guest: AbiRange) -> Result<u32, String> {
    let lo = host.min.max(guest.min);
    let hi = host.max.min(guest.max);
    if lo > hi {
        return Err(format!(
            "no common ABI: this host speaks {}..={}, the module speaks {}..={}",
            host.min, host.max, guest.min, guest.max
        ));
    }
    Ok(hi) // the newest both can do
}

The negotiated number is echoed back to the guest in every call (WireCallContext::abi), so a guest built for 1..=2 knows which shape it is being handed and never guesses.

4.2 A manifest decoder that tolerates forward, refuses required

v1's codec is positional and expect_end makes an extra field a decode error (codec.rs:167). v2's manifest — and only the manifest — is TLV:

/// Tag numbering: the HIGH BIT marks REQUIRED.
/// 0x0001..0x7FFF  optional — an unknown one is skipped and remembered.
/// 0x8001..0xFFFF  required — an unknown one is REFUSED BY TAG.
pub const REQUIRED_BIT: u16 = 0x8000;

pub struct WireManifest2 {
    pub abi: AbiRange,                      // tag 0x8001 (required)
    pub kind: GuestKind,                    // tag 0x8002 (required)
    pub name: String,                       // tag 0x8003 (required)
    pub requires: Vec<Capability>,          // tag 0x8004 (required — absent means "none")
    pub handler: Option<HandlerFacts>,      // tag 0x0010 — kind PackageHandler only
    pub meter: Option<MeterFacts>,          // tag 0x0011 — kind Meter only
    /// Optional tags this host did not know. Kept, never interpreted, printed by
    /// `holger plugin describe` so an operator can see the module said MORE than
    /// this holger understands.
    pub unknown_optional: Vec<(u16, Vec<u8>)>,
}

pub enum ManifestError {
    UnknownRequiredTag { tag: u16, host_abi_max: u32 },   // refused BY NAME (by number)
    WrongKindPayload { kind: GuestKind, tag: u16 },
    // … the v1 decode errors, unchanged
}

Rule, stated once so nobody softens it: an unknown OPTIONAL tag is carried; an unknown REQUIRED tag is a refusal naming the tag and this host's HOST_ABI_MAX. The call payloads stay positional and expect_end-terminated exactly as v1 — the negotiated version already tells both sides which shape a payload has, so paying TLV cost on the hot path would buy nothing.

4.3 The capability model

Today's single implicit ungrantable capability becomes declared, granted, and refused by name — the shape edda's host already enforces structurally (plugin_host.rs:277-306).

#[non_exhaustive]
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum Capability {
    StoreRead,      // host_store_get, host_store_size
    StoreWrite,     // host_store_put
    StoreList,      // host_store_list
    UsageRead,      // host_usage_read        — the counters
    AuditWrite,     // host_audit_write       — one record, host-stamped
    ClockRead,      // host_clock             — a trusted reading, with its SOURCE
    PrincipalRead,  // the principal field is populated in WireCallContext
    DigestRead,     // the digest field is populated in WireArtifactRef
}

impl Capability {
    /// The stable wire/config spelling. ONE writer.
    pub fn name(self) -> &'static str {
        match self {
            Capability::StoreRead => "store.read",
            Capability::StoreWrite => "store.write",
            Capability::StoreList => "store.list",
            Capability::UsageRead => "usage.read",
            Capability::AuditWrite => "audit.write",
            Capability::ClockRead => "clock.read",
            Capability::PrincipalRead => "principal.read",
            Capability::DigestRead => "digest.read",
        }
    }
    pub fn from_name(s: &str) -> Option<Capability> { /* inverse; unknown => None */ }
    /// The `env::` import names this capability unlocks. Empty for the two
    /// capabilities that are FIELDS rather than imports.
    pub fn imports(self) -> &'static [&'static str] { /* … */ }
}

Two gates, both at LOAD, both refusing by name. Neither alone is enough:

pub enum GrantError {
    /// The manifest asked for a capability the operator did not grant.
    NotGranted { module: String, capability: &'static str, granted: Vec<&'static str> },
    /// The module IMPORTS something its manifest did not declare — a lying manifest.
    UndeclaredImport { module: String, import: String },
    /// The module imports something outside the whole capability vocabulary.
    UnknownImport { module: String, import: String },
    /// The kind requires a capability the module did not ask for.
    KindRequires { module: String, kind: GuestKind, capability: &'static str },
}

Grants live in holger's config file, per module, not in env vars — the appliance has no shell (LAW; and lane 4 §2 names the env-var policy struct as the defect):

plugins: [
    ( module: "allocation.wasm",      grants: ["usage.read", "store.list"] ),
    ( module: "monetize-plugin.wasm", grants: ["usage.read", "principal.read", "digest.read", "clock.read", "audit.write"] ),
]

An unreadable or unparseable grant file refuses every module — gunnar's GUN-007 rule (unreadable gate config refuses everything), adopted verbatim. A module present on disk with no entry gets the empty grant, so a module dropped in without an operator decision can do nothing at all; if its requires is non-empty that is NotGranted at boot.

StoreRead/StoreWrite/StoreList are carved out of today's implicit bundle. Migration: an ABI-1 module declares nothing, so the host grants it the v1 set — the one backward compatibility rule, written down here so it is a decision and not a leak.

4.4 The context every call carries

pub struct WireCallContext {
    /// The negotiated ABI (§4.1), so the guest never guesses the payload shape.
    pub abi: u32,
    /// holger's tenant unit: the REPOSITORY (monetize/products/holger/src/lib.rs
    /// already models it that way — holger has no namespaces and no accounts).
    pub repository: String,
    pub verb: WireVerb,                       // Fetch|Put|List|Http|Delete|Promote
    /// `None` unless `principal.read` was granted. NOT an empty string — a guest
    /// must be able to tell "not granted" from "anonymous".
    pub principal: Option<WirePrincipal>,
    pub artifact: Option<WireArtifactRef>,
    /// `None` unless `clock.read` was granted.
    pub now: Option<WireInstant>,
}

pub struct WirePrincipal {
    /// Exactly `AuditEvent::ident` (server/lib/src/audit/mod.rs:146): the OIDC
    /// `sub`, the mTLS CN, or "anonymous". ONE spelling of identity in holger.
    pub ident: String,
    /// "bearer" | "mtls" | "oidc" | "passkey" | "anonymous"
    pub method: String,
    /// `ip:port` as the server saw it; empty when the transport exposed none.
    pub source_ip: String,
}

pub struct WireArtifactRef {
    pub id: WireArtifactId,                   // v1's type, unchanged
    /// `None` unless `digest.read` was granted AND the store knows it. Two
    /// different reasons for `None`, distinguished by `digest_state`.
    pub digest: Option<WireDigest>,
    pub digest_state: DigestState,            // Present | NotGranted | NotKnown
    pub size_bytes: u64,
}

pub struct WireDigest { pub algo: DigestAlgo, pub bytes: Vec<u8> }   // Blake3_256 | Sha256

DigestState::NotKnown is load-bearing: holger's attestation store is digest-keyed (server/lib/src/attest.rs) and verify.rs already hashes with BLAKE3, but not every backend can produce a digest for every artifact. A guest that gets NotKnown must be able to refuse rather than believe an absent digest is a fresh one.

4.5 The clock, and why it carries its source

pub struct WireInstant {
    pub unix_nanos: i64,
    pub source: ClockSource,
    /// How far into the future the host is willing to vouch for this reading —
    /// for `SignedFact`, the fact's own horizon. 0 = "no horizon, do not
    /// extrapolate".
    pub trusted_until_unix_nanos: i64,
}

#[non_exhaustive]
pub enum ClockSource {
    /// The box's own wall clock. On an airgapped appliance this is NOT trusted:
    /// gunnar measured VMs booting +2h and UpCloud dropping inbound NTP.
    HostWall,
    /// Time carried INSIDE a signed artefact the host verified — the update
    /// bundle's fact, or gunnar's signed nonce+time from the front.
    SignedFact,
    /// Monotonic nanoseconds since this process started. Cannot be moved
    /// backwards by an operator with a clock.
    MonotonicSinceBoot,
}

The rule the closed plugin must obey, and the ABI makes checkable: an expiry decision is never taken on HostWall alone. paid_until is compared against a SignedFact reading, and MonotonicSinceBoot is what stops a box that is turned back a year from resurrecting an expired grant. A guest that decides on HostWall is making a decision the operator can move with a clock; the ABI cannot stop it, but it makes the choice visible to whoever reads the guest, and the host logs which source it handed over.

4.6 Usage, honestly

pub struct WireMeterReading {
    pub meter: String,              // "artifact_bytes", "archive_bytes", …
    pub scope: MeterScope,          // Tenant | Product  — monetize_product::Scope's words
    pub enforcement: Enforcement,   // Enforced | Measured | Provisioned — monetize_product::Enforcement's words
    pub value: ReadingValue,
}

/// THE honest-meter type. There is NO `u64` variant without a story.
pub enum ReadingValue {
    /// A number the backend actually counted.
    Counted(u64),
    /// The backend cannot answer this meter. Carries WHY, by name. This is NOT
    /// zero, and a fold that treats it as zero is the bug this type exists to
    /// make impossible (monetize::fleet already counts it as a HOLE —
    /// `FleetTotal::tenants_without_reading`).
    Refused { reason: String },
}

Import: host_usage_read(repo_ptr, repo_len) -> u64 → encoded Result<Vec<WireMeterReading>, String>. The host fills scope and enforcement; a guest cannot upgrade its own meter to Enforced.

4.7 Audit, unforgeable

/// What a GUEST may say. Note what is absent: no timestamp, no identity, no
/// source address. The host stamps `ts_nanos`, `ident` and `source_ip` from the
/// call it is already inside, so a plugin can never write a record naming
/// somebody else or claiming another time.
pub struct WireAuditRecord {
    /// Free string; `AuditAction::from_wire` (audit/mod.rs:125) already maps an
    /// unknown value to `Other`, so a newer plugin writing a newer verb into an
    /// older holger degrades instead of failing.
    pub action: String,
    pub repository: String,
    pub artifact: String,
    pub status: u16,
    pub bytes: u64,
    pub detail: String,
}

Import: host_audit_write(ptr, len) -> u64 → encoded Result<(), String>. It lands in the existing Arc<dyn AuditLog> (server/lib/src/audit/mod.rs:212) and therefore in the blake3-chained Arrow-IPC segments — which is also the outbound half of the airgap loop (§8). A failed audit write stays best-effort (record_audit is a log::warn!, never a failed request) except for a guest whose verdict depended on it, which must be able to refuse; so host_audit_write returns the Result rather than swallowing it.

4.8 The verdict a gate-shaped guest returns

pub enum WireVerdict {
    Allow,
    /// Allowed, with a line the client must SEE (grace). gunnar's ladder rule:
    /// never hold data hostage; warn on the way through.
    AllowWithNotice { line: String },
    /// Refused BEFORE the bytes. `code` is a holger-errcode name so the refusal
    /// is greppable and stable; `reason` is the human line; `url` is where to go.
    Refuse { code: String, reason: String, url: Option<String> },
}

Refuse is the one place the design touches enforcement, and enforcement is a gate — see §9. The guest produces a verdict; it does not apply one.


5. DESIGNED — guest KINDS, so a third kind is not a third rewrite

5.1 What a guest kind is

A guest kind is a named contract between the host and a module: an op table, a payload shape per op, and a minimum capability set. v1 has exactly one kind and it is implicit — the export list exports::ALL is the contract (abi/src/lib.rs), which is why a second kind would today mean a second export list, a second loader and a second HandlerRuntime.

#[non_exhaustive]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum GuestKind {
    PackageHandler = 1,   // v1's contract, renumbered into the op space
    Meter          = 2,   // NEW: readings, capacity, verdicts, facts
}

impl GuestKind {
    pub fn name(self) -> &'static str { /* "package-handler" | "meter" */ }
    /// Capabilities no module of this kind can function without. The host refuses
    /// a module whose `requires` omits one, BY NAME (`GrantError::KindRequires`).
    pub fn mandatory(self) -> &'static [Capability] { /* … */ }
    /// Ops this kind answers. Data, not a `match` in the loader.
    pub fn ops(self) -> &'static [OpSpec] { /* … */ }
}

5.2 One export, an op space partitioned by kind

The export set becomes fixed and kind-independent — edda's bt_call shape (plugin.rs:1606), which is exactly why edda could add ops 11–13 without touching its export list or its ABI number:

export signature
alloc (u32) -> u32 v1, unchanged
dealloc (u32, u32) NEW — v1 leaks by design; a long-lived meter guest cannot
result_len () -> u32 v1, unchanged
plugin_abi () -> u64 NEW — min << 32 | max, read before anything else
plugin_manifest () -> u32 v1, now returning the TLV manifest
plugin_call (op: u32, ptr: u32, len: u32) -> u64 NEW — replaces the six per-verb exports

Op space:

    0 ..=  99   common to every kind (0 = manifest, reserved)
  100 ..= 199   PackageHandler   100 fetch, 101 put, 102 list, 103 coordinate_for_path, 104 http
  200 ..= 299   Meter            200 meters, 201 read, 202 capacity, 203 selftest,
                                 210 evaluate, 211 accept_fact, 212 describe_fact
  300 ..= 399   the third kind, whatever it is

The seam that makes kind three cheap, stated as the property to preserve: the loader never matches on a kind. It reads kind from the manifest, looks the kind up in a table of &'static KindSpec { mandatory_caps, ops, required_exports }, and checks the module against that spec. Adding a kind is one KindSpec const and one enum variant; the loader, the capability gates, the limits, the verification and HandlerRuntime are untouched. An unknown kind is refused at load naming the kinds this host speaks — the same by-name refusal shape as everything else here.

HandlerRuntime (backend.rs:33) stays exactly as it is and stays PackageHandler-only. MeterRuntime is its sibling, with the same two impls for the same reason:

pub trait MeterRuntime: Send + Sync {
    fn manifest(&self) -> &WireManifest2;
    fn meters(&self) -> Result<Vec<WireMeterDecl>, String>;
    fn read(&self, repository: &str) -> Result<Vec<WireMeterReading>, String>;
    fn capacity(&self) -> Result<WireCapacity, String>;
    fn selftest(&self) -> Result<WireSelftest, String>;
    fn evaluate(&self, ctx: &WireCallContext) -> Result<WireVerdict, String>;
    fn accept_fact(&self, signed: &[u8]) -> Result<WireFactSummary, String>;
}

LinkedMeter<M: Meter> and WasmMeter — native and wasm from one implementation, the property plugin/abi was built to have (abi/src/lib.rs:16-31) and that gunnar's build-thing proves with a test that runs both and compares (wasm_plugin.rs:1-8). The open guest §6.1 gets that test on day one.

5.3 Capacity and selftest — the open build's whole job

pub struct WireCapacity {
    pub total_bytes: u64,
    pub servable_bytes: u64,     // what holger can actually still write
    pub used_bytes: u64,
    pub source: ReadingValue,    // Refused when the backend cannot say
}

pub struct WireSelftest {
    pub name: String,
    pub passed: bool,
    /// What was measured and what was expected, in words, for an appliance with
    /// no shell: this is the only way an operator sees the check ran.
    pub detail: String,
}

5.4 Where the kinds are gated

plugin/host features: wasm-plugins (the loader, unchanged), verify-modules (unchanged), meter-guests (new — the Meter kind's op table, wire types and MeterRuntime). With meter-guests off the binary contains no meter vocabulary at all (§3.3 point 2).


6. DESIGNED — the two artifacts, named

6.1 OPEN: holger-plugin-allocation

6.2 CLOSED: vetra/monetize-plugin

6.3 What is NOT one of these two

monetize/products/holger (impl Product for HolgerProduct, 883 lines) is monetize's plugin for holger, not holger's plugin for monetize. It stays where it is, native, in monetize's tree, talking to holger's gRPC doors. Do not confuse the two; lane 5 §B.5 already tabulated them and that table is correct.


7. Reconciling with the metering doors written twice and merged never

Both branches are ONE commit each, written from different points on the same line, an hour apart. MEASURED: monetize-doors f0ce959 = 22 files, +2355/-23, parent 4ccc645; feat/metering-doors f6ff7ec = 22 files, +2326/-27, parent 0b06503. They are the same approach written twice, not two designs: both append UsageService + MeterScope + a meter message to proto/holger.proto at the same place (after ServerProfileResponse, :412), both put the paid half in a separate proto file with its own package for the same stated reason, and both declare an EntitlementFact with byte-identical field names, types and numbers 1-7. They cannot both land: the holger-metered-* error numbers are declared frozen and disagree (metered-2 is CAP_EXCEEDED on one and CAP_UNPOLICEABLE on the other; metered-3 is CAP_EXCEEDED vs SIGNATURE_INVALID).

7.1 feat/metering-doors (f6ff7ec) wins

monetize-doors f0ce959 feat/metering-doors f6ff7ec
entitlement store WIRED no — entitlement::install() has zero call sites; no config field; the door is permanently failed_precondition yes — Holger.monetize_pubkey → parse → EntitlementStore::new → with_entitlements → add_service; a bad key is a boot error (holger-metered-4)
facts PERSISTED no — RwLock<BTreeMap<..>> process-global; a restart silently forgets every accepted fact yes — one JSON per repository under the data root, plus a sanitize() and a roster gate (holger-metered-5) so a plan cannot be written for a repository this box does not serve
audit on a plan change none AuditAction::Entitlement added with the from_wire round trip intact; logs the accept (200) and the signature refusal (403)
unpoliceable caps stores any cap; CapVerdict::check() exists and nothing calls it — a bytes_served cap is accepted, echoed back, never enforced refused at the sale: caps_holger_cannot_honour() + well_formed() → holger-metered-2 INVALID_ARGUMENT, before the signature is trusted
tests 8 unit tests on the store struct; the gRPC metered_doors module has no test 6 tests through the real handlers with a real Ed25519 key, incl. roster closure and nothing-written-on-forgery; the guard test has a negative control (the_guard_would_catch_a_leak)
reactor hygiene list + archive_info called synchronously inside an async handler one spawn_blocking for both, so the two readings share one measured_at
rebase onto 542e296 8 conflicted files (incl. build-space/src/lib.rs's hard-coded name vectors, which main has since changed) 6 conflicted files

Neither applies cleanly: main has moved 110 files / +14610 / -19118 since (the server/ui crate deleted, update-bundle reworked, the appliance/passkey recipe rows). Both conflict sets are the same mechanical shape — everything that appends a row to rows() and re-emits .nornir/build-variants.toml collides on the row count and the parity fixture. Neither branch's new files conflict at all; the protos, the modules, the generated Rust and the tests are pure adds.

7.2 Carry these four things over from f0ce959

  1. the source: string field on the meter message — naming where a number came from, and the natural place to say "this backend cannot enumerate". It is the wire ancestor of §4.6's ReadingValue::Refused, and f6ff7ec has no field for it;
  2. caps echoed on ReadUsageResponse, so a tenant can see its own ceiling;
  3. CapVerdict + METERED_CAP_EXCEEDED — the enforcement half f6ff7ec lacks entirely (it refuses an unpoliceable cap and then never polices a policeable one). This is the piece that becomes WireVerdict::Refuse behind a Gate (§9);
  4. 0 means NO CAP, never "may hold nothing", and the ed25519:<base64> / raw pubkey spellings (f6ff7ec accepts hex only).

And fix on the way in: f6ff7ec's mvp-server-metered row reuses the bin name holger-mvp, which trips build-space's path-collision guard (two variants of one delivery emitting one path). f0ce959 got that right with its own name (holger-mikro-metered).

7.3 Plugin INSTEAD of the doors, or BEHIND them? — BEHIND.

The doors and the plugin answer different questions and neither replaces the other:

That inversion is what buys the open/closed split, and it is small:

f6ff7ec as written behind the plugin
metering.rs inlines the counters and the meter-name consts metering.rs folds Vec<Arc<dyn MeterRuntime>>; the meter names come from each guest's meters()
metered.rs holds the key, verifies, persists, and is #[cfg(feature="metered")] the closed guest holds the key and verifies; metered.rs shrinks to a door that forwards bytes and writes the audit line
a cap is refused at the sale by a hard-coded PER_REPOSITORY_METERS list a cap is refused at the sale because the guest's own WireMeterDecl::enforcement says Measured
artifacts = unwrap_or(0) — a confident false zero on the four backends that cannot enumerate (znippy-go, znippy-composer, znippy-docker, znippy-nuget, none of which override RepositoryBackendTrait::list, traits/src/lib.rs:350-353) ReadingValue::Refused { reason } — there is no way to spell an uncounted zero (§8.1)

The false zero is unfixed on BOTH branches and is the first thing to fix, before or with the rebase: both do list(...).unwrap_or(0) and then stamp the result REPOSITORY scope, and f6ff7ec additionally lets a cap be sold against that meter. unwrap_or(0) also collapses a genuine backend error into 0, so "the disk broke" and "empty repository" are one answer on the wire. The proto needs the third state — f0ce959's source field, or an explicit unavailable flag, or Option<u64> — and it needs it in the message monetize already reads.

Order of work: (a) fix the false zero in the proto and in traits (a supports_listing()-shaped answer); (b) rebase f6ff7ec onto 542e296, with the four carry-overs from §7.2 and the bin-name fix; (c) ABI v2 and the open guest; (d) move the two handlers behind MeterRuntime; (e) the closed guest in vetra/monetize-plugin. Steps (a) and (b) are useful on their own and must not wait for (c).

8. Honest meters and the airgap

8.1 artifacts must be REFUSED where it cannot be counted

MEASURED, from monetize's own holger plugin (monetize/products/holger/src/lib.rs:44-60): four of holger's backends do not override RepositoryBackendTrait::list and therefore answer the trait default — an empty listing (holger/traits/src/lib.rs:352). A plugin that reported that would bill a full repository as empty. archive_* has the same trap: a backend with no archive answers the default ArchiveInfo — 0 files, 0 bytes (traits/src/lib.rs:342).

ReadingValue::Refused { reason } (§4.6) is the ABI-level answer: there is no way to spell a zero you did not count. The open guest emits Refused { reason: "<format> cannot enumerate" }, monetize's fold already treats an absent reading as a hole rather than a zero (FleetTotal::tenants_without_reading), and artifact_bytes — the one meter every backend can answer from ArtifactEntry.size_bytes — is the meter the business is actually built on.

8.2 A cap on an unpoliceable meter is refused AT THE SALE

Not at the push, not on the invoice, not in a log line: at the sale. The order is order → is this meter policeable? → signature → fact. The sequence matters because a refusal after the signature is money that moved for a promise nobody keeps. feat/metering-doors already carries METERED_CAP_UNPOLICEABLE in that order (lane 5 §B.3, §249) and that is a reason it wins (§7). On the plugin side the input is WireMeterDecl::enforcement: only Enforcement::Enforced may carry a cap, and the guest refuses a fact whose caps name a meter it declared Measured or Provisioned — before it installs the fact, so the box never holds a promise it cannot keep.

8.3 The airgap loop, both directions


9. What this needs from a Gate trait — and nothing more

holger has no Gate trait: policy is a struct PolicyGate configured by env vars (HOLGER_PROMOTE_ENFORCE, server/lib/src/promote/mod.rs:88) on a box with no shell, and Ok(None) means pass-through (lane 4 §1). gunnar has the seam — gunnar-policy/src/lib.rs pub trait Gate { fn name(&self) -> &str; fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict>; } (~:173), with the rule that "Returning Err means the gate could not decide, which PolicyEngine::evaluate treats as a refusal, never as an allow", plus GUN-007 (an unreadable gate config refuses everything).

Another lane owns that trait. This section only states its requirements, so whoever writes it knows what a metering plugin will ask of it:

  1. The context must carry the PRINCIPAL and the REPOSITORY, not just (ecosystem, name, version). A quota is per tenant; today's gate cannot see one.
  2. It must run on PUT, before the bytes, not only on promote. A cap enforced after the upload is a cap that already cost the disk.
  3. Err is a refusal, never an allow — gunnar's rule, copied, no variation.
  4. Config from a FILE, not env vars, and an unreadable config refuses everything (GUN-007). The capability grants of §4.3 live in that same file.
  5. A refusal must carry a code, a reason line and a URL to the client, so the refusal is the same shape as WireVerdict::Refuse and nothing is translated twice.
  6. A gate must be constructible from a plugin handle — WasmGate(Arc<dyn MeterRuntime>) implementing Gate by calling evaluate. That single impl is the whole join, and it is why the plugin is behind the gate rather than instead of it.

Until that trait exists, the plugin still ships useful: readings, capacity, selftest and fact acceptance need no gate at all. Only the cap needs one, and a cap without a gate is exactly the promise nobody keeps that §8.2 refuses at the sale.


10. What needs the OWNER

  1. Repo or crate for the closed half. vetra-automation/monetize-plugin as its own private repository, or a second crate inside monetize-impl? A separate repo keeps the holger-shaped code away from the Stripe-shaped code; one repo is one CI and one release.
  2. Does the appliance ship ONE row still? holger's single server+twin row (holger-recipe/src/variants.rs:264-268) is the thing that saves it from gunnar's un-metered-twin scar. Turning on wasm-plugins + meter-guests in that one row means the open appliance carries the host half (§3.3 point 3). Accept, or declare a second row and take gunnar's scar knowingly?
  3. Fuel and deadline for a Meter guest. A package handler's 1e9 fuel / 30 s deadline is sized for "one artifact's bytes". A meter's evaluate is on the push path and should be one or two orders of magnitude tighter (proposal: 10e6 fuel, 250 ms). Owner's call on whether a slow verdict refuses or allows — this section's position is refuses, per gunnar's Err-is-refusal rule.
  4. monetize-embed on wasm32-unknown-unknown is UNPROVEN here. It depends on ed25519-dalek and arc-swap — both should build for that target, and verify needs no RNG — but nobody has compiled it. If it does not, the closed guest carries its own verifier, which breaks the "holger writes no second canonical form" rule and must be escalated rather than quietly done. First code task of this lane: build monetize-embed for wasm32-unknown-unknown and report.
  5. The stale wasmtime comment. server/lib/Cargo.toml:458-461 still warns about wasmtime 27 and 17 advisories; deny.toml:32-38 says that acceptance is gone and the bump happened. Fixing the comment is a one-line commit, but it is the line that made lane 5 conclude "native seam", so it should be fixed on purpose and not as a drive-by.

11. ?18 re-answered, and THE INJECTION PATH

Added 2026-09-20 after the owner drew the line I had missed: "monetize kör väl på OS — du menar pluginen som kör i gunnar skulle vara en wasm. Där har du faktiskt rätt, gör wasmen." monetize-server is a process on an OS with Stripe and UpCloud behind its factories (§1.2); it was never the plugin. The plugin is the half that runs inside the appliance. §3–§6 above is the spec. And the deciding question is now this one: "kan gunnar uppdateras då, och hur? Utan att ISO:n körs om — måste injektas på något sätt." A plugin that can only arrive by re-imaging is not a plugin.

11.0 First, recovering what the cargo feature had and the module does not

A cargo feature's absence is provable in the symbol table — gunnar proves it twice (§1.1: crate gone from the resolved graph, symbol families gone from the binary). An uninstalled module's absence is only "a file that is not there", which is weaker. That must be recovered, and the capability grant is where, because an ungranted capability is not merely refused, it is not linkable (§4.3 gate B).

DESIGNED — server/lib/tests/open_appliance_loads_no_paid_module.rs, three assertions against a booted Holger, not against a build:

  1. Catalog census. Every entry in the handler and meter catalogs is named, and the set equals the linked set ∪ the modules whose digests appear in the import receipts. There is no anonymous module: a module with no receipt is a module nobody imported.
  2. Grant census. The effective grant table contains none of principal.read, digest.read, clock.read, audit.write. This is the strong one — it proves "and none can be", from configuration, without looking at a directory at all.
  3. Linker census. The set of host functions actually defined on this boot's wasmtime Linker equals the granted set. A module wanting host_audit_write fails instantiation because the import is not defined. This is the running-process analogue of gunnar's symbol-table proof, and it is arguably stronger: it is a property of this boot, not of this build.

For (1) and (2) to be readable at all, AdminService.ServerProfile must carry the module census (name, digest, kind, receipt id) and the granted capabilities per module. An operator on a shell-less appliance has no other way to ask "what is loaded here", and neither has the test.

11.1 (a) Can update-bundle carry a MODULE as a member?

Byte-wise yes today; semantically it is release-shaped, and the smallest honest change is two small things, not a new envelope.

MEASURED. PayloadEntry { relative_path, media_type, size_bytes, sha256, blake3 } (update-bundle/src/core.rs:72-79) is media-type-typed with a free-form path, so payload/monetize-plugin.wasm with media_type: "application/wasm" validates with no change to the format. BundleCore (core.rs:14-43) has no deny_unknown_fields, so a field added with #[serde(default)] is readable by an older verifier — the same forward-tolerance rule §4.2 gives the plugin manifest.

But BundleCore's vocabulary is a vendor release carried through a gateway: supplier, product, release_version, supported_hardware_models, supported_hardware_revisions, and a SourceProvenance with source_uri, vendor_checksum, tls_origin (core.rs:46-70). A plugin is not a firmware release of a hardware model.

The house law refuses a seventh bespoke envelope (server/lib/Cargo.toml:162 names the six: KORPSIG1, holger.update-bundle/v1, …), and it is right to. So:

The module's detached CMS signature travels as a second payload member (payload/monetize-plugin.wasm.p7s, PayloadKind::PluginSignature) — it must be in the bundle, because the plugin host looks for it beside the module (plugin/host/src/verify.rs:50-57, SIGNATURE_EXT = "p7s", appended not substituted).

11.2 (b) Is there an admin verb that imports into a RUNNING appliance?

No. MEASURED ABSENCE, and it is a bigger gap than the plugin question.

And a second thing, which changes what "import" even means here: run_import resolves its destination as a raw repository's ron_data_dir (resolve_store, :185-206) and atomic_writes the bundle there (:295-297). holger is the gateway that RECEIVES and SERVES vendor bundles to others; it does not apply one to itself. There is no self-update path in this crate at all.

DESIGNED, and deliberately small: a new UpdateService on the admin door with two verbs, sharing run_import's core rather than re-implementing it —

service UpdateService {
  // Stream a .holger.znippy in; verify; admit; materialize. Same bytes, same
  // verifier, same lock, same receipt as `holger bundle import`.
  rpc ImportBundle(stream BundleChunk) returns (ImportBundleResponse);
  // What is installed, what was admitted, and at which sequence.
  rpc ListImports(ListImportsRequest) returns (ListImportsResponse);
}

Online and airgapped are the same path: online the bytes arrive over ImportBundle; airgapped the media is mounted and ImportBundle is fed from the local path by the console verb. Same bytes, same verification order, one implementation. The airgap posture is not a second code path — that is the whole reason to insist on it.

The exclusive lock becomes the honest constraint rather than a comment: ImportBundle takes the same lock, so a plugin import (which touches ron_plugin_dir and no served repository) can proceed on a running box, while a served-bundle import still refuses while the store is in use. That distinction is worth making in the lock's own name.

11.3 (c) Can a guest be replaced WITHOUT a restart?

Today: no. MEASURED. Holger::handler_catalog() (server/lib/src/lib.rs:2123-2144) is built once per config — its own comment: "Built ONCE per config, so the directory is read and each module compiled a single time however many repositories name a handler" — inside instantiate_backends() (:2145), before the first repository is wired. The resulting HandlerBackend holds its HandlerRuntime for the life of the process.

The house precedent is a DRAIN, not a restart: gunnar's growth never restarts — it drains and unmounts itself. Which shape applies depends on the kind, and the answer is different for the two:

kind swap why
Meter guest hot, DESIGNED — load + verify + instantiate the new guest beside the old, ArcSwap the handle, let in-flight evaluate/read calls finish on the old one, drop. arc-swap is already in the workspace (monetize-embed's hot path uses it). A meter holds no bytes, mounts no repository and owns no store: there is nothing to drain but a call in flight.
PackageHandler guest restart-only — keep v1's posture It owns a repository's serve path and its BlobStore. A half-swapped handler is a 404 over bytes that exist, which is precisely the failure registry.rs:12-17 was written to prevent. Draining a serve path is a different lane's problem.

So the monetize plugin — a Meter guest — is injectable and replaceable with no restart and no re-image, and that is the answer to the owner's question. A package handler still needs a restart, and saying so is better than promising a drain nobody has written.

11.4 (d) What does the ANTI-ROLLBACK floor do here?

MEASURED, and the coordinator's warning is confirmed exactly. RollbackStore::load returns AcceptedState::empty() when the directory does not exist (rollback.rs:126-131), and check_admissible compares only against highest_accepted_sequence() (rollback.rs:76-83) — so with an empty state every sequence is New, including an older, vulnerable one. The store root is <parent of ron_data_dir>/holger-update-state (secure_update.rs:185-206), i.e. in the data area. While injection meant re-imaging, that was a blemish. With an injection channel it is a prerequisite.

Does the plugin share the firmware floor or get its own? Its own — and it is free. The floor is keyed per (supplier, product) (rollback.rs:119-124), so (vetra, monetize-plugin) is a separate monotonic lane with no code change. That is not a convenience, it is correct: a plugin's version line and the firmware's are independent, and sharing one sequence would make a routine plugin update read as a firmware rollback (and vice versa) — SequenceRollback on a bundle that is not a downgrade of anything.

The empty-floor hole cannot be fixed by moving the floor. There is nowhere on an appliance to put it that an operator holding the disk cannot reach, and pretending otherwise would be the "stable, plausible and wrong" shape this tree keeps finding. The honest fix is to stop the emptiness being silent:

/// Today `load` conflates two very different states. They must not be one type.
pub enum Floor {
    /// A floor exists and these are its entries. Anti-rollback IS in force.
    InForce(AcceptedState),
    /// No floor has ever been recorded for this (supplier, product).
    /// Anti-rollback is NOT in force and every sequence would be admitted.
    Absent,
}

and then: an import against Floor::Absent either refuses unless the operator passes an explicit first-import flag, or it proceeds and writes an audit row naming that anti-rollback was not in force for this import. Today it does neither; it silently admits. That is one type in rollback.rs and it is the single highest-value item in this whole section.

Three independent brakes then stand between an operator and a re-installed vulnerable module, and it is worth being explicit that they are independent:

  1. the bundle chain — release signature, two-person rule, per-(supplier, product) sequence floor;
  2. the module chain — CMS over blake3 against the operator's roots, before compilation (plugin/host/src/verify.rs). ⚠ This one is NOT rollback-aware: a correctly signed OLD module passes it for ever. The floor is the only thing that stops a downgrade;
  3. the capability grant — an old module still needs its grants, and grants are operator config (§4.3).

11.5 (e) Does a paid module survive korp.reinstall with its default Keep?

MEASURED, and the answer reframes the question.

So, once anchored: yes, a paid module survives Keep — and it SHOULD, because the config that authorises it survives too. Keep is an image upgrade of the same box, not a wipe; a module that vanished while its grants remained would leave the box configured for a handler it no longer has, which handler_catalog() turns into a boot failure.

The real defect is a different one, and it is two defects that compose:

  1. There is no decommission verb. Nothing says "this box is no longer entitled" — no holger plugin forget, no entitlement revoke. The only eraser is korp.reinstall=wipe.
  2. wipe erases the anti-rollback floor along with everything else, silently — see (d). So the one act an operator reaches for to decommission a box is exactly the act that leaves the floor empty, and a wipe-then-import of an OLD paid module is admitted.

An operator who reinstalls to recover a box is served correctly today. An operator who reinstalls to resell or decommission one is not served at all: with Keep the entitlement ships with the hardware, and with wipe the rollback floor goes with it. Both need the owner (§11.7).

11.6 The injection path, as a numbered sequence an operator follows

  1. Author. The vendor prepares a bundle with supplier = "vetra", product = "monetize-plugin", sequence = N, carrying payload/monetize-plugin.wasm (PayloadKind::Plugin) and payload/monetize-plugin.wasm.p7s (PayloadKind::PluginSignature). Two distinct approvers sign the approval statements; a distinct release signer signs the release manifest.
  2. Deliver. Online: UpdateService.ImportBundle streams the .holger.znippy to the admin door. Airgapped: the media is mounted and the same verb is fed the local path. Same bytes, same verifier, one code path.
  3. Chain 1 — the bundle, in the order the code already runs it and none other: file-size cap (MAX_BUNDLE_FILE_BYTES, schema.rs) → control members only, bounded by MAX_CONTROL_TOTAL_BYTES → release signature checked against the trust policy → payloads sized against the now-signed manifest → only then decompressed (container.rs:219-230) → every member's sha256 AND blake3 re-checked, an unreferenced member refused, a missing member refused (manifest.rs:85-122) → approvals authorised by certificate fingerprint, never by the self-asserted role string (policy.rs:224-235) → two-person rule with distinct-certificate role coverage and a release signer distinct from every approver (verify.rs:300-345) → anti-rollback against the (vetra, monetize-plugin) lane (rollback.rs:64-90).
  4. Materialize. The two payload members are written into ron_plugin_dir through the staging-directory-then-rename path (api.rs:48-83) — no partial tree is ever visible. An ImportReceipt is written (receipt.rs) and an audit row recorded; a plugin import gets its own audit action rather than sharing Other, so it is greppable.
  5. Chain 2 — the module, independent of chain 1 and with a different key set: at load the host reads the module, hashes it with blake3, and verifies the detached CMS .p7s against the operator's DER roots before Module::new compiles a byte (plugin/host/src/verify.rs). A failure is refused by name (registry.rs:49-56 UntrustedModule), never a quietly smaller catalog. Two chains, two key sets, both must pass before code becomes code.
  6. Grant. Installing a module grants it nothing. The operator adds its capabilities to the grant file (§4.3); an ungranted capability is not merely refused, the import is not linkable and instantiation fails loudly.
  7. Activate. Meter guest: ArcSwap the handle, in-flight calls drain on the old one, no restart (§11.3). PackageHandler guest: restart.
  8. Verify from outside. AdminService.ServerProfile now reports the module census and the granted capabilities, so the operator — and §11.0's test — can read off a running box exactly what is loaded and what it may do.

No step re-images anything. Step 2 is the only new door, and step 1's format change is one defaulted enum field.

11.7 What needs the OWNER (added to §10)

  1. Does the appliance get an upload door at all? UpdateService.ImportBundle is the proposal, but today the only importer is a CLI verb on a box with no shell (§11.2). This is a gap in the Secure Update Gateway independent of plugins, and it may deserve its own lane.
  2. payload_kind on PayloadEntry — one defaulted enum field, or keep the bundle format literally untouched and distinguish a plugin bundle only by its (supplier, product)? The field buys a refusal by name; the alternative buys a format that has not changed at all.
  3. Floor::Absent must stop being silent (§11.4) — refuse without an explicit first-import flag, or admit with a loud audit row? This section's position: refuse, because the flag is one operator act with a receipt and the alternative is a downgrade nobody sees.
  4. Should korp.reinstall=wipe be allowed to erase the anti-rollback floor? It is the one piece of state whose whole purpose is to outlive the box's other state. Proposal: it is kept across wipe, or wipe announces its destruction by name.
  5. A decommission verb. There is none (§11.5). An entitlement that ships with resold hardware is discovered on somebody's invoice. holger plugin forget + an entitlement revoke, or an explicit "this box is decommissioned" act that clears facts and grants while keeping the rollback floor.
  6. Hot-swap for Meter guests: confirm. A module replaced under a running server with no restart is a capability worth having and a capability worth being sure about.