Stinky lane 5 — the suite, monetize, and the MVP line
Written 2026-09-20 against holger
main542e296, monetize84f0a8a, monetize-implb5ab0e9, gunnar1d0fd62, znippy/edda/modgunn as checked out. Every finding is tagged MEASURED (ran a command / read the line and it is so today), READ (a doc or comment says so; not re-proven here), or DESIGNED (proposed here, no code). No live machine touched, no credential read, no key value anywhere in this file.The owner's brief this lane answers, 2026-09-20, near verbatim: holger/gunnar/dwarves/mimir/modgunn can become a suite — standalone first, reuse where it helps; the MVP's LAST feature is holger in the cloud with repo A→B and then holger on-prem with repo C; what does not fit becomes holger Pro; monetize exists as an open-source resource-allocation plugin plus vetra/monetize-impl for the paid part (UpCloud + Stripe); and holger needs a plugin for monetize.
A. THE SUITE — where reuse is real and where it would be a coupling mistake
A.0 Vocabulary, fixed before anything else (LAW)
Two words are spelled "suite" and they are not the same word (workspace_nordisk/nornir-workspace.toml:48-59, MEASURED):
| word | meaning | used in this doc as |
| PRODUCT | a member of the nordisk software suite: gunnar · holger · dwarves · nornir · korp · modgunn · skade. A PRODUCT IS NEVER A LIBRARY. | "product" |
| library | znippy, znippy-zoomies, facett, Skidbladnir, draupnir, edda, knut — made for reuse, have build-things, have NO product | "library" |
| SUITE (flavour) | the suites = [...] list on one [[build.variant]] row: MVP or ORIGINAL. holger is the ONLY product with two. |
"MVP" / "ORIGINAL", never bare "suite" |
mimir is in the owner's list and NOT in the law's product list. MEASURED: find /home/rickard/git -maxdepth 3 -iname '*mimir*' returns no repo and no crate. Mímir is (a) 11 443 lines of compiled Rust INSIDE nornir — nornir/src/mimir.rs, mimir_analyze.rs, mimir_calibrate.rs, mimir_clones.rs, mimir_subclone.rs, mimir_slices.rs, mimir_dup.rs and 23 lens modules under nornir/src/mimir_lens/, exported at nornir/src/lib.rs:269-299, served by a gRPC service (nornir/Cargo.toml:692 MimirSvcTrait); (b) the 72-table Iceberg warehouse those read (nornir/.nornir/mimir-datastructure-manual.md:5,9,30-41); (c) ~25 design docs under workspace_nordisk/.nornir/mimir-* of which the trust lineage (mimir-agent-trust-lineage-design-2026-07-24.md:5 "No code shipped") and "mimir v2 the purgatorium" (workspace_nordisk/mimir-v2.md, code actually lives in edda/crates/nornir-purgatory) are ideas. Its consumer is dwarves (dwarves/crates/dwarves/Cargo.toml:291 mimir = ["coverage"], GUI at dwarves-gui/Cargo.toml:539).
Honest answer: mimir today is a CAPABILITY OF NORNIR — a subsystem plus an idea, not a product and not a library. If the owner wants it in the suite as a peer, it needs a repo, a crate name, a recipe and a row; today nornir is the product and mimir is what nornir's warehouse knows. This doc treats "mimir" as "nornir's code-intelligence warehouse" everywhere below.
A.1 The dependency graph as it IS (MEASURED, Cargo.toml lines)
No cycle among the seven products. The hub is edda/crates/* + libraries. Nothing depends on holger except dwarves, optionally; holger depends on no sibling PRODUCT except modgunn.
| edge | fact | where |
| holger → modgunn | default-features = false, features = ["scan"] — the CVE/licence verdict for PolicyGate; NOT provenance-verify |
holger/server/lib/Cargo.toml:137 (rationale :132-136) |
| holger → znippy-common | optional = true, only under sign; used for CMS verify of znippy archives before serve |
server/lib/Cargo.toml:181, repository.rs:512,555,583 |
| holger → Skidbladnir | optional, federation ⇒ full only; plus handler-skidbladnir plugin pair (in every tier) |
server/lib/Cargo.toml:208,429-432, plugin/handler-skidbladnir |
| holger → edda leaves | nornir-hash, jera, nornir-service, nornir-auth-policy, nornir-auth-webauthn (opt), korp-installer 0.1.7 in xtask | server/lib/Cargo.toml:152,217,254,270,282; xtask/Cargo.toml:60 |
| holger → skade | NONE — two comments about avoiding skade's arrow-59 fork | xtask/Cargo.toml:94, server/lib/Cargo.toml:192 |
| holger → nornir (product) | NONE | — |
| gunnar ↔ holger | NEITHER direction; prose only ("Mirrors holger server/cli/Cargo.toml:150") | gunnar/crates/gunnar-server/Cargo.toml:38 |
| dwarves → holger | optional holger = ["dep:holger-server-lib"], in-process artifact store; rehearse, embed-holger |
dwarves/crates/dwarves/Cargo.toml:356,411,536, src/artifact_store.rs:202-216 |
| modgunn → | skade (opt), edda leaves, Skidbladnir, znippy-common (opt); no product | modgunn/Cargo.toml:131,182,207 |
| nornir → | draupnir, Skidbladnir, ~20 edda leaves | nornir/Cargo.toml:561,615,590-668 |
A.2 The standards ARE in the house, and holger's use of them is HALF-TRUE, not none
The brief says "holger uses none of them". MEASURED, it is more nuanced and the nuance is the finding:
| standard | implemented | holger status |
| DSSE v1, PAE byte-exact | znippy/znippy-common/src/sign.rs:1190 dsse_pae, :1347 dsse_sign, :1375 dsse_verify, feature sign (znippy-common/Cargo.toml:44) |
REFERENCED AND UNWIRED. holger/server/lib/src/attest.rs (1458 lines, commit fa9f5e6 2026-09-19 23:56) delegates wholly to znippy_common::sign::dsse_verify (attest.rs:810-835) and holds AttestStore, Statement, AttestTrust, AttestPolicy. Consumers: security.rs:194,227,676, sbom.rs:45,67,179,183. Nothing on the promote path calls it (grep self.promotion = 4 hits, none a fetch or attest). |
| M-of-N threshold | sign.rs:1045 sign_multi, :1090 verify_threshold (refuses M=0; opt-out confined to :1109) |
Used by holger-verify (update-bundle) for the bundle path only. Not on promotion. |
| in-toto Statement v1 + SLSA Provenance v1 | edda/crates/nornir-build-thing/src/provenance.rs:297 to_slsa_statement, :361 canonical bytes, seams :421,428 |
attest.rs:95-116 declares the same _type/predicate constants. Not depended on: server/lib has NO nornir-build-thing dep (only holger-recipe/holger-build-thing do). |
| verifier, default ON | modgunn/src/provenance.rs:700 verify_sealed_provenance; modgunn/Cargo.toml:18 default = ["scan","provenance-verify"] |
holger pins modgunn with scan ONLY (:137) — so holger deliberately compiles OUT the verifier that is on by default for everyone else. The comment at modgunn/Cargo.toml:54-64 still says "OFF by default" and contradicts :18 (stale). |
The reuse trap nobody has measured: the DSSE/M-of-N API is unreachable from crates.io — local znippy-common 0.9.15 vs published 0.9.13 which lacks sign (holger/Cargo.toml:113 patches it in; znippy-common/Cargo.toml:3-6 calls the result "19 holger crates unpublishable fork riders"). Every "reuse znippy's DSSE" sentence in promote-sync-attest-design.md is therefore a sentence about a PATH dep, which is exactly the shape A.4 forbids for a standalone clone.
The seventh-envelope rule holds today. MEASURED the five bespoke containers: KORPSIG1 (edda/crates/korp-installer/src/rollback.rs:105), holger.update-bundle/v1 (holger/update-bundle/src/schema.rs:4, ed25519 over exact bytes), gunnar-sshsig-v2 (gunnar/crates/gunnar-grpc/src/auth.rs:164), monetize's in-object EntitlementFact.signature (monetize/crates/monetize-product/src/lib.rs:453), Skidbladnir .sig.json (Skidbladnir/src/archive.rs:428). attest.rs:26-40 inventories all five and adds none. The unmerged holger metering branches add a SIXTH VERIFIER of an existing envelope (monetize's, via monetize_embed::signing::verify_fact, holger-wt-metering/server/lib/src/metered.rs:110-112) — that is reuse, not a new envelope, and it is the right shape.
A.3 The reuse audit, pair by pair
Rule the owner set: standalone first. So every row says how the standalone case survives. The house answer is almost always a crate in edda (exists to avoid circular deps, nornir-workspace.toml:18), and almost never product→product.
holger ↔ gunnar — two content-addressed stores with the same organs and ZERO shared code
MEASURED: no Cargo edge either way (A.1). Both have: CAS (gunnar gunnar-store, gunnar-core/src/doc.rs:6; holger attest.rs digest-keyed + promote/mod.rs:576 blake3 blob tree), gates (gunnar gunnar-policy/src/lib.rs:173 trait Gate; holger PolicyGate concrete struct promote/mod.rs:66), attestation (gunnar gunnar-core/src/{provenance,key_attestation,transparency,seal}.rs; holger attest.rs), twin (gunnar gunnar-cli/src/control/twin.rs; holger one-medium holger-recipe/src/packaging.rs:258), metering (gunnar gunnar-core/src/entitlement.rs; holger two unmerged branches, B.4), console (gunnar gunnar-server/src/dashboard*; holger holger-front + holger-ui), a clock (gunnar gunnar-clock peer://; holger none, SystemTime).
| could be shared | how, standalone-safe | must NOT |
trait Gate { name, scope, check } -> Verdict, Err = refuse, all gates run, refusal names the gate, unreadable policy refuses everything (GUN-007) |
lift gunnar-policy's trait + engine (no git types) into edda/crates/nornir-gate; gunnar and holger both depend on the edda crate; each ships its own PushContext/PromotionContext |
holger depending on gunnar-policy (drags gix); copy-paste (the design doc says "copy this trait" — copying is the debt, lifting is the fix) |
EntitlementFact verify + the open-build-speaks-no-money guard |
already a published crate: monetize-embed (publish = true, monetize/crates/monetize-embed/Cargo.toml:19) — this IS the right shape and both consume it |
a holger-side canonical form (metered.rs says so: "holger writes no second canonical form") |
| signed time for an airgapped box | gunnar-clock peer:// (gunnar/crates/gunnar-clock/src/peer.rs:12-20,68) → move to edda as nornir-clock; holger's expiry checks (update-bundle/src/policy.rs:93-97) are claims about now and today trust the RTC |
holger inventing a second time protocol |
| audit log | holger's Arrow-IPC hash-chained log (server/lib/src/audit/arrow_ipc.rs) vs gunnar's gunnar-core/src/audit.rs — two append-only logs, two verifiers |
merging them now; but a shared query surface (C.4) should be designed once |
| twin | the RULE (twin = same build, same config) is shared; the mechanism is not | holger copying gunnar's keyless twin row — holger's one-medium answer (variants.rs:264-268) is the stronger one and gunnar should copy holger |
holger ↔ dwarves — dwarves builds, holger stores; today it is an env var and a path patch
MEASURED: dwarves/crates/dwarves/src/artifact_store.rs:202 DWARVES_HOLGER_DIR, configured() :214-216 returns None unless feature+env; it links holger-server-lib IN-PROCESS (Cargo.toml:355 "never a twin CAS"). There is no publish verb and no network hop — a forge on dwarf cannot push into a holger on another host; it can only write into a holger directory it can see. The dwarves→holger patch is flagged LOCAL-DEV-ONLY (dwarves/Cargo.toml:23-29).
| could be shared | how | must NOT |
provenance: dwarves emits SLSA v1 (nornir-build-thing/src/provenance.rs:297); holger's AttestStore accepts DSSE-wrapped in-toto — the producer and the consumer already speak the same statement and nobody has connected them |
AttestationService.Attach(repo, artifact, envelope) (DESIGNED in promote-sync-attest-design.md §3.4, not in proto/holger.proto); dwarves' DsseSealer seam (provenance.rs:428) is the hook |
dwarves linking holger-server-lib to attest (it already links it to store — that is the coupling to unwind, not extend) |
| the build declaration | nornir-build-thing (edda) — already shared, both consume it |
— |
| a holger gRPC client crate for dwarves to push over the wire | holger-client in holger, thin, published; dwarves depends on the registry version |
in-process linking as the only door |
holger ↔ modgunn — the one real product→product edge, and it is scoped right
MEASURED server/lib/Cargo.toml:137 features = ["scan"]. The verdict is content-addressed by (blob_sha256, policy_version, advisory_db_rev) (READ, promotion-design.md §4). Standalone survives because modgunn 0.0.6 is on crates.io (modgunn/Cargo.toml:3, no publish = false) and version = "0.0" is named beside the path. Stink: holger takes the scanner and refuses the verifier (provenance-verify off) while attest.rs re-implements verification over the same znippy DSSE — one house, two verifiers of one envelope. Either holger turns on provenance-verify and drops its own verify_envelope, or modgunn's verifier moves to edda and both call it.
holger ↔ nornir/skade — nothing today, and the one thing that belongs there is the audit QUERY
⟶ BUILT 2026-09-21 — see .nornir/HOLGER-PLAN.md §4 "the audit query seam — 2026-09-21", which carries the measured can/cannot table for AuditService.Query, the holger.audit.export.v1 layout, the AuditService.ListSegments door, the end-to-end reader proof, and the eight things a lakehouse still cannot ask. The ruling below stands and was implemented as written: holger grew NO query engine and NO edge to skade or nornir — it published the layout and a way to enumerate it, and a reader pulls.
MEASURED: no holger→skade edge; feature-complete-gaps.md:320-325 already rules "a servable who pulled X query belongs in skade". C.4 needs exactly that query. The standalone answer: holger writes Arrow IPC already (audit/arrow_ipc.rs) — skade reads Arrow IPC natively. So the query surface can be holger-audit-query over skade as an ORIGINAL/Pro capability, while MVP's C answer (C.4) needs only a fold over read_dir grouped by ident — no skade, no iceberg, no arrow-59 fork in an airgap binary.
holger ↔ mimir — an idea only
Nothing to share until mimir is a thing (A.0). The one honest link: mimir's vuln_findings are modgunn's, and holger's ScannerGate reads modgunn's advisory feed; if both read one warehouse the verdict is computed once. DESIGNED, far.
A.4 The constraint that kills lazy answers (MEASURED)
- Skidbladnir MUST be used by all nordisk services (law line 15). holger:
server/lib/Cargo.toml:208optional underfederation(⇒fullonly) + the handler plugin in every tier; service lifecycleholger service {add,…}via Skidbladnir systemd. So MVP'smvp-serverlinks Skidbladnir only through the handler plugin — the service-support half ridesnornir-service(edda,:254). Whether that satisfies "MUST be used by all services" is an owner call; it is at least not the federation crate. - A repo must build from a standalone clone.
holger-frontwas made a 3-dep leaf (holger-front/Cargo.toml:8-13) and the thing published today washolger-errcode0.1.2 (commitbb2b22e, 2026-09-20 01:52) becauseholger-frontnames four error rows that 0.1.1 lacked. ⚠bb2b22eis not an ancestor ofmain542e296— working treeerrcode/Cargo.toml:5still reads 0.1.1. The registry moved and the trunk did not. - The same test with the same answer for every "reuse gunnar" proposal: if the shared piece is not on crates.io under a version holger names, it is a path dep, and a path dep is a clone that does not build alone.
znippy-commonsignfails this test TODAY (A.2). Fixing it is adwarves crate-releaseof znippy-common 0.9.15, and it is the precondition for slice 7 of the design.
B. MONETIZE — two halves, and holger's plugin
B.1 The split as it really is (MEASURED)
OPEN — /home/rickard/git/monetize |
CLOSED — /home/rickard/git/monetize-impl |
|
| remote | ssh://git.gunnar.rs:2222/nordisk/monetize (NOT GitHub, though Cargo.toml:24 claims github.com/vetra-automation/monetize) |
git@github.com:vetra-automation/monetize-impl.git, single remote — confirmed never on gunnar.rs |
| last commit | 84f0a8a 2026-09-19 |
b5ab0e9 2026-09-19 |
| crates | monetize (core: orders, ledger, reserve→provision→capture), -pay, -cloud, -product, -proto, -embed, -server, -ui-server, -ui-web, products/gunnar, products/holger | monetize-pay-impl (klarna/stripe/swish), monetize-cloud-impl (upcloud/scaleway + kvm rehearsal estate), monetize-signup-impl, monetize-tax-impl, monetize-server-vetra (the binary) |
| published | ONLY monetize-product + monetize-embed (publish = true, crates/monetize-product/Cargo.toml:21, monetize-embed/Cargo.toml:19); rest publish = false |
nothing; depends on the open half by RELATIVE PATH ../monetize/crates/* (Cargo.toml:28-33) |
| money words | yes, everywhere — monetize IS the money engine (transaction.rs, invoice/, tax.rs). Stripe appears only in prose (monetize-pay/Cargo.toml:19) |
Stripe at crates/monetize-pay-impl/src/stripe/mod.rs (HMAC webhook :82, env names :220-223, live/test detection reconcile.rs:84-88), feature stripe (Cargo.toml:20) |
| UpCloud | trait CloudProvider (monetize-cloud/src/lib.rs:155), ApplianceGrow (monetize-product/src/lib.rs:908) |
crates/monetize-cloud-impl/src/upcloud/{mod,api,grow,price}.rs — grow.rs is the OUTSIDE actor of the data-set-growth flow (detach→resize→re-attach, journaled) |
| seams | PaymentProvider monetize-pay/src/lib.rs:274, SignupSink signup.rs:173, TaxReport monetize/src/tax.rs:474, Product monetize-product/src/lib.rs:598; registration = VendorFactory { name, config_envs, build: fn } (monetize-pay/src/factory.rs:74-84), no trait objects loaded dynamically, no wasm, no dylib |
implements all four via factory::register; configured-but-not-linked refused by name at boot (monetize-server/src/registry.rs:388-398) |
Correction to the brief's framing: the open half is NOT "a resource-allocation plugin with no money in it". The open half is the whole money engine minus the vendors; the product plugins (products/gunnar, products/holger) are the resource-allocation half and are what a product build links via monetize-embed. What carries no money is the product's open build, policed by open_build_speaks_no_money.rs in gunnar (gunnar/crates/gunnar-server/tests/) and copied to both holger branches. "unlimited" has no spelling in monetize (monetize-server/src/oversell.rs:38); on the open appliance it is the ABSENCE of an Allowance (gunnar/crates/gunnar-lfs/src/allowance.rs:64-65).
The live-charge fact, verified as far as a file lets you: reconcile.rs:84-88 key_mode() distinguishes live/test by key prefix at runtime; the vetra front's key is rk_live_, livemode true (memory, not re-read here). No key value is in either tree; the only key-shaped literal is the fake sk_test_not_a_real_key (stripe/tests.rs:40).
B.2 The gunnar scars, each verified
| scar | verdict | evidence |
-open answers Entitlement.Set Unimplemented ⇒ paid signups get no quota |
TRUE, by ABSENCE: the service is #[cfg(feature = "metered")], not stubbed (gunnar/crates/gunnar-grpc/src/service/mod.rs:37-41, 687-701); tonic answers UNIMPLEMENTED; monetize's gunnar plugin reads that as MeteredDoor::Absent (monetize/products/gunnar/src/lib.rs:839-846) |
the metered ISO is metered_twin() cloning the open installer row with metered_env(monetize_pubkey) (gunnar-recipe/src/recipe.rs:1163-1181, applied :733-735) |
--monetize-pubkey must reach the appliance |
TRUE: gunnar-common/src/cli.rs:507, deliberately not cfg-gated so an open build refuses it by name (:509-537); a metered build started without it logs "every namespace is Free" (gunnar-server/src/serve.rs:1071) |
boot param korp.env.GUNNAR_MONETIZE_PUBKEY (recipe.rs:1108) |
monetize principal must be enrolled (server admin) after re-bootstrap |
TRUE, documented on the monetize side only: monetize-impl/crates/monetize-signup-impl/src/appliance.rs:26-32; Entitlement.Set requires Access::Server/Admin (gunnar-grpc/src/service/entitlement.rs:165-167). Nothing in gunnar checks a principal named monetize — it is a grant, not a name |
|
| ONE unattested key per principal; the console takes it | TRUE in shape: gunnar-core/src/key_attestation.rs:147-155 — NobodyToAsk only when the target holds no key; after the first key a holder exists and every later key needs that holder's sshsig |
the consequence (paid customers cannot add their own SSH key) is a console defect, not a core one |
| the metered appliance's TWIN is un-metered | TRUE: gunnar-twin row gunnar-recipe/src/variants.rs:730, features [], is twin_installer(&open) — the keyless/open row (:725-728); gunnar/.nornir/twin-transaction-log.md:368,384,414 records "T1d entitlements back: UNRUN — the keyless medium refuses Entitlement.Set" |
promoted, it stops enforcing. holger's answer is already stronger: ONE ISO row for server and twin (holger-recipe/src/variants.rs:264-268, packaging.rs:258) — so holger can never have this scar unless someone adds a second row |
B.3 What holger already has toward monetize — two branches doing the same thing (MEASURED)
monetize/products/holger—monetize-product-holger,impl Product for HolgerProductover a vendoredholger.proto. Since 2026-09-18 it answerslist_tenants(=AdminService.ListRepositories; a tenant IS a repository, holger has no namespaces) andread_usage(four meters fromListArtifacts+ArchiveInfo), and REFUSES BY NAMEread_activity,can_absorb,servable,push_entitlement,metered_door,purge_tenant(products/holger/src/lib.rs:1-60,194-220,595-603). Only ONE meter is BACKED:artifact_bytes(test at:727-730).- holger branch
monetize-doors(/home/rickard/git/holger-wt-one,f0ce959, 2026-09-16 04:07):UsageService.Read+EntitlementService.Set/Get,holger-entitlement.proto,usage.rs+entitlement.rs, 22 files +2355. - holger branch
feat/metering-doors(/home/rickard/git/holger-wt-metering,f6ff7ec, 2026-09-16 03:06, ONE HOUR EARLIER):UsageService.ReadUsage+EntitlementService.Set,holger-metered.proto,metered.rs+metering.rs, 22 files +2326. Carries the sentence the brief quotes: a cap on a meter this server only measures server-wide is "Refused at the sale rather than stored, logged and then ignored" (metered.rs:19, codeMETERED_CAP_UNPOLICEABLE:250). - Neither is merged (
git merge-base --is-ancestorboth → not).mainhas NOmeteredfeature, no monetize dep, no usage RPC.grep -rn metered server/lib/Cargo.toml= one comment line (:163). The plugin'spush_entitlementtherefore refuses today for a true reason. - Both branches agree on the load-bearing decision —
MeterScope:artifactsandarchive_bytesare per repository;bytes_served,requests,proxy_upstream_hits,reposare SERVER-WIDE Prometheus counters and go out labelled so, never apportioned ("a number that is stable, plausible and wrong").
Stink: two 2 300-line branches with different proto package names and RPC names for the same two doors, four days old, both unmerged, and the design doc that would pick one does not exist. Whichever the owner merges, the other is a git branch -D; the longer both live the more each drifts from main's auth-passkey and front changes (both branches predate b8e13a0).
B.4 DESIGNED — what holger meters, and what it refuses to meter
| meter | honest unit | per repo? | policeable on the box? | airgapped? |
stored bytes (artifact_bytes / archive_bytes) |
bytes at rest, uncompressed, as ArchiveInfo reports |
yes | yes — a put that would cross the cap is refused before the write; deterministic, offline |
yes — the cap is a signed fact on disk, the check is local |
| artifacts (count) | entries as ListArtifacts enumerates |
yes, but 4 backends cannot enumerate (traits/src/lib.rs:352 default empty) — REFUSE the meter for those formats, never report 0 |
yes | yes |
| repositories | count of ron_repo_type rows |
server-wide | yes — refuse Admin.CreateRepository above the cap; but holger's repos are RON config, created at boot, not by RPC — so a cap here is a boot refusal, which bricks the box |
yes |
| downloads / bytes served / requests | Prometheus counters, no repo label |
no | no — served bytes can be replayed by any reader; and the cap would be on a number the box cannot attribute | — |
| promotions | AuditAction::Promote rows |
yes (repo on the row) |
yes, but pointless: promotion is the product's reason to exist; capping it sells a gateway that stops gating | — |
| gates evaluated | per verdict | yes | no — a gate is a verifier, its cost is the analyser's, outside holger (design §3) | — |
Rules that follow (DESIGNED, keep the branch's instinct):
- Sell only what the box can refuse.
artifact_bytesandartifactsare the two honest meters.reposis honest but its cap is a boot refusal — sell it only as a plan shape (which repos the fact names), never as a runtime cap. Server-wide meters areMeasured(shown, tested, never capped) —METERED_CAP_UNPOLICEABLEat the sale, as the branch does. - Airgap = a signed fact with an
paid_until, and the box keeps counting. A holger inside a wire cannot phone home, so the plugin cannot pull usage. The fact travels IN with the bundle (holger.update-bundle/v1already carriespayloads+evidence; anEntitlementFactis one more evidence member), and usage travels OUT in the audit segments the courier carries back (ArrowIpcAuditLog::read_dir). Pastpaid_untilthe box does what 6b of the design already proposes for expired metadata: read-only, never brick — config field, no default. A meter that cannot be read by the seller for a quarter is still a meter; the invoice is late, not wrong. - The open build shows it and tests it.
UsageService.Readis mounted in every build (both branches agree); the guardopen_build_speaks_no_money.rsproves no plan vocabulary is in the open string table.
B.5 DESIGNED — the holger plugin for monetize, concretely
There are two things that could be called "holger's monetize plugin" and only one of them is holger's:
| what | where it lives | ABI | |
| (a) the PRODUCT plugin | impl Product for HolgerProduct — monetize's side, reads holger's RPCs |
exists: monetize/products/holger |
tonic client over the vendored proto; VendorFactory-style fn registration; native, no wasm |
| (b) the metered DOORS | UsageService (open) + EntitlementService (metered only), verifying monetize's Ed25519 fact via monetize-embed |
exists twice, unmerged (B.3) | gRPC services on HolgerGrpc; metered cargo feature; the paid vocabulary in its own proto package so it never enters the open string table |
It must be a native seam, not a wasm plugin — three measured reasons.
wasm-pluginsis OFF in every tier of both suites (server/lib/Cargo.toml:448-462; wasmtime 27, 17 advisories, 4 sandbox escapes). Holger's plugin ABI (plugin/abi/src/lib.rs:152) is a package handler ABI — format logic over host-owned bytes. It has no host imports for "refuse this put", "read this counter", "verify this signature against this key". Adding them is a new ABI, not a plugin.- monetize itself has no dynamic loading anywhere — "no dylib, no wasm, no libloading" (
monetize-pay/src/factory.rs:20-22) — and its registration contract is three fields and a function pointer. The gunnar plugin is native. A wasm holger plugin would be the odd one out in its own family. - The metered half must be ABSENT from the open build, not disabled — gunnar's rule, adopted by both holger branches. A cargo feature achieves absence; a wasm file present-or-not on disk does not (the host would still carry the vocabulary to load it).
Concrete proposal (patch sketch, DESIGNED):
holger/server/lib/Cargo.toml
[features] metered = ["dep:monetize-embed", "dep:monetize-product"] # registry deps, both published
monetize-embed = { version = "0.x", optional = true } # NOT path — standalone clone rule (A.4)
monetize-product = { version = "0.x", optional = true }
proto/holger.proto UsageService.Read(repository) -> readings[{meter, value, scope, source}] # every build
proto/holger-metered.proto EntitlementService.{Set,Get,List} # package holger.metered.v1, `metered` only
holger-recipe/src/variants.rs mvp-server-metered = mvp-server row + "metered" feature, SAME argv otherwise (gunnar's metered_twin shape)
holger-appliance-iso stays ONE row (server + twin) — the metered twin is the metered build by construction
xtask installer --monetize-pubkey <hex|path> → boot param korp.env.HOLGER_MONETIZE_PUBKEY; open build refuses the flag BY NAME
server/lib/tests/open_build_speaks_no_money.rs three guards (clap surface, string table, live output) — already written on both branches
Pick ONE branch (recommendation: feat/metering-doors — its Set order shape→policeable→signature is the one that carries the refusal-at-the-sale, and its proto package is named for the product state not the RPC), rebase it over 542e296, and land it as slice 0 of Pro (C.2) — it is Pro, not MVP, because MVP is the airgap posture and the fact-in-bundle path (B.4 rule 2) is what makes it airgap-safe, and that path is not written on either branch.
C. THE MVP LINE
C.1 What is settled (READ, do not re-argue)
- MVP = holger SLIMMED:
mvp-server = ["mini","secure_update"],mini = ["auth-bearer","auth-mtls"]; rowsmvp-server,mvp-server-musl,agent,mvp-bundle,mvp-verify,holger-mvp-appliance,holger-installer,holger-appliance-iso(holger-recipe/src/variants.rs:172-287;cargo xtask build --suite MVP --list= 8, re-measured 2026-09-20 in SUITES.md). - Ruling 2026-09-19, "yes include in holger mvp" (
SUITES.md:22): the WHOLE promote→attest→sync→verify chain, gate orchestration included. The argument that orchestration was ORIGINAL-shaped was put and overruled. Recorded. - Ruling 2026-09-20 (this brief): the LAST MVP feature is holger in the cloud with repo A→B, then holger on-prem with repo C. Everything that does not fit is holger Pro.
C.2 ONE TABLE — every capability → MVP / Pro / neither
MVP's virtue is slimness. A row is MVP only if the last feature (A→B in the cloud, C on-prem receiving B) does not work without it. "Compiled in today" is not an argument — 17 backends and the marketplace are compiled into holger-mvp today and most of them should not be.
| capability | today (MEASURED) | line | reason |
| raw/generic repository | non-optional, both | MVP | Henrik's MVP needs only the raw door (SUITES.md:241-244); A, B and C can all be raw |
| rust (crates.io-shaped) repository | non-optional | MVP | the house's own artifacts; ClosureGate is computable only here (design §6c) |
| maven3, pip, npm, nuget, go, gem, deb, rpm, helm, docker/oci, conda, composer, group | non-optional, in holger-mvp |
Pro | zero of them are needed for A→B→C; each is attack surface on a zero-egress node; making them optional is a server-lib change SUITES.md already names as "real hardening" |
| bearer + mTLS auth | mini |
MVP | the only identity the chain needs (design §6a) |
passkey (auth-passkey) |
named on mvp rows since 2026-09-20 | MVP | the one login an airgap can have; pure Rust p256 |
| OIDC, OAuth2 | full only |
Pro | need an IdP; an IdP is outside the wire |
| RBAC (Reader<Writer<Admin) | always compiled, config-gated | MVP | who may promote into B / read A is the gate's identity model |
| audit log (append-only, hash-chained) | always compiled | MVP | it is the ONLY record of "what C has" (C.4); without it the last feature has no proof |
| audit QUERY surface | ABSENT (no RPC, no route; feature-complete-gaps.md:320-325) |
MVP | the last feature's UI question "what did I send C" is a fold over AuditEvent{ident, action: Download, repo, artifact, status, bytes} — 60 lines over read_dir, not skade |
promotion (seal, blake3 CAS, exclusive ref) |
always compiled, ungated CLI verb | MVP | it IS A→B |
stage ladder + withdraw |
wired via RON promotion: (lib.rs:1275) |
MVP | A→B is the ladder with two rungs |
to_repo resolved to a REAL repository (served) |
ABSENT: sanitized only; fetch has no non-test caller; promotion to ../../pwn succeeds (promote/mod.rs:812-826) |
MVP | B must be something C can pull. A promoted store nothing serves is not a repository B |
Gate trait + PolicyEngine (all gates run, Err = refuse, GUN-007) |
ABSENT as a seam (PolicyGate concrete, promote/mod.rs:66) |
MVP | ruled 2026-09-19; MVP's slim version = the compiled-in verifier gates only |
ScannerGate (modgunn CVE/licence) |
PolicyGate::from_env, fail-closed |
MVP | already there, already scan-only |
AttestationGate + DigestGate + AttestationService.Attach |
attest.rs present, unwired; no RPC |
MVP | "verify, never execute" is the whole airgap argument; this is the smallest gate that makes B trustworthy |
ClosureGate, SetSignerGate, PromoteSet (N artifacts atomic) |
ABSENT | Pro | valuable, but A→B of one artifact at a time proves the line; a set verb can ship later without changing the wire of the single one |
| per-verdict DSSE statements + SLSA VSA | ABSENT (VSA absent everywhere in the house) | MVP (VSA only) | C verifies ONE object; the per-verdict N×M statements are Pro (design §6c storage cost) |
holger.repo-metadata/v1 (freshness, sequence, thresholds over update-bundle) |
update-bundle has all four primitives; not applied to repos | MVP | it is what makes "C has B" a checkable claim rather than a directory listing |
bundle import / verify (secure_update, holger-bundle, holger-verify) |
MVP today | MVP | MVP's transport is the carried bundle — this is C's intake |
holger-remote upstream type (C pulls B) |
ABSENT: 0 hits; ReplicationService.Replicate same-process only |
OPEN — C.3 | this row is where the owner decides what MVP means |
replication (replicate_pairs, same process) |
always compiled | MVP | it is the verb §5 unifies promotion into; dry-run default |
nexus/artifactory/azure/aws remotes (federation) |
full only |
Pro | upstream harbours are the cloud's business, not the gateway's |
rust-remote (crates.io pull-through) |
needs online |
Pro | egress |
| ProxyBackend cache | code in both, dead without online |
Pro | egress |
holger-agent (offline courier, own store) |
MVP + ORIGINAL | MVP | the medium between B and C when there is no wire |
holger-agent-online, emit-rsync/rclone |
ORIGINAL | Pro | egress |
| SBOM attach/fetch | always compiled | MVP | the CycloneDX predicate is one of the three the design requires |
| properties (+ quarantine predicate) | always compiled | MVP | PropertyGate is the existing require_property; quarantine is a fetch-boundary refusal C wants |
| search | always compiled | Pro | nobody searches a gateway; C's operator lists |
| retention/GC | always compiled | Pro | a sealed promoted store is immutable by design; GC is a full-registry concern |
| checksum-deploy headers | always compiled | MVP | ingest into A must bind bytes to a digest the caller claims — cheap, already there |
metrics /metrics |
always compiled | MVP | operators of C need liveness; it is also the usage meters' source |
| marketplace fulfilment (Azure, Entra sign-in, webhook) | pub mod marketplace UNCONDITIONAL (lib.rs:68), doors need online |
Pro — and a #[cfg] |
an appliance behind a wire compiling Azure Marketplace fulfilment is the exact opposite of slim; today it is in holger-mvp |
front public page |
full + server-local only |
Pro | already out of MVP; correct |
| appliance / installer / ISO / one-medium twin / signed boot | MVP only | MVP | it is how C exists on-prem |
embedded tier (in-process lib) |
ORIGINAL | Pro | dwarves/nornir consumers, not the gateway |
| wasm plugins | OFF everywhere | neither | blocked on wasmtime; and gates will never be wasm (design §3) |
| linked handler plugins (bundle-core, rust-toolchain, skidbladnir) | every tier | MVP | skidbladnir bundles are a real intake format; the other two are tiny |
UsageService.Read (open meters) |
two unmerged branches | Pro (slice 0) | the open build should show storage (law), but the last feature does not need it |
EntitlementService + metered feature |
two unmerged branches | Pro | money is Pro by definition; the fact-in-bundle airgap path is unwritten |
signed time (peer://) |
ABSENT in holger | Pro, flagged | every expiry check in MVP trusts the RTC (design §6b.4); an owner may rule this MVP once C is real |
| three UIs (egui desktop, web console, BFF) | other repos | MVP: ui-mini only | the last feature needs the UI to say sent (C.4); the console and BFF are cloud-side, Pro |
| mannequin, bench, testmatrix | dev surfaces | neither | not shipped |
| HA/clustering, S3 backend, multi-tenancy, webhooks/event bus, cosign/Rekor hosting, long-tail formats | ❌ in feature-complete-gaps.md |
Pro / neither | none touches the line |
Headline: MVP = raw + rust, bearer/mTLS/passkey, RBAC, audit + QUERY, promote into a REAL served B with verifier gates and a VSA, signed repo metadata, bundle import, agent, appliance. Fifteen format backends, the marketplace, federation, OIDC, search, retention, metering, the web console are Pro. Three of MVP's rows are ABSENT today (served B, gate seam, audit query) and one is an open decision.
C.3 THE OPEN TENSION — do not read past this row
MEASURED: build-space/tests/auth_ship_small.rs:86-136 walks the REAL resolved tree (cargo tree -e normal, invoked via Command::new(env!("CARGO")) — a test process, and the one place holger's own law about shelling is bent) and fails if mini, embedded or holger-verify link reqwest/ureq/curl, with the non-vacuous anchor that full MUST. :293 proves the MVP appliance row links the passkey door and no HTTP client. The guard skips silently when cargo is absent (:117) — a cargo-less CI passes it vacuously.
And the last feature says C, on-prem, receives B. There is no holger-remote upstream type (0 hits), and the two ways C can receive are:
| option | what MVP means afterwards | consequence |
(1) C's fetch is NOT an MVP posture. C receives B as a carried bundle (holger-bundle on the cloud side, holger-verify + bundle import on C). "holger on-prem with repo C" = a holger whose C is filled by import, and the audit row that proves "what C has" is written by the courier's import, not by a fetch B served. |
zero-egress guarantee stays exactly what it says: no HTTP client in the binary. | The upstream does NOT know what C has (it never served a fetch) — C.4's ruling "the upstream knows because it served the fetch" is FALSE under (1); the truth lives in C's audit log and travels back on media. The UI on the cloud side can only say bundled, not sent. |
(2) C PULLS from B over the wire. A holger-remote backend (a RepositoryBackendTrait impl speaking holger's own gRPC, mTLS, per-credential) so replicate_pairs((remote B, local C)) is the sync — design §5's unification. |
zero-egress means something NARROWER than it says: "no outbound HTTP to the internet"; the binary links a gRPC client (tonic already links hyper — the guard's forbidden list is reqwest/ureq/curl, and tonic's client half is not on it, so the guard would stay green while the posture changed). |
The upstream DOES know what C has: AuditEvent{ident: C, action: Download, repo: B, ...} grouped by ident. The UI can say sent. But a C that reaches upstream is a C with a route out of the wire, and the customer buying "airgap" is buying (1). |
Both are coherent. They are different products. (1) is the Secure Update Gateway as sold; (2) is a mirror. The design doc chose (1) in words (§6a.2 "MVP transport is the carried bundle") and (2) in architecture (§5 "sync down, two holgers — the same function"). The brief's own C.4 ruling ("the upstream knows because IT SERVED THE FETCH") presumes (2). This is the owner's decision and this doc does not make it. What it does say: under (2) the egress guard must be rewritten to name tonic's client half explicitly as allowed to one configured peer or it is measuring the wrong thing; under (1) the "what C has" surface is C's audit log carried back, and the cloud UI must say bundled.
C.4 What MVP is MISSING to reach the last feature (MEASURED, each)
- No
holger-remoteupstream.repository.rs:281-511dispatches 21 string kinds; none is a holger.ReplicationService.Replicateis same-process (replication.rs:168). Needed under (2) only; under (1) the courier is the remote. PromotionEngine::fetchhas no non-test caller (promote/mod.rs:699; callers at:776…:1437, all#[cfg(test)]). The promoted store is write-only in production. Slice 3 of the design (makeHolger::fetchconsult it) is the smallest fix; §5's real fix (B is a real backend, the seal becomes aWriteOncewrapper onput) is the right one.to_repois sanitized, never resolved (promote/mod.rs:294,537-545;grpc/services.rs:920uses it only as ACL scope). A promotion into a repository that does not exist SUCCEEDS — the testcrafted_to_repo_cannot_escape_prod_root(:812-826) asserts exactly that for"../../../../outside/pwn". Only the optional ladder refuses an undeclared rung (stages.rs:235-243, boot checklib.rs:520+); with nopromotion:block "any hop is permitted" (lib.rs:1280). Fix:sealtakes&Repository, not&str; the gRPC handler resolves throughFastRoutesand answersNOT_FOUNDby name.- The audit log has NO query surface.
trait AuditLogisrecord+flush(audit/mod.rs:212-218); reads exist only offline (arrow_ipc.rs:299,377,539);proto/holger.protohas no Audit service; no/-/auditroute. "What does C have" — under (2) — isAuditEvent{ts_nanos, ident, action: Download, repo, artifact, status, bytes}grouped byident, filteredstatus == 200, overread_dir. Under (1) it is the same fold over C's own segments after the courier brings them back. Either way:AuditService.Query(ident?, repo?, action?, since)→ rows, ADMIN-gated, MVP. The UI must say sent (aDownloadrow with status 200 and the byte count equal to the artifact's size), never accepted: accepted is C's verify verdict, which the upstream does not have under either option, and the two differ exactly when C refused what B served. - The gate seam is a struct (
PolicyGate,promote/mod.rs:66,122) — slice 4. attest.rsis unwired to promote — noAttestationService, noAttestationGate— slice 7/8. And its DSSE verify needsznippy-common0.9.15 published first (A.2) or the MVP clone does not build alone.- MVP has no signed time; every
expires_atcheck isSystemTime. Flagged, not blocking (design §6b.4).
C.5 Sequence (DESIGNED — the order that keeps every step landable)
- Owner rules C.3 (1) or (2). Nothing below changes except step 5.
- Publish
znippy-common0.9.15 viadwarves crate-release; drop the patch atholger/Cargo.toml:113. (Standalone clone rule.) seal(&Repository, …); promotion into an undeclared repo isNOT_FOUND; flip the:812test's expectation. (C.4.3)Holger::fetchserves the promoted store (slice 3); then B becomes a real backend with aWriteOncewrapper (§5). (C.4.2)AuditService.Queryoverread_dir, grouped byident. (C.4.4)- Under (2):
holger-remotebackend + rewrite the egress guard to name the one allowed peer client. Under (1): nothing —bundle importwrites the audit row; the cloud UI word is bundled. trait Gatelifted toedda/crates/nornir-gate; holger'sScannerGate,DigestGate,PropertyGate,AttestationGate. (slices 4, 7, 8; VSA only)holger.repo-metadata/v1over update-bundle. (slice 9)- Make the 15 non-MVP backends and the marketplace
#[cfg]-optional;mvp-servernamesraw+rust. Measure the binary before and after. - Pro slice 0: merge ONE of the two metering branches (B.5).
D. The five worst findings, ranked
- A promotion to a repository that does not exist succeeds — and a test asserts it (
promote/mod.rs:812-826);to_repois a directory name, not a repository, and the promoted store has no reader outside tests (:699). The product's core verb writes into a side tree nothing serves. MEASURED. - The last MVP feature is undefined until C.3 is ruled, and the house's own documents have already taken both sides (design §5 vs §6a.2; the brief's "upstream served the fetch" presumes pull). Building before ruling builds the wrong product with a green egress guard. MEASURED + READ.
- "What does C have" has no surface: the audit log is write-only over the wire (
audit/mod.rs:212), no Audit RPC, no route. Under either C.3 option the answer is a fold nobody has written. MEASURED. - Two unmerged 2 300-line branches implement holger's monetize doors twice, one hour apart, different proto packages and RPC names (
f0ce959vsf6ff7ec), andmainhas neither — somonetize-product-holgerrefusespush_entitlementfor a true reason. MEASURED. - The MVP binary is not slim: 17 format backends non-optional and
pub mod marketplaceunconditional (lib.rs:68) — Azure Marketplace fulfilment compiles into an airgap appliance; and holger refuses modgunn's default-on verifier (scanonly) while re-implementing DSSE verification over aznippy-commonthat crates.io does not have (0.9.13 lackssign). MEASURED.
Runner-up: holger-errcode 0.1.2 was published today from a commit (bb2b22e) that is not on main; the working tree says 0.1.1. The registry moved and the trunk did not.