04 — Code structure: traits and WASM plugins
Stinky lane 4, 2026-09-20, against main = 542e296. Sibling sections: 01 dataplane, 02 twin, 03, 05.
The owner's brief: "kodstrukturen på holger — den skall vara trait-baserad, och det finns wasm plugins." So the question this section answers is not "is the code nice". It is: where are holger's seams, are they traits, and can a third party extend holger without forking it?
Every claim is tagged. MEASURED = a number or a line produced by a command over the tree at 542e296 (the command is quoted where it is not obvious). READ = what the source or a commit says about itself. DESIGNED = my proposal; nothing under DESIGNED exists in the tree.
The constraint that shapes every recommendation: holger ships as an appliance with no OS and no shell, and "MVP is holger SLIMMED DOWN" — it is the only product with two suites (MVP 7 rows, ORIGINAL 6, MEASURED in .nornir/build-variants.toml). A refactor that adds surface to the MVP binary is a bad recommendation unless it removes more than it adds. Section 9 files every proposal under MVP or Pro.
1. The seam inventory
One row per place where holger could be extended. "Trait" means an object-safe or generic trait a second party could implement; "shape" means N copies of one struct/enum layout with a match.
| seam | trait or not | where (file:line) | impls (prod / test) | verdict |
| repository format (the backend) | trait RepositoryBackendTrait, 12 methods, 6 defaulted |
traits/src/lib.rs:323 |
21 prod / ~22 test | A real seam — but wiring is a 20-arm string match in the core (server/lib/src/repository.rs:281), and all 17 format crates are unconditional path deps of server/lib (Cargo.toml:42-77). A new format touches the core twice. |
| package handler (wasm-able) | trait PackageHandler + BlobStore |
plugin/abi/src/lib.rs:152, :129 |
2 linked (skidbladnir, rust-toolchain) / 1 probe | The only seam a third party can reach without a rebuild — and it is OFF in every tier (wasm-plugins), and it carries only 6 of the backend trait's 12 verbs. See §4. |
| handler execution (linked vs loaded) | trait HandlerRuntime |
plugin/host/src/backend.rs:33 |
2 (LinkedHandler, WasmHandler) |
Correct: the router cannot tell them apart (HandlerBackend is the one RepositoryBackendTrait impl). Good seam. |
| handler → repo/store placement | trait Mounts |
plugin/host/src/registry.rs:148 |
1 (FileStemMounts) |
One impl. A seam that is not (yet) a seam; harmless, small. |
| storage / pool | not a trait — a stub | server/lib/src/storage/mod.rs:24-34 |
0 | StorageEndpoint { ron_storage_type: "znippy"|"rocksdb", ron_path } with backend_from_config() = Ok(()). The file says so itself: "this is still a stub — opens no store". Every backend owns its own bytes (archive path / data dir) — there is no storage seam at all. |
| remote upstream (pull-through) | trait RemoteUpstream: RepositoryBackendTrait |
traits/src/lib.rs:487 |
2 (SkidbladnirUpstream [federation], RustRegistryUpstream [online]) |
A trait, but selection is again string arms in the same match ("rust-remote", "nexus-remote" | "artifactory-remote" | …). The ecosystem→URL mapping is a hard match in upstream_path (traits:511). |
| upstream digest attestation | trait AdvertisedDigest |
server/lib/src/proxy.rs:92 |
1 prod | One impl; fine — it exists so the proxy can refuse bytes that contradict the index. Keep. |
| authentication methods | trait nornir_service::AuthProvider (edda) |
edda/crates/nornir-service/src/lib.rs:181 |
bearer, oidc, oauth2, mtls, passkey (edda crates) | Real seam, shared house-wide. But holger's own side is a closed enum AuthMethodConfig with a match per feature (server/lib/src/auth.rs:46-52, 81-87) — a sixth method is an edda PR and a holger PR. Acceptable for MVP (bearer+mTLS only). |
| authorisation / RBAC | not a trait — functions over AuthConfig |
server/lib/src/auth.rs, exposed/http.rs:1811 authorize_http_write |
— | Fine as a closed policy; nobody asked to plug an authorizer. |
| gates / promotion policy | not a trait — struct PolicyGate { scanner: OsvScanner, enforcement } |
server/lib/src/promote/mod.rs:66, gate_check :522 |
1 | The seam holger lacks. One hard-wired scanner, configured from env vars (HOLGER_PROMOTE_ENFORCE, :88), judged per artifact by (ecosystem, name, version) — no bytes. Ok(None) when unset = pass-through. See §3. |
| promotion stages | struct StageLadder / Stage { gated } |
server/lib/src/promote/stages.rs:169 |
— | Data, not a plugin point. Correct: a ladder is config, not code. Stage::gated is the hook the future gate trait would hang from. |
| replication peers | not a trait — RepoPair { source: Arc<dyn Backend>, dest: … } |
server/lib/src/replication.rs:156 |
— | Replication is a pure function over two backends. That is the right seam: any backend replicates. A remote peer = a RemoteUpstream. No trait needed. |
| audit sink | trait AuditLog (record, flush) |
server/lib/src/audit/mod.rs:212 |
3 (arrow-ipc, memory, noop) | Real seam, chosen by config. Good. |
| attestation store | struct AttestStore (filesystem) |
server/lib/src/attest.rs:578 |
1 | Not a trait. Digest-keyed, DSSE-verified — the content is right. The store being a concrete filesystem struct is fine for an appliance; it does not need to be pluggable. |
| metering / usage plane | absent on main; on feat/metering-doors (f6ff7ec) it is a gRPC service pair, no trait |
server/lib/src/metering.rs, metered.rs (branch only) |
0 on main | See §4.4: the only way to meter today is a gRPC client outside the box. A plugin cannot read a counter. |
| transport facade | trait HolgerObject (11 async verbs) |
traits/src/lib.rs:717 |
2 (LocalHolger, RemoteHolger) |
Legitimate two-impl seam. |
| external connectors (agent side) | trait ConnectorTrait |
traits/src/lib.rs:566 |
3 + 1 fake | Real seam. |
| marketplace fulfilment API | trait FulfilmentApi |
server/lib/src/marketplace/fulfilment.rs:212 |
1 + 1 fake | Fine — but the whole marketplace module is compiled unconditionally (§6). |
MEASURED: 23 trait definitions in the tree outside generated code (grep -rn 'pub trait'); 8 of them are tonic-generated service traits in generated/holger.v1.rs, 15 are holger's own.
The short reading. holger has one first-class seam — the repository backend — and it is a good trait. Everything that varies per deployment (storage, gates, metering) is either a stub, an env-var struct, or absent. The wasm host is the one door open to a third party and no tier opens it.
2. Findings per seam
2.1 The backend trait is a seam; the wiring around it is not
READ: RepositoryBackendTrait (traits/src/lib.rs:323-463) demands name, format, is_writable, fetch, put, handle_http2_request; six more have safe defaults (archive_*, list, delete_artifact fails closed, coordinate_for_path + maps_paths_to_coordinates default to "cannot tell", which the quarantine gate refuses to boot over). That is a well-judged contract.
MEASURED: server/lib/src/repository.rs:281-520 is a match self.ron_repo_type.as_str() with 20 string arms (rust, rust-remote, nexus-remote|artifactory-remote|azure-remote|aws-remote, pip, npm, nuget, go, gem, deb, rpm, helm, docker, oci, raw, conda, composer, group, znippy-build-thing, znippy), and server/lib/Cargo.toml:42-77 names all 17 repository crates as unconditional path deps. ArtifactFormat/RepositoryType in traits are closed enums with their own as_str matches.
Consequence: adding a format = a new crate + a Cargo dep + a match arm + an enum variant + (for upstreams) a upstream_path arm. Five edits in three crates, all in the core. The ABI crate's own rustdoc admits it (plugin/abi/src/lib.rs:7-10: "the sixteen repository backends are unconditional path dependencies … adding a package handler meant editing server/lib/Cargo.toml and rebuilding").
DESIGNED (Pro; MVP-neutral): the ron_handler path already IS the answer — backend_from_config_with tries the catalog first (repository.rs:251-258) and only then falls into the match. Move the 17 built-ins into HandlerCatalog::register_linked (they would each implement PackageHandler over a BlobStore, exactly as skidbladnir/rust-toolchain do), and the match shrinks to the three shapes that are not handlers: group, raw, and the remote upstreams. Then a format is one crate that registers itself, and the MVP could drop the formats it does not ship (today holger-mvp links all 17 — the MVP row's "znippy only, no package plugins" claim in SUITES.md is about the archive client, not the server). That is a refactor that removes from MVP.
2.2 Storage is a stub, and every backend re-implements the bytes
READ: storage/mod.rs is 36 lines; backend_from_config returns Ok(()). Each znippy-backed repo opens its own archive; each file-backed repo owns its own dir; DirStore in plugin/host/src/store.rs is a third filesystem-bytes implementation (the BlobStore one). MEASURED: impl BlobStore has 3 impls (DirStore, HostStore guest shim, ProbeStore), and none of the 17 format crates use it.
The house has the storage engine: znippy (O(1) arrow-IPC archives, the law's "reuse the nordisk repos"). A BlobStore over a znippy archive (read) + a writable-archive BlobStore would let every handler run over the appliance's real store. Today a wasm handler can only ever write loose files under <data_root>/<stem>, which on a no-shell appliance is a directory nobody can inspect or seal.
DESIGNED (Pro first, MVP later): one BlobStore impl over znippy (read) and one over the writable-archive path RustRepoZnippy::with_writable_archive already uses. Delete StorageEndpoint or make it the thing that constructs those two. No new trait — BlobStore exists.
2.3 Auth: the edda seam is right, holger's enum is the cost
READ: edda's AuthProvider (bearer / client-cert / SAML verbs, "not mine" vs authoritative reject) is the correct shape and holger consumes it. holger's AuthMethodConfig enum + provider_pinned (auth.rs:46) + the kind() match (auth.rs:81) + 18 cfg(feature = "auth-oidc") sites (§6) are the price of pinning providers per tier. For MVP (bearer + mTLS + passkey) this is fine; it is closed on purpose so an airgap build links no IdP client. No change recommended.
2.4 Replication needs no trait
READ: replicate_pairs(&[RepoPair], &ReplicationOptions) is pure over two Arc<dyn Backend>. A "peer" is just a backend that happens to be remote. This is the model the rest of holger should copy: the seam is the backend trait; features compose over it.
2.5 Audit is the model seam
READ: AuditLog — two verbs, three impls, chosen by config, Critical profile refuses to boot without a writable sink (lib.rs:654-696 tests). Nothing to fix. Note for §4: a plugin cannot write to it.
3. The gate seam — what gunnar has and holger does not
3.1 gunnar's Gate, read properly
READ, /home/rickard/git/gunnar/crates/gunnar-policy/src/lib.rs:173:
pub trait Gate: Send + Sync {
fn name(&self) -> &str;
fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict>;
}
Five properties, each load-bearing:
- Live on the hot path. It runs between the pack landing and the refs moving (pre-receive). It cannot write and cannot decide half a push (rustdoc
:168-171). - Bytes are a required constructor argument.
PushContext(context.rs:266) carriesprincipal,repo,updates: &[RefUpdate],new_objects: &[ObjectId],push_options. A gate sees the objects, not a name. - Whole-set judgement.
PolicyEngine::evaluateruns every gate, even after one refuses, and returns aDecision { rejections: Vec<GateRejection> }withundecided: boolper rejection — "your push violates a rule" and "gunnar is broken" are distinguishable to the operator (:245-260). - Gates are declared in config.
PolicyEngine::from_config(&PolicyConfig)→config.gates()buildsVec<Box<dyn Gate>>;gate_names()is exposed asPolicy.ListGatesbecause "a repository whose protection rules failed to load would look exactly like one that has none". - GUN-007: an unreadable policy REFUSES EVERYTHING.
transport.rs:2570-2620:Errfromload_policyis a refusal of every reference, neverPolicyConfig::default(). Until 2026-09-11 a typo silently switched branch protection off; the fix is a test (gunnar-cli/tests/policy_fails_closed.rs) that also proves a clone of the same repo still works, so the refusal costs pushes and nothing else.
Err from a gate = undecided = refused. Ok(Verdict::Allow) is the only way through.
3.2 holger's equivalent today
READ, server/lib/src/promote/mod.rs:
PolicyGate { scanner: OsvScanner, enforcement }(:66) — one concrete scanner, not a trait.- Built by
PolicyGate::from_env()(:88) from three environment variables; unset ⇒Ok(None)⇒gate_checkreturnsOk(())(:522-525, "unconfigured ⇒ unconditional"). On an appliance with no shell, "env var" is a config surface nobody can reach, andNoneis silent pass-through. evaluate(ecosystem, id)(:122) callssecurity::artifact_from_coords(ecosystem, name, version, false)— the coordinate-only seam.fa9f5e6measured what that does: modgunn'sVerdict::cache_key()is(blob_sha256, policy_version, advisory_db_rev)and holger passedblob_sha256: String::new(), so every artifact in a promotion collapses onto("", policy, db). The digest-carrying seamartifact_from_coords_with_digestnow exists (security.rs:227) — butPolicyGate::evaluatestill calls the coordinate-only one (MEASURED:promote/mod.rs:123). The fix landed one layer down and the gate does not use it.- Per-artifact only.
gate_check_hopruns per rung (:470); there is no "the promoted set as a whole" call, which SUITES.md §"MVP gains the chain" explicitly requires ("analyses per package AND over the promoted set as a whole"). - Not declared in config:
Stage::gatedis a bool, not a list of gate names. - No GUN-007. An unreadable advisory DB is an
Errat boot (good,:101), but no configured gate isOk(None), and there is no way to say "this rung must be gated" and have the absence of a working gate refuse the hop.
The attest plane (attest.rs) already does the right thing on its side: digest-keyed, many attestations per digest, -3 refuses a statement whose subject names different bytes. So the evidence store is gate-ready; the gate is not evidence-ready.
3.3 What holger's gate should look like
DESIGNED (MVP — SUITES.md rules the promote→attest→sync→verify chain into MVP, so this is MVP surface that replaces the env-var struct rather than adding beside it):
// server/lib/src/promote/gate.rs — or edda if gunnar's Verdict/Decision shapes are reused, which they should be
pub struct PromotionContext<'a> {
pub principal: &'a AuthIdentity,
pub hop: &'a Hop, // from-rung, to-rung, ladder name
pub set: &'a [PromotedArtifact<'a>], // EVERY member of this promotion
}
pub struct PromotedArtifact<'a> {
pub id: &'a ArtifactId,
pub ecosystem: &'a str,
pub digest: ArtifactDigest, // attest.rs's type — REQUIRED, not Option
pub bytes: &'a [u8], // or a reader; the point is the gate CAN see them
pub attestations: &'a [Statement], // what the evidence plane already holds for this digest
}
pub trait Gate: Send + Sync {
fn name(&self) -> &str;
/// Err = undecided = refused. Judges the SET; a per-artifact gate iterates inside.
fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict>;
}
Stage { gates: Vec<String> }in the RON ladder replacesgated: bool. A rung naming a gate this build does not carry is a boot error (the same ruleron_handleralready applies for handlers: "an unknown name is a boot error … lists what is accepted").PolicyGate::from_envis deleted; the OSV scanner becomesimpl Gate for OsvGatebuilt from the RONpromotionblock (which6de216falready made reach the engine).- HOL-GUN-007: a rung whose gate list cannot be resolved, or whose gate returns
Err, refuses the hop. The hop is the pre-receive equivalent — refusing it costs promotions and nothing else; reads, fetches, serving are untouched. Test it the way gunnar does: the same fixture, gate config broken, hop refused, fetch still works. - Emit ONE summary attestation per promotion from
Decision(the set-scope DSSE statementattest.rsalready files under every member —a_set_scope_statement_is_keyable_from_every_member).
Cost to MVP: - env-var parsing, - Option<PolicyGate>; + one trait, one struct, one Vec<String> in config. Net smaller and no new deps. Verdict/Decision/GateRejection should be lifted from gunnar-policy into edda rather than copied (reuse law); gunnar-policy is an "S" crate (no OS, no fs) so it is already appliance-shaped.
4. The wasm plugin host: the host-guest contract
4.1 What is there (READ, b594965, 2552ceb)
- Host: wasmtime 48.0.2,
Engine/Module/Store/Linker/Caller/Memory/Valonly, no WASI linked (2552ceb). Fuel1_000_000_000, memory 256 MiB, tables 100 000, 30 s epoch deadline, allPluginLimitsfields refuse0(limits.rs:51-94). Module verified (detached CMS over blake3, viaznippy_common::sign) beforeModule::from_binary;from_filerejected because it also accepts WAT (b594965). Refusals namedholger-plugin-1..9. A failed verification is a boot error; a failed load is aRejectedModulebeside working ones. - Guest ABI (
plugin/abi/src/lib.rs): 8 exports (alloc,result_len,plugin_manifest,plugin_fetch,plugin_put,plugin_list,plugin_coordinate_for_path,plugin_http); 4 imports fromenv(host_store_get/put/list/size), packedu64returns, length-prefixed codec, never JSON. ABI_VERSION: u32 = 1in the manifest; host refuses a mismatch by name (wasm.rs:156-165).- Agreement harness (
agreement.rs): every-wasmcrate must prove native == wasm over real bytes, never skips, and refuses a stale.wasm. - MEASURED:
wasm-pluginsappears in no variant's feature list (grep wasm-plugins .nornir/build-variants.toml= 0 hits) andserver/lib/Cargo.tomlsays "OFF in every tier, including full". With it off,ron_plugin_dirset isScanError::NoWasmHost(loud, good).
This is a well-built, well-bounded host. It is also, today, a host for exactly one kind of guest.
4.2 What a plugin can DO (the capability audit)
The guest sees: its own key-value blob store, keyed by strings it chooses, rooted where Mounts says. That is the entire world. It cannot:
| capability | available? | why it matters |
| read another repository | no | a gate/scanner plugin must see the promoted bytes; a handler is its own island |
| read artifact digests / the attest store | no | no evidence plane access ⇒ no gate plugin, no SBOM plugin |
| write an audit event | no | a plugin that refuses something leaves no trace in the only attestation trail |
| read a counter / usage / metrics | no | no metering plugin (§4.4) |
| know the principal of the request | no — WireHttpRequest carries method/path/body only (READ wire.rs) |
a handler cannot do per-user anything; RBAC is host-only (fine) but reporting who did what is impossible |
| time, randomness, clock | no | not needed for a format handler; needed for anything that signs or expires |
| network | no (by design; no WASI) | correct for airgap |
| call another plugin | no | — |
| declare what it needs | no — the manifest is {abi_version, handler, format, writable} |
all-or-nothing: every module gets the four store imports, nothing else, and there is no field in which to ask for more |
So the capability model is: one implicit capability (own blob store), unannounced, ungrantable. There is no requires: [...] in the manifest and no per-import gating in register_host_functions (wasm.rs:483, all four func_wraps unconditional). That is fine while the only guest kind is a format handler; it means the next guest kind cannot be written without changing the host and the ABI version and every existing module's agreement test.
4.3 Versioning and what happens when the host changes
READ: ABI_VERSION is one u32; a mismatch is refused. There is no range (a host that speaks 1 and 2), no per-verb versioning, no feature-negotiation. Consequence: the first ABI bump orphans every signed .wasm in the field until the operator gets a re-signed module. On an airgap appliance that is a bundle import per plugin per host upgrade. Acceptable for v1; must not stay a single integer once a third party ships anything. WireManifest::decode also has no trailing-field tolerance — a v2 manifest with one more field fails expect_end("Manifest") even if the host is v2-capable, so the manifest itself cannot be the negotiation channel as written.
4.4 The first three real plugins, and whether ABI v1 carries them
- monetize meter reader (the owner's named one). READ, monetize
products/holger/src/lib.rs:195-220: four meters —artifacts,artifact_bytes,archive_bytes,archive_files— and today the product answersUnavailable("holger: not wired")(:8). Onfeat/metering-doors(f6ff7ec, not on main) holger growsUsageService.ReadUsage+Entitlement.Setas gRPC services plus an in-processmetering.rs. So the metering plane, when it lands, is a door for an external client, not a plugin point. Can ABI v1 host it? No. A wasm guest has no read of any repository's listing, archive info, or counters, no clock, and no way to export a reading. It lacks: (a) ahost_usage_readimport or, better, a second guest kindMeterSourcewith an exportplugin_meters() -> Vec<(name, value, scope)>; (b) read access to theHolgerroster; (c) a way to be called periodically rather than per HTTP request. The honest answer is that monetize does not want a plugin here — it wants the gRPC doorf6ff7ecalready wrote, and the plugin lives on monetize's side (it already does:products/holger). A holger-side "plugin for monetize" is only needed for enforcement (refuse a put over cap), and that is a gate — which needs the §3 trait, not the wasm ABI. Verdict: landf6ff7ec's doors (MVP-metered atom, already declared as a separate variant), don't build a metering plugin ABI. - A gate plugin (OSV/licence/custom policy as
.wasm). Needs the §3Gatetrait first, then a guest kindplugin_check(PromotionContext) -> Verdict, which needs bytes or digest + attestations in — none of which the v1 wire types carry (WireArtifactIdhas no digest field). Not carriable. - A third format handler (e.g. conda-as-plugin, or a customer's in-house bundle format). This ABI v1 carries it today — it is exactly what it was built for, and the agreement harness proves native==wasm. The only blockers are (a)
wasm-pluginsoff everywhere, (b) the guest's store is a loose directory, not the appliance's znippy store (§2.2), (c) no#[cfg]-free way to ship the host without cranelift: MEASUREDwasmtime+craneliftin a musl appliance is the size argument the Cargo.toml note makes, and it is real.
Plugin ABI verdict: v1 is a correct, bounded, signed format-handler ABI with zero capability model and single-integer versioning, shipped in no build. It can carry plugin #3 today and neither #1 nor #2. A plugin system nobody can write a plugin for is not a finding yet — a third format handler could be written this afternoon — but a plugin system that cannot grow a second guest kind without an ABI break is.
DESIGNED (Pro): (a) WireManifest { …, requires: Vec<String> } and the host registers only the imports a module names, refusing unknown ones by name (holger-plugin-10); (b) a guest kind in the manifest (handler today; gate and meter-source later), each kind = its own export set; (c) ABI_VERSION → abi_min..=abi_max on the host; (d) a host_audit_record import so a refusal inside a module reaches the append-only log. None of this is MVP: MVP's wasm-plugins stays off, and the MVP's extension story is linked handlers registered in builtin_catalog().
5. Trait discipline
5.1 dyn vs generics — MEASURED
server/lib/src + plugin + traits, generated code excluded: Arc<dyn 108, Box<dyn 8, &dyn 23, impl Trait in argument position 0. Of the 108 Arc<dyn, 80 are RepositoryBackendTrait, 14 AuditLog, 3 FulfilmentApi, 4 AdvertisedDigest, 1 HolgerObject.
Verdict: the dyn is where it belongs. Backends are heterogeneous per repository and chosen at boot from config; a route table of Arc<dyn Backend> is the right shape and a generic FastRoutes<B> would be wrong. HandlerBackend boxes dyn HandlerRuntime (backend.rs:98) — also right, that is the whole point of the linked/loaded erasure. The one place generics are used is LinkedHandler<H: PackageHandler> — right again, monomorphised per linked handler and erased once at into_runtime.
No dyn-where-generic-belongs found. No generic-where-dyn-belongs found. The trait-object hot path (handle_http2_request per request through a vtable) is one indirect call per request; not a performance concern next to the bytes.
5.2 Traits with one implementation
MEASURED: Mounts (1), AdvertisedDigest (1 prod), PropertyLookup (0 prod, 1 mock — search.rs:95), PypiIndex (1), BundleKind (2), FulfilmentApi (1 + fake). PropertyLookup exists only so search tests can mock the property store; that is a test seam wearing a pub trait. Harmless, but it is not "trait-based" in the owner's sense — nobody plugs a property lookup.
5.3 The god-object: GatewaySettings
READ exposed/http.rs:92-155: 13 fields — routes, auth, TLS, body cap, audit, operation mode, security profile, sboms, properties, passkeys (cfg), quarantine, reserved properties, promotion property, marketplace. It is built once in Holger::start_grpc_endpoint and read in handle. MEASURED: it is named in exactly 2 files (lib.rs, http.rs). So it is not threaded everywhere — it is a constructor bag for one 1 000-line function. The smell is not the struct; it is that handle is the only consumer, because handle does everything (§7).
5.4 cfg(feature) doing architectural work
See §6. The pattern to name: auth.rs:46-52 provider_pinned is a match over the enum returning cfg!(feature = …) per arm — five features answering one question that the edda AuthProvider list already answers at runtime (is there a provider with this name?). That is a cfg standing in for "ask the registry". It is load-bearing for the boot warning warn_unpinned_providers, and correct, but it is the shape a sixth method has to copy.
6. #[cfg(feature …)] census — server/lib/src
MEASURED: 78 sites, 7 distinct features:
| feature | sites | legitimate? | note |
testmatrix |
24 | yes — test emitter, compiled out of every ship row | not architecture |
auth-oidc |
18 | 13 of 18 are test-only (grpc.rs:1778-1995, the promotion tests need a signed JWT); 5 prod (marketplace/signin.rs) |
the prod ones gate an IdP sign-in flow that has no business in an airgap build — legitimate as absence of a subsystem |
online |
13 | yes — the zero-egress guard is the whole point of the airgap tiers; each site removes an HTTP client | legitimate, and measured by a dependency-tree guard, not trusted from a feature list |
auth-passkey |
7 | yes — the passkey store + its doors are a subsystem | note the accident below |
front |
6 | yes — whole public page absent from the appliance (b8e13a0) |
the model case |
federation |
6 | yes — Skidbladnir ship + cloud harbours absent | legitimate |
sign |
4 | yes — but it rides the dependency edge on every tier (server/cli/Cargo.toml:71), so the #[cfg(not(feature = "sign"))] fail-closed arms (attest.rs:848-860, repository.rs:584) are unreachable in any shipped binary. Dead-but-correct code. |
keep, it is the honest fallback |
Legitimate whole-subsystem absences: front, federation, online, auth-passkey, the prod auth-oidc sites. Missing abstraction: none of the 78 — the auth enum (§5.4) is the closest, and it is a deliberate ship-small choice. What is NOT gated and should be: pub mod marketplace (lib.rs:68) — 2 371 + 5 files of cloud-marketplace fulfilment (Microsoft landing/webhook/Entra sign-in, tenant ledger, provisioning) compiled into holger-mvp on the airgap ISO with no feature at all. Only its HTTP client is behind online; the doors, the state machine and getrandom are in every build.
The two accidents, both MEASURED and both fixed by naming the feature on the row:
6f02ddf:frontleaked into--no-default-features --features mini,benchbecause bench-scenarios asked for server-lib's defaults and cargo unifies per graph.server/cli/Cargo.toml:150-160(2026-09-20):auth-passkeyreached the plainholger-serveronly through thebenchedge; dropbenchand the login door vanished.
Reading: cfg is doing load-bearing work it can do, but the guard is a cargo tree measurement (holger-recipe/src/rules.rs), not the type system. That is the correct guard for "is an HTTP client linked"; it is the wrong guard for "is the marketplace in the appliance", which should simply be a feature that MVP does not name.
DESIGNED (MVP, removes surface): marketplace = ["dep:…"] feature, on for full/server-local rows, off for every MVP row; pub mod marketplace and GatewaySettings.marketplace behind it, same shape as front. This is the one cfg addition in this document, and it takes ~2 400 lines out of the MVP.
7. Size and shape
MEASURED (wc -l over src/ per crate, tests included):
| crate | lines | note |
server/lib |
41 012 | of which generated/holger.v1.rs 3 610 |
update-bundle |
5 838 | network-free, its own crate — correct |
server/cli |
4 241 | main.rs 2 689 |
bench-scenarios |
3 912 | |
plugin/host |
3 181 | |
traits |
1 323 | lib.rs 1 035 — types + 4 traits, one file |
| 17 repository crates | 305 – 1 534 each | median ~650 |
Biggest files (prod lines = up to the first #[cfg(test)]):
| file | total | prod | tests | jobs it does |
exposed/http.rs |
3 424 | 2 077 | 1 347 | accept loop + backoff + TLS deadline; bearer/mTLS auth; read gate; front page mount; passkey ceremony doors (8 routes); healthz/readyz/v2; /-/search; /-/sbom; /-/properties read+write; /metrics; marketplace landing/callback/webhook; RBAC write gate; checksum verify; quarantine gate; repo routing. handle() alone is 1 018 lines (:451-1469). |
grpc/services.rs |
2 954 | 1 769 | 1 185 | 8 tonic service impls in one file |
lib.rs |
2 911 | ~2 200 | ~700 (interleaved mod *_tests) |
config structs, RON reading, wiring, start(), gRPC endpoint start (:1757-1994), retention executor, quarantine posture, handler catalog |
grpc.rs |
2 680 | 1 096 | 1 584 | interceptor + server setup; 60% tests |
cli/main.rs |
2 689 | one binary, every verb | ||
marketplace/mod.rs |
2 371 | 1 378 | 993 | see §6 |
audit/arrow_ipc.rs |
1 711 | one sink; fine | ||
proxy.rs |
1 629 | 731 | 898 |
Module depth: max 4 (server/lib/src/exposed/http.rs). No deep nesting; the problem is width, not depth.
7.1 What should come out of http.rs, and what the seam is
READ: the route dispatch in handle is a sequence of if path == "/-/…" blocks (MEASURED via grep at :167, 278, 281, 295, 431, 482, 529, 587-669). Each block re-does: parse query, run the read/write gate, call a store off GatewaySettings, shape a JSON/text response. The seam that already exists and is not used here is the same one the marketplace module did use for itself: marketplace/door.rs has Door::{Landing, Webhook} and http.rs:618 dispatches to it.
DESIGNED (MVP-neutral to MVP-negative; pure motion of code, no new deps):
/// A meta door under `/-/`. Sees the already-authenticated identity and the settings; never a backend.
pub(crate) trait MetaDoor: Send + Sync {
fn prefix(&self) -> &'static str; // "/-/search", "/-/auth/passkey", …
fn serve(&self, req: &MetaRequest<'_>, s: &GatewaySettings) -> Response<Full<Bytes>>;
}
with one impl per file: doors/search.rs, doors/sbom.rs, doors/properties.rs, doors/passkey.rs (cfg(auth-passkey)), doors/front.rs (cfg(front)), doors/marketplace.rs (cfg(marketplace)), doors/metrics.rs. handle() becomes: accept → authenticate → is it a meta door? dispatch → else repository route. GatewaySettings shrinks to routes, auth, tls, audit, mode, profile + a Vec<Box<dyn MetaDoor>> built in lib.rs from whichever stores are configured — the Option<Store> fields (sboms, properties, passkeys, marketplace) move into the door that owns them, so "None ⇒ 404" becomes "door not mounted ⇒ falls through", which is what front already does. The read gate (gate_meta_read, :388) and the write floor (authorize_http_write, :1811) stay in http.rs, run before dispatch, once. the_reserved_meta_namespace_never_routes_into_a_repository (:2762) is the test that must stay green through the move.
The accept loop / backoff / TLS deadline (:157-352) is a second job and goes to exposed/accept.rs; it has its own three tests already (:3285-3384).
Everything else in the table is one job per file and is merely long; grpc/services.rs could be one file per service to match the proto, but nothing forces it.
8. The five worst findings
- The gate is not a seam —
PolicyGateis one struct over one scanner, configured from environment variables on a box with no shell,Ok(None)when unset, per-artifact by name, and still calling the coordinate-only seam thatfa9f5e6measured collapsing every artifact onto one cache key (promote/mod.rs:66, 88, 123, 522). gunnar'sGate+ GUN-007 is the model; §3.3 is the shape. MVP (SUITES.md rules the chain in). wasm-pluginsis off in every tier and the ABI cannot grow a second guest kind — v1 is a correct format-handler ABI with no capability field, four unconditional imports and a single-integer version (wire.rs:15,wasm.rs:483). The owner's named plugin (monetize) needs a door, whichf6ff7ecwrote and is not on main, and enforcement, which is a gate (#1). Pro for the ABI; MVP-metered for landingf6ff7ec.- The marketplace is compiled into the airgap appliance —
pub mod marketplacehas no feature; ~2 400 lines of Microsoft fulfilment + tenant provisioning +getrandomride inholger-mvpon the ISO (lib.rs:68, §6). Onecfg, shaped likefront, removes it. MVP. - Storage is a stub and every backend owns its own bytes —
StorageEndpoint::backend_from_configisOk(())(storage/mod.rs:31); the wasmBlobStoreis a loose directory; none of the 17 format crates share a store, and the house's storage engine (znippy) is opened 17 different ways. ABlobStoreover znippy is the missing piece that makes the plugin lane run over the real store. Pro. http.rs::handleis 1 018 lines doing eleven doors' worth of parse-gate-serve — the seam (MetaDoor) already exists in spirit asmarketplace::Door; everyOption<Store>field ofGatewaySettingsis a door that should own its ownNone ⇒ 404. Pure motion, tests exist. MVP (neutral to negative on surface; it makes thecfg(front)/cfg(marketplace)cut a file boundary instead of anif).
Honourable mention: the backend match in repository.rs:281 with 17 unconditional format deps — the ABI crate's own rustdoc calls this out, and the fix (§2.1) is to register the built-ins through the catalog the plugin lane already built. Pro, but it is what would let MVP stop linking formats it does not ship.
9. MVP or Pro — every proposal in this document
| proposal | § | suite | surface delta to holger-mvp |
Gate trait + config-declared gates + HOL-GUN-007, deleting PolicyGate::from_env |
3.3 | MVP | negative (env parsing out; one trait in; no deps) |
marketplace feature, off in MVP rows |
6 | MVP | −~2 400 lines, −getrandom |
MetaDoor split of http.rs, GatewaySettings shrink |
7.1 | MVP | zero (motion) |
land f6ff7ec metering doors as the declared mvp-server-metered atom |
4.4 | MVP-metered only | +metering.rs in the metered atom, nothing in open MVP (the branch's open_build_speaks_no_money test holds that) |
built-in formats through HandlerCatalog::register_linked; shrink the match |
2.1 | Pro first | later lets MVP drop formats |
BlobStore over znippy (read + writable archive); retire StorageEndpoint stub |
2.2 | Pro | none until MVP turns on plugins |
manifest requires + guest kinds + ABI version range + host_audit_record |
4.4 | Pro | none (wasm-plugins stays off in MVP) |
lift Verdict/Decision/GateRejection from gunnar-policy into edda |
3.3 | both (shared) | none |
PropertyLookup — leave; it is a test seam |
5.2 | — | — |
| auth enum — leave; ship-small is the point | 2.3 | — | — |
What is not proposed: any new runtime dependency; any generic-ification of the route table; a storage trait (one exists — BlobStore); a metering plugin ABI (monetize wants a door, and has one on a branch).