Promote → attest → sync → verify — design
Class:
design· Function:promote(usesreplicationfor the two-endpoint verb,registryfor the per-repolist()/fetch()/put()seam,sbomfor the evidence door, andgrpcfor the RPC surface). Supersedes nothing; it extendspromotion-design.md§4 (the policy gate, there a one-line stub) andreplication-design.md(there a same-process verb) into the shape Rickard asked for on 2026-09-19:In the cloud, one (later two) holger instances. Repository A and repository B; artifacts are PROMOTED A → B. Promotion is gated by HOOKS that run analyses — per individual package AND over the promoted set as a whole. A downstream holger, on-prem inside a protected area, SYNCS DOWN the contents of repo B and VERIFIES SIGNATURES. Standard supply-chain formats. Simple to configure, cryptographically assured, and in the MVP suite.
Status: DESIGN — ratifiable. Every claim below is tagged MEASURED (code exists and runs on a wired path today), DESIGNED (a trait/stub/doc exists and the default is a no-op or a refusal), or ABSENT (nothing). Decision owner: Rickard.
Scope decision (Rickard, 2026-09-19, authoritative): the WHOLE capability — promotion gating, evidence emission, sync, and downstream verification — goes in holger's MVP suite, not just the verify half. §6 works through what that costs.
0. The one-paragraph headline
holger already owns almost every primitive this needs and wires almost none of them to each other. The bytes move (PromotionEngine::seal, server/lib/src/promote/mod.rs:566, MEASURED). The stage model exists in full (StageLadder, server/lib/src/promote/stages.rs:169, MEASURED as a type, never constructed outside tests — with_ladder has zero non-test callers). The offline trust plane exists in full and is TUF-shaped in everything but its name (update-bundle/src/policy.rs:30, rollback.rs:65, MEASURED). The DSSE/in-toto/SLSA implementations exist, spec-exact, one repository away (znippy/znippy-common/src/sign.rs:1190, edda/crates/nornir-build-thing/src/provenance.rs:297, MEASURED). And the one thing holger has no shape for at all — a gate that runs an analysis and refuses — exists in gunnar, wired, on a hot path, in 300 lines (gunnar/crates/gunnar-policy/src/lib.rs:173, MEASURED). This document is therefore mostly a wiring design, not an invention design. The single genuinely new decision is §3: where a hook runs.
1. What holger needs — the itemized list
Each row says what it is, what holger has today, and whether gunnar or monetize already solves it and where.
| # | holger needs | holger today | solved elsewhere? |
| 1 | A gate seam: something that observes a promotion and can REFUSE it, by name, with a reason fit to return over the wire. | ABSENT as a seam. PolicyGate (server/lib/src/promote/mod.rs:66) is one concrete struct with one method, evaluate(ecosystem, id) -> (Decision, Vec<String>) (:122). It is not a trait. Nothing can add a second gate without editing that file. |
YES — gunnar. pub trait Gate (gunnar/crates/gunnar-policy/src/lib.rs:173): name() + check(&PushContext) -> Result<Verdict>. Err = could not decide = refuse (:181-184). Live on the receive-pack hot path at gunnar/crates/gunnar-server/src/transport.rs:2515. Copy this trait. |
| 2 | Gates that see BYTES, not a coordinate string. | ABSENT. PolicyGate::evaluate takes (&str, &ArtifactId) and builds a synthetic artifact from coordinates (security::artifact_from_coords, promote/mod.rs:135). The bytes are in scope at the call site (grpc/services.rs:1114 passes &data to seal) and are never handed to the gate. |
YES — gunnar. PushContext carries new_objects: &'a [ObjectId] as a required constructor argument, with the reasoning written out (gunnar-policy/src/context.rs:289-293): "a defaulted-empty object set is an always-allow switch". |
| 3 | A gate that judges the SET, not one member. | ABSENT. There is no multi-artifact promote verb at all; PromoteArtifactRequest is singular (proto/holger.proto:57). |
PARTLY — gunnar. PushContext.updates: &'a [RefUpdate] is a slice, and PolicyEngine::evaluate runs every gate over the whole push, then re-runs per-ref only on the refusal path to attribute blame (gunnar-policy/src/lib.rs:315, :246). That is exactly the per-member/whole-set split holger wants. |
| 4 | Gates declared in config, not in code. | PARTIAL. PolicyGate::from_env (promote/mod.rs:88) reads three env vars. Fail-closed on a bad advisory feed (good). But env, not RON, and a fixed gate set. |
YES — gunnar. PolicyConfig (gunnar-policy/src/config.rs:25, #[serde(default, deny_unknown_fields)]) → PolicyConfig::gates() -> Vec<Box<dyn Gate>> (:58). Only gates that would decide something are built, so gate_names() answers "what is live here" honestly (lib.rs:286). |
| 5 | A signed, standard-format record of each verdict. | ABSENT. A verdict is a (Decision, Vec<String>) that is logged and discarded. The audit row records that a promotion happened, not what it passed (grpc/services.rs:1119). |
YES, one repo away. DSSE dsse_pae/dsse_sign/dsse_verify — znippy/znippy-common/src/sign.rs:1190,1347,1375, byte-exact PAE including the payload-type-confusion defence. in-toto Statement v1 + SLSA Provenance v1 mapper — edda/crates/nornir-build-thing/src/provenance.rs:297-428. Verifier — modgunn/src/provenance.rs:700, on by default. holger uses none of it. |
| 6 | One object the downstream verifies instead of re-running every scanner. (SLSA VSA.) | ABSENT. | ABSENT everywhere. No VSA in the constellation. This is genuinely new — but it is 60 lines of JSON mapping over item 5's envelope. |
| 7 | Signed repository metadata for the sync: freshness, rollback protection, key rotation, thresholds. | PRESENT BUT UNCONNECTED. update-bundle has all four: TrustPolicy.maximum_bundle_age_secs + require_expiry (freshness, policy.rs:93,97), AcceptedState::check_admissible (monotonic anti-rollback + bundle-id-reuse forgery signal, rollback.rs:65), approved_signers keyed by certificate fingerprint, never a role string the signer writes about itself (policy.rs:17-26), required_approval_roles + minimum_distinct_approvers + release_signer_must_be_distinct (M-of-N and the two-person rule, policy.rs:43-57). It governs holger.update-bundle/v1 and nothing else. |
n/a — this is holger's own and it is the best thing in the repo. Build on it. Do not replace it with a TUF crate. |
| 8 | A promoted store something actually serves. | ABSENT. PromotionEngine::fetch (promote/mod.rs:699) re-verifies blake3 on read and is called from tests only. grep -rn "self.promotion" over server/lib/src returns four hits, none on a fetch path. to_repo is never resolved to a Repository. |
n/a |
| 9 | ONE digest discipline. | BROKEN. Promotion content-addresses with blake3 (promote/mod.rs:576); replication and search dedup on sha256 (replication.rs:46, nornir_hash::sha256_hex). update-bundle carries both per entry and treats that as the point (digest.rs:56 Digests { sha256, blake3 }, PayloadEntry at core.rs:73). in-toto subjects are keyed by sha256. |
update-bundle's dual-digest Digests::of (digest.rs:64) is the reconciliation, already written. |
| 10 | An identity model for "who may promote" that works with bearer + mTLS only. | PRESENT. promote_artifact takes an ADMIN gate scoped to the destination repo and separately authorizes read on the source (grpc/services.rs:916-947) — a two-ended RPC that authorizes two ends (grpc/services.rs:1977). Roles come from mTLS CN or OIDC claims. |
YES — gunnar, and it is stricter. AuthTable::may (gunnar/crates/gunnar-core/src/auth.rs:1562) with a documented rule order: the account gate first (rule −1, :1570), anonymous second, then unknown, then self-ownership, then public-read, then max-over-grants. require reports a gate AS a gate, never as a missing grant (:1602-1620). The lesson holger should take is that ordering, not the table. |
| 11 | Per-credential connections and single-use, method-bound credentials for the cross-instance hop. | ABSENT. ReplicationService.Replicate is a same-process verb today; there is no client identity for a remote holger. |
YES — gunnar. gunnar-sshsig-v2 <fp> <ts> <nonce> <method> <armor> (gunnar/crates/gunnar-grpc/src/auth.rs:13-17), verified in five steps at auth.rs:1353, method binding checked before the key lookup and before the replay guard so a relayed credential does not burn the honest nonce (auth.rs:1379-1384). One fresh credential per outgoing RPC, one channel per key (gunnar-grpc/src/client_signer.rs:1-3). |
| 12 | A trust root for the downstream. | PRESENT, ARMED, UNUSED FOR THIS. ron_trusted_roots on a znippy repo verifies the archive's detached CMS before it is served, and a build without the sign feature refuses to wire rather than serving unverified (server/lib/src/repository.rs:512,555,583). Plus the boot chain: cargo xtask installer-iso --sign <key> --root-key <seed> (xtask/src/installer_iso.rs:50-53), root.pub on the ESP, require_signed on the cmdline. |
n/a. Correction to an earlier claim in this house: the sign feature is NOT unreachable. server/cli/Cargo.toml:71 sets features = ["sign"] on the dependency edge, so it rides into every tier including mvp-server; the Cargo.toml says so in prose at :69-70. What is true is only that no variant names sign in its own feature list. There is no "enable sign first" step in §7. |
| 13 | A loud, working default for every unwired slot. | PARTIAL. PromotionEngine::new with no gate is unconditional promotion — which is the permissive default. |
YES — monetize, and it is the sharpest thinking in the constellation. LoudSink answers Created and creates nothing, logging "would create …" (monetize/crates/monetize-server/src/signup.rs:698), because "the open path must be a working path, or nobody can tell a broken deployment from an unconfigured one" (:690-697). But where a permissive default could silently sell something, the default refuses: Product::may_act_for is default DENY (monetize/crates/monetize-product/src/lib.rs:849), can_absorb has no default at all (:700), name_verdict has none (monetize/crates/monetize-pay/src/signup.rs:206-209). |
| 14 | A registration ABI for a gate that is not in this repo. | ABSENT. | YES — monetize, and the shape is the interesting part. "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/crates/monetize-pay/src/factory.rs:20-22). VendorFactory { name, config_envs, build: fn(&VendorBuild) -> Result<Box<dyn PaymentProvider>, String> } (:74-84). Configured-but-not-linked is refused by name at boot, not 404 at use (monetize-server/src/registry.rs:388-398). Dispatch is trait objects — no dylib, no wasm, no libloading anywhere in monetize. |
| 15 | An event surface that is NOT the veto surface. | holger has neither. | BOTH solve it the same way and say so. gunnar: one broadcast bus, lagging subscribers get an EventGap (gunnar/crates/gunnar-grpc/src/bus.rs, events.proto:9-14); webhooks are a subscriber, never a second source (gunnar/crates/gunnar-server/src/webhook.rs:1-35), and each delivery is sshsig-signed under namespace gunnar-webhook (gunnar-core/src/webhook.rs:47). monetize: "publish failing | infallible; a push must not fail for lack of watchers … and the stake is higher: a PAYMENT must not" (monetize-server/src/bus.rs:25). Neither lets a subscriber veto. The veto is seam-typed, not event-typed. holger must copy that split. |
The shape of the answer, in one line each
- gunnar answers "how does a gate refuse".
trait Gate+Verdict+PolicyEngine, all gates run,Errmeans refuse, the refusal names the gate. - monetize answers "how does a slot stay honest while unwired". A loud working default where the permissive answer is harmless; no default where the permissive answer sells something.
- holger's own
update-bundleanswers "what does the downstream check". Thresholds, fingerprint-keyed roles, monotonic sequence, expiry. - znippy + edda + modgunn answer "what is the envelope". DSSE, in-toto Statement v1, SLSA Provenance v1 — already spec-exact, already tested, already reachable.
Nothing in this list requires holger to invent an envelope, a signature scheme, or a trust-policy format. Item 6 (the VSA) is the only new artifact, and it is a predicate inside an envelope that already exists.
2. The architecture — two planes
The design's whole load-bearing idea is that evidence and repository state are different planes with different lifetimes, and conflating them is what makes airgap supply chains break. holger already half-knows this: the audit log is append-only and permanent, the jera job ledger is bounded and operational, and the code says so (promote/mod.rs:326-331).
── EVIDENCE PLANE ── ── REPOSITORY PLANE ──
(per artifact, immutable, (per repo, re-signed,
never expires) expires on purpose)
analyser ──DSSE──▶ in-toto Statement repo B ──▶ signed repo metadata
(outside subject: {sha256} { version, expires,
holger) predicate: scan/SBOM/ targets[{name,
license verdict sha256, blake3}],
│ thresholds, keys }
▼ │
holger promote ──▶ PolicyEngine::evaluate │
boundary (verify, never execute) ▼
│ downstream holger
▼ 1. verify metadata sig
SLSA VSA, DSSE-signed (M-of-N, fingerprints)
subject: {sha256} 2. check freshness + seq
predicate: { verifier, time, 3. fetch targets by digest
result: PASSED, policy, 4. verify each target's VSA
resourceUri: repo B } 5. THEN write to disk
│
└── travels WITH the artifact, forever
2a. The evidence plane — DSSE + in-toto, one statement per hook verdict
Envelope. znippy_common::sign::DsseEnvelope (znippy/znippy-common/src/sign.rs:1276), produced by dsse_sign(payload_type, payload, signers) (:1347) and checked by dsse_verify(env, signer_certs_der, roots) (:1375). Every envelope signature must verify and chain to a root, else it is an error — not a partial pass. keyid is the signer's certificate fingerprint hex, which is the same key the update-bundle TrustPolicy already names (update-bundle/src/policy.rs:19-21). One fingerprint vocabulary across both planes is the reason to reuse this rather than adopt a sigstore crate.
Payload. An in-toto Statement v1: _type, subject: [{name, digest: {sha256}}], predicateType, predicate. The mapper shape is already written at edda/crates/nornir-build-thing/src/provenance.rs:297 (to_slsa_statement) and is the model to follow, including the detail that a working-tree freshness digest is recorded as a named dependency rather than silently folded away (:314-318).
Subjects are bound by digest, never by coordinate. This is the correction to item 2 stated as a law: a statement whose subject is serde@1.0.200 says nothing, because two different byte strings can wear that name in two repositories. A statement whose subject is {sha256: "ab…"} is a claim about bytes. holger's promote boundary has the bytes in hand (grpc/services.rs:1114) and must digest them itself; it must never accept a caller-supplied digest. gunnar states the same rule for keys and it is worth quoting because the reason generalises: the server derives the fingerprint itself, since "a client-chosen one could never match the SSH listener" (gunnar/crates/gunnar-proto/proto/gunnar/v1/access.proto:165-169).
One statement per hook verdict. Not one per promotion. A promotion of N artifacts through a gate set of M gates produces up to N×M statements plus one VSA. They are small, they are content-addressed, and they dedup in exactly the same blob store the artifacts do.
The VSA is the point. A SLSA Verification Summary Attestation is holger saying "I, holger-cloud, at time T, evaluated artifact sha256:ab… against policy sha256:cd… and the result was PASSED". The downstream then verifies one signature over one small object instead of re-running an SBOM differ, a CVE scanner and a licence checker inside an airgap where the advisory feed is by definition stale. The per-hook statements travel too — for audit, for "why did this pass", and so a downstream with a stricter policy can re-decide — but the fast path is the VSA alone.
Failure modes, stated.
- A VSA that verifies but whose
subjectdigest does not match the bytes on disk is a refusal, not a warning. This is thetampered_blob_fails_integrity_on_readcase (promote/mod.rs:748, MEASURED) lifted from blake3-vs-ref to VSA-vs-bytes. - A VSA signed by a key that verifies but is not in the trust policy is a refusal, and the message must say "not a trusted verifier", never "bad signature".
- An artifact with no VSA is not "unknown", it is refused on the verify-on-sync path. The
update-bundleverifier already takes this line:PolicyResult.non_productiondefaults totrueso that "we did not use a real advisory snapshot" cannot be mistaken for a pass (update-bundle/src/core.rs:113-119). Copy that default. verify_thresholdrejects M=0 outright and confines the unsigned opt-out to a separately-named function so it can never be reached by accident (znippy-common/src/sign.rs:1090-1109). The VSA path inherits this.
2b. The repository plane — TUF semantics on the update-bundle substrate
TUF exists to answer four questions the evidence plane cannot: is this listing fresh? is it newer than the one I have? which keys may sign it now? how many of them must agree? holger needs all four for the sync, and already implements all four for bundles:
| TUF concept | holger's existing implementation | file:line |
| root (keys + roles + thresholds) | TrustPolicy { trusted_roots, approved_signers[{fingerprint, roles}], required_approval_roles, minimum_distinct_approvers } |
update-bundle/src/policy.rs:30-78 |
| role separation / two-person rule | release_signer_must_be_distinct, trusted_release_signers |
policy.rs:52-57 |
| timestamp / expiry (freshness) | maximum_bundle_age_secs, require_expiry, BundleCore.expires_at |
policy.rs:93-97, core.rs:26 |
| snapshot (rollback protection) | AcceptedState::check_admissible — refuses a lower sequence, a replayed sequence with different bytes, and a reused bundle_id with different bytes |
rollback.rs:65-111 |
| targets (name → digest) | ReleaseManifest + MemberDigest{sha256, blake3}; verify_members rejects unreferenced and missing members both |
manifest.rs:31,22,87 |
| verify-before-use | load_bundle_verified(path, policy) — verification precedes decompression |
container.rs:257 |
So the repository plane is not a new subsystem. It is update-bundle's BundleCore/ReleaseManifest/TrustPolicy triple, with payloads being the artifacts of repository B and evidence being the DSSE envelopes of §2a, and with one addition: the manifest must be re-signed on a cadence so expires_at advances even when the content does not. That cadence is the whole of "timestamp role", and it is the source of the honest problem in §6.
We are not adopting a TUF crate. A TUF library brings its own key format, its own role vocabulary and its own metadata expiry semantics, and holger would then have two trust roots that must agree — the certificate-fingerprint one that already governs ron_trusted_roots and secure boot, and TUF's. Two trust roots that must agree is the shape one implementation exists to prevent (gunnar-policy/src/context.rs:286-288 says exactly this about two lists). We take TUF's semantics, not its file format, and we say so in the schema string: holger.repo-metadata/v1.
3. The hook seam — the decision
The three candidates were (i) in-process trait objects, (ii) wasm plugins after a wasmtime bump, (iii) hooks running entirely outside holger with holger only verifying their signed attestations.
The answer is (iii). Holger never runs a hook. Holger verifies attestations.
The argument.
-
It is the only option that survives the sandbox problem rather than deferring it. wasm plugins are OFF in every tier because wasmtime 27 carries 17 advisories, four of them sandbox escapes. wasmtime 45.0.3 exists and is in a sibling lockfile, so (ii) is available. It is still wrong here: a gate is the one piece of holger that by construction runs operator-supplied, frequently updated, third-party code over untrusted bytes, inside the process that holds the promotion signing key. That is the worst possible place to bet on a sandbox, and the bet must be re-won on every wasmtime CVE forever. Option (iii) has no sandbox to escape because there is nothing to sandbox.
-
It is what in-toto and SLSA are for. The entire point of a detached, signed attestation is that the producer of a claim and the consumer of a claim need not share a process, a machine, a network, or a clock. Running the scanner inside holger and then signing its output is a strictly weaker arrangement than having the scanner sign its own output: it moves the trust boundary from "the scanner's key" to "holger's key plus every line of the scanner". modgunn already produces verifiable provenance (
modgunn/src/provenance.rs:700) and is a separate binary. Let it stay one. -
It is the only option compatible with the no-shelling law. The law (
workspace_nordisk/nornir-workspace.toml:23: "Use pure Rust 100% = no shelling, no python") forbidsfork/exec, which is how every other artifact server runs a promotion hook. gunnar reached this conclusion first and wrote it down:
"Hooks are in-process
Gateimpls, not shell scripts. There is no shell on the server to run one in, and there will not be one. … An out-of-process hook, if one is ever wanted, is a gRPC call to a gate service, neverfork/exec." —gunnar/crates/gunnar-policy/src/config.rs:3-8
Option (iii) is the stronger form of gunnar's escape hatch: not even a gRPC call at promote time, because an MVP holger has no outbound HTTP (§6) and a synchronous call to an analyser is a liveness dependency on the promotion path. The analyser pushes; holger reads what was pushed.
- The inbound door already exists.
SbomService.AttachSbom(proto/holger.proto:79) andPropertyService.SetProperty(:94) are already authenticated, already audited, already MVP. An attestation-attach door is the same shape:AttestationService.Attach(repo, artifact, dsse_envelope). The analyser is a client, not a callee. holger dials nobody.
What (i) is, in this design
Option (i) is not a rival; it is the mechanism by which (iii)'s verification is expressed, and it is copied from gunnar almost verbatim:
// server/lib/src/promote/gate.rs — NEW
pub enum Verdict { Allow, Reject(Rejection) }
pub struct Rejection { pub subject: Option<String>, pub reason: String }
/// One check that runs between the bytes being in hand and the seal being written.
/// A gate sees a `PromotionContext` and returns a `Verdict`. It cannot write,
/// cannot move an artifact, and cannot promote half a set.
pub trait Gate: Send + Sync {
fn name(&self) -> &str;
fn scope(&self) -> Scope; // PerArtifact | WholeSet
fn check(&self, ctx: &PromotionContext<'_>) -> Result<Verdict, GateError>;
}
with, exactly as in gunnar (gunnar-policy/src/lib.rs:181-184, :315):
Erris a refusal, never an allow. "The gate could not decide" and "the gate said yes" must not be the same outcome. gunnar's live site makes this concrete: when the pushed objects cannot be enumerated, every configured gate refuses, and the log line ispolicy.gate.undecidable(gunnar-server/src/transport.rs:2471-2486).- All gates run even after one refuses, so the operator learns everything wrong with one promotion in one round trip instead of one problem per attempt.
- The refusal names the gate, in the message, in the audit row, and in the error code. gunnar's
warn!line carriesgate = %first.gatefor exactly this reason, and the comment says what it replaced: an event "with an emptygatefield and no log line at all that said who refused it" (transport.rs:2530-2536). - An engine with no gates says so out loud rather than returning an empty decision, "because 'allowed by every gate' and 'there are no gates' are different facts" (
transport.rs:2461-2467). - An UNREADABLE gate configuration refuses everything. This is gunnar's GUN-007 and it is the single most valuable line in the whole seam.
PolicyGatecarriesunreadable: Option<String>(gunnar-server/src/transport.rs:2297); when set,checkrefuses every reference. The bug it fixed: an unreadable policy document becamePolicyConfig::default(), and an empty engine is let through, "so a document with a typo, or a storage fault, quietly turned branch protection andrequire_signatureoff for the duration". holger'sPolicyGate::from_envalready has the right instinct for one case — a configured-but-unparseable advisory feed is anErrso serve refuses to start (promote/mod.rs:82-86) — and must extend it to the whole gate document. - The decision and the fault are different
Results. gunnar's registry admission returnsResult<Result<Admitted, Refusal>>on purpose: "Collapsing the two would make 'this artifact has no provenance' indistinguishable from 'the disk is full'" (gunnar/crates/gunnar-http/src/registry.rs:38-41).AttestationGatehas exactly this shape — no attestation and could not read the attestation store are different facts that happen to produce the same refusal, and only one of them is the operator's to fix. - Ship an always-allow stand-in and prove the harness goes red against it.
gunnar-policy/src/blind.rs+tests/blind.rsexist for the reason stated atgunnar-policy/src/lib.rs:30-33: "A gate that always returnsAllowis indistinguishable from a working gate in any test that only pushes valid commits." holger's gate tests must include the same negative control.
A note on the DESIGNED escape hatch. gunnar's own sentence — "An out-of-process hook, if one is ever wanted, is a gRPC call to a gate service; never fork/exec" (gunnar-policy/src/config.rs:7-8) — is the only statement about an external hook in that repository, and it has no proto, no client and no call site. It is DESIGNED and unbuilt. This document takes the stronger line (§3.3): not even a synchronous gRPC call, because the analyser pushes its attestation in rather than being called out to. That removes the liveness dependency gunnar's sentence would have created.
And every Gate implementation holger ships is holger's own compiled-in code. The shipped set is small and each member is a verifier:
| gate | scope | what it checks |
AttestationGate |
per artifact | a DSSE envelope over an in-toto statement whose subject digest equals the bytes in hand, signed by a key in the trust policy, with a predicate type the config names as required |
DigestGate |
per artifact | sha256 and blake3 of the bytes match the source repository's index (item 9) |
PropertyGate |
per artifact | the existing require_property check (promote/mod.rs:398), unchanged |
ClosureGate |
whole set | every dependency of every promoted artifact is either in the set or already in the destination — the "no broken releases" invariant promotion-design.md §Executive summary asks for and nothing implements |
SetSignerGate |
whole set | the set's attestations are signed by at least M distinct fingerprints, via verify_threshold (znippy-common/src/sign.rs:1090) |
The existing PolicyGate (modgunn CVE/licence, promote/mod.rs:66) becomes ScannerGate and keeps its behaviour and its fail-closed from_env, so nothing that works today stops working.
A third-party gate that is not in this repo — if one is ever wanted — arrives as monetize's GateFactory: three fields and a function pointer (monetize/crates/monetize-pay/src/factory.rs:74-84), linked by a private crate's register(&mut Vec<GateFactory>), configured-but-not-linked refused by name at boot (monetize-server/src/registry.rs:388-398). Still no dynamic loading, still no sandbox.
What this refuses to do
- No wasm gates. Not after the wasmtime bump either. The bump is worth doing for package handlers (
plugin/abi/src/lib.rs:152, format logic over host-owned bytes — a much smaller blast radius); it is not what unblocks gates, and this document does not depend on it. - No shelling, no
fork/exec, no Python. House law. - No outbound call at promote time. A gate that dials an analyser is a gate that turns an analyser outage into a promotion outage, and an MVP holger has no outbound HTTP to dial with (§6).
4. The config shape
One new optional block on the root config node. Holger is #[serde(deny_unknown_fields)] (server/lib/src/lib.rs:352) and the reason is written out at :617-641 — a key holger does not understand is a key holger is not honouring, and the worst instance of that is a security control that reads as ON in the file and is OFF in the process. Every struct below carries the same attribute.
(
exposed_endpoints: [ ( ron_name: "cloud", ron_url: "https://10.101.1.9:8443" ) ],
storage_endpoints: [
( ron_name: "store-a", ron_storage_type: "znippy", ron_path: "/var/lib/holger/rust-a/" ),
( ron_name: "store-b", ron_storage_type: "znippy", ron_path: "/var/lib/holger/rust-b/" ),
],
repositories: [
( ron_name: "rust-a", ron_repo_type: "rust", ron_upstreams: [], ron_in: None,
ron_out: Some(( ron_storage_endpoint: "store-a", ron_exposed_endpoint: "cloud" )) ),
( ron_name: "rust-b", ron_repo_type: "rust", ron_upstreams: [], ron_in: None,
ron_out: Some(( ron_storage_endpoint: "store-b", ron_exposed_endpoint: "cloud" )),
// The downstream verifies THIS archive's detached CMS before serving it.
// A build without the `sign` feature REFUSES TO WIRE rather than serving
// it unverified (server/lib/src/repository.rs:583). `sign` rides in on
// every tier via server/cli/Cargo.toml:71 — including mvp-server.
ron_trusted_roots: Some("/etc/holger/trust/root.der") ),
],
// ── THE PROMOTION LADDER ──────────────────────────────────────────────────
// Absent ⇒ no stage model. `seal` judges nothing extra and behaves exactly as
// it did before stages existed (promote/mod.rs:412). This is slice 1.
promotion: Some((
ladder_name: "rust",
allow_skipping: false, // a jump past a rung is refused BEFORE any write
stages: [
( name: "a", repo: "rust-a", gated: false ), // ingest; rehearsal is ungated
( name: "b", repo: "rust-b", gated: true ), // the boundary that bites
],
// ── THE GATE SET ──────────────────────────────────────────────────────
// Only gates named here are built. An engine that builds zero gates SAYS SO
// (`gate_names()` is empty and boot logs it), because "allowed by every
// gate" and "there are no gates" are different facts.
//
// `enforcement` is per gate, not per engine:
// Block ⇒ a Reject REFUSES the promotion (fail closed)
// Warn ⇒ the verdict is recorded and attested, and the promotion proceeds
// A gate that returns Err (could not decide) REFUSES at Block and is
// recorded as `undecided` at Warn. It is never an allow.
gates: [
// ── per-package ───────────────────────────────────────────────────
( name: "attestation", scope: PerArtifact, enforcement: Block,
kind: Attestation((
// Each required predicate must be present, DSSE-verified against
// `trust`, and its in-toto subject digest must equal the sha256 of
// the bytes holger has in hand. A caller-supplied digest is never
// believed.
require_predicates: [
"https://slsa.dev/provenance/v1",
"https://cyclonedx.org/bom",
"https://nordisk.dev/modgunn/scan/v1",
],
// Age of the attestation itself, not of the artifact.
max_age_secs: Some(2592000), // 30 days
)) ),
( name: "digest", scope: PerArtifact, enforcement: Block,
kind: Digest(( require_sha256: true, require_blake3: true )) ),
( name: "scanner", scope: PerArtifact, enforcement: Block,
kind: Scanner((
// The EXISTING modgunn seam (promote/mod.rs:66), unchanged.
// A configured-but-unreadable advisory feed is a BOOT ERROR, not
// an empty gate that would wave promotions through.
advisory_db: Some("/var/lib/holger/advisories/osv-2026-09-19.json"),
deny_licenses: ["GPL-3.0-only", "AGPL-3.0-only"],
)) ),
( name: "signed-property", scope: PerArtifact, enforcement: Block,
kind: Property(( key: "signed", value: Some("true") )) ),
// ── whole-set ─────────────────────────────────────────────────────
( name: "closure", scope: WholeSet, enforcement: Block,
kind: Closure((
// Every dependency of every promoted artifact resolves either
// inside this promotion set or already in the destination.
// A closure that cannot be COMPUTED is `undecided` ⇒ refused.
allow_preexisting_in_destination: true,
)) ),
( name: "two-person", scope: WholeSet, enforcement: Block,
kind: Threshold((
// M-of-N over DISTINCT signer certificate fingerprints, via
// znippy_common::sign::verify_threshold. M=0 is not spellable
// here; the crate refuses it (sign.rs:1090-1109).
minimum_distinct_signers: 2,
required_roles: ["security-reviewer", "system-owner"],
release_signer_must_be_distinct: true,
)) ),
],
// ── WHAT PROMOTION EMITS ──────────────────────────────────────────────
emit: ((
// One DSSE-signed in-toto statement per gate verdict, subject bound by
// sha256 of the bytes. Absent ⇒ nothing is attested and the downstream
// has nothing to verify; boot WARNS loudly when gates are configured
// and emit is not.
per_verdict_statements: true,
// The one object the downstream checks instead of re-running scanners.
verification_summary: true,
verifier_id: "https://nordisk.dev/holger/cloud-1",
signer: (( key: "/var/lib/holger/keys/promote.p8", cert: "/var/lib/holger/keys/promote.der" )),
)),
)),
// ── THE TRUST ROOT ────────────────────────────────────────────────────────
// `holger.bundle-trust-policy/v1`, the format update-bundle/src/policy.rs:30
// ALREADY parses and enforces. Trust is by certificate FINGERPRINT (sha256 of
// the cert DER) — never by a role string a signer writes into its own
// statement. `trusted_roots: []` is certificate-PINNING mode.
//
// This is the same fingerprint vocabulary as `ron_trusted_roots` above and as
// the secure-boot ceremony (`cargo xtask installer-iso --sign <key>
// --root-key <seed>`, xtask/src/installer_iso.rs:50-53), so a site has ONE
// root-of-trust ceremony, not three. The TUF-style root below can be enrolled
// by that same ceremony: root.pub on the ESP, require_signed on the cmdline.
trust: Some("/etc/holger/trust/policy.ron"),
)
and the trust policy it points at — an existing, parsed, enforced format, shown here with the repository-plane fields that §2b adds:
// /etc/holger/trust/policy.ron — holger.bundle-trust-policy/v1
(
schema_version: "holger.bundle-trust-policy/v1",
trusted_roots: [], // [] = certificate-pinning mode
approved_signers: [
( fingerprint: "9f2c…", subject: Some("CN=security-reviewer"), roles: ["security-reviewer"] ),
( fingerprint: "41ab…", subject: Some("CN=system-owner"), roles: ["system-owner"] ),
( fingerprint: "7d10…", subject: Some("CN=holger-cloud-1"), roles: ["vsa-verifier"] ),
],
required_approval_roles: ["security-reviewer", "system-owner"],
minimum_distinct_approvers: 2,
trusted_release_signers: ["c3e8…"],
release_signer_must_be_distinct: true,
maximum_bundle_age_secs: Some(31536000), // 365 days — freshness (§6 open problem)
require_expiry: true,
require_sbom: true,
require_vex: false,
require_scan: true,
allowed_products: [],
allowed_models: [],
)
A note on the siblings' config formats, so nobody looks for the wrong file. gunnar has no estate.toml and no RON anywhere (confirmed by search over both gunnar and gunnar-ui); it configures by CLI flags + ~100 GUNNAR_* env vars (gunnar/crates/gunnar-common/src/cli.rs), per-data-root JSON documents whose only editor is a control-plane RPC (gunnar-policy.json, webhooks.json, replication.json, wg0.conf), a versioned git-blob control document for the ACL (gunnar-core/src/control.rs:174), and the kernel command line for facts that must be readable before any store opens — never for secrets, because /proc/cmdline is world-readable (gunnar-server/src/wg.rs:39-44). monetize is TOML, one file, /etc/monetize/monetize.toml (monetize-server/src/config.rs:397), with env used only for secrets so they stay out of the 0644 file (config.rs:178-182). RON is holger's own dialect and this design stays in it. The transferable rule is gunnar's, and holger should adopt it: a feature that ships and cannot be turned on is not shipped — "the reference appliance runs as PID 1 with no editor and no scp, so the only way to write <data-root>/webhooks.json is over the control plane" (gunnar/crates/gunnar-proto/proto/gunnar/v1/webhooks.proto:15-19). A holger appliance is the same kind of box, so the gate set and the trust policy each need a control-plane writer, not only a file an operator is assumed to be able to edit.
Declaring a new extension point — the recipe
Both siblings converged on the same four steps and holger should follow them verbatim. monetize's [tax] seam is the freshest instance and shows all four (monetize/crates/monetize/src/tax.rs:474,536,495; monetize-server/src/registry.rs:50,106; monetize-server/src/config.rs:495-508); gunnar's list of fifteen live seams shows the same four steps applied to every one of them (gunnar/crates/gunnar-policy/src/config.rs:57, lib.rs:286, blind.rs):
- Declare the trait in the open tree (
trait Gate, above). - Declare the build context + factory —
GateBuild<'a> { name, data_root, settings: Settings<'a> }andtype GateBuilder = fn(&GateBuild) -> Result<Box<dyn Gate>, String>. No trait, no generics; three fields and a function pointer. - Write the loud default so an unwired deployment is a working deployment where that is safe, and a refusing one where it is not. For holger: a gate list that is present but empty is a boot error (you asked for gating and named none), a gate list that is absent is unconditional promotion exactly as today, and it is logged at boot in both cases.
- Add the untyped config block and the slot on the linked set, and refuse a configured-but-unlinked name by name at boot.
- Expose "which ones are live" on the control plane —
PolicyEngine::gate_names()→Policy.ListGatesin gunnar (gunnar-policy/src/lib.rs:286-295), because "a repository whose protection rules failed to load would look exactly like one that has none". holger's equivalent is aPromotionService.ListGatesRPC, and it is not optional: with GUN-007 above, failed to load and none configured are the two states an operator must be able to tell apart from outside. - Ship an always-allow stand-in and prove the harness goes red against it (
gunnar-policy/src/blind.rs).
5. One holger or two — promotion and replication must be the same verb
This is the item that decides whether going from one instance to two is a config change or a rewrite, and holger is currently half right.
What is already right. replicate_pairs(pairs: &[RepoPair], opts) is pure over Arc<dyn RepositoryBackendTrait> (server/lib/src/replication.rs:168, RepoPair at :156). It does not know, and cannot ask, whether its source and destination are in the same process. That is exactly the endpoint-agnostic shape the two-holger world needs: a remote destination is a RepositoryBackendTrait implementation that speaks gRPC, and replicate_pairs never learns the difference. The design doc says so (replication-design.md:28-31).
What is wrong. PromotionEngine::seal(from_repo: &str, to_repo: &str, data, …) (promote/mod.rs:566) takes repository names as strings and writes into a private blake3 blob+ref tree under <data_root>/holger-prod-store that no backend serves (item 8). to_repo is never resolved to a Repository. So promotion is not a transfer between two endpoints; it is a write into a side store that happens to be keyed by a string that looks like a repository name. That store cannot be a remote endpoint, because it is not an endpoint at all.
The unification. Promotion becomes replication with an evidence step:
// The ONE verb. The endpoints differ; nothing else does.
pub struct Transfer<'a> {
pub pair: &'a RepoPair, // (source backend, destination backend)
pub engine: &'a PolicyEngine, // §3 — empty engine ⇒ ungated copy = today's replicate
pub evidence: Option<&'a EvidenceSink>, // §2a — None ⇒ no attestation = today's replicate
}
pub fn transfer(t: &Transfer<'_>, opts: &ReplicationOptions) -> TransferReport;
- Promotion, one holger:
pair = (rust-a backend, rust-b backend), both resolved fromFastRoutesin the same process. Gates run. Evidence is emitted. The destination is a real repository, so what was promoted is what is served. - Sync down, two holgers:
pair = (remote rust-b client backend, local rust-b backend). The same function. The downstream's engine holds one gate,AttestationGate, atBlock, so "sync down and verify signatures" is not a second code path — it istransferwith a different gate set. - Replication as it is today: empty engine, no evidence sink. Byte-for-byte the current behaviour, which is what makes this landable without a flag day.
The immutability guard survives. The seal's create_new ref (promote/mod.rs:660) is what makes a promoted coordinate impossible to overwrite, and it is race-safe. That guard moves onto the destination backend's put path as a WriteOnce/SealedBackend wrapper rather than being deleted. Losing it would trade a correctness property for an architectural one, and that is not a trade this document is making.
The corollary, stated because it is uncomfortable: the existing blake3 blob+ref store under holger-prod-store becomes the evidence store — the right home for DSSE envelopes and VSAs, which genuinely are content-addressed side data that nothing serves as a package — and stops being the artifact store. Existing sealed refs are readable by a compatibility path; they are not rewritten. A sealed ref is a signed historical fact and rewriting one would be the exact lie the whole seal exists to prevent.
6. MVP, and the honest open problems
6a. What "MVP" costs, said plainly
MVP is mvp-server = ["mini", "secure_update"] and mini = ["auth-bearer", "auth-mtls"] (server/cli/Cargo.toml:140,150-155). Three consequences:
-
No OIDC and no OAuth2 in MVP. The gate's identity model must work with bearer + mTLS only. This is fine and arguably better: promotion authority in this design is expressed as a certificate fingerprint in the trust policy (
update-bundle/src/policy.rs:19-21), not as a token claim. The mTLS CN → role mapping already inauth.rscovers "who may callPromote"; the trust policy covers "whose attestation counts". Those are different questions and it is correct that they have different answers. Requirement, not a change I am making: nothing in §3 or §4 may grow a dependency onserver-lib/onlineor on an OIDC claim. -
MVP omits
onlineon purpose — no outbound HTTP, provably (server/cli/Cargo.toml:125-131, the zero-egress keystone). So an MVP holger can produce a signed repository-metadata bundle and can verify and import one, but it cannot pull one down over the network. State the tension rather than glossing it:- MVP transport is the carried bundle. The downstream's "sync" is
holger bundle importover a bundle that crossed the boundary on media. That is the airgap posture the product is for, it issecure_updatewhich MVP already has, andload_bundle_verifiedalready verifies before it decompresses (update-bundle/src/container.rs:257). - Pull-based sync needs
onlineand is therefore afull-tier capability, not MVP. The verb is the same (§5); only the destination backend's transport differs. This is exactly why §5 matters: if promotion and sync are one verb, adding the online puller later is a newRepositoryBackendTraitimpl, not a rewrite of the sync. - Do not paper over this by making the gate dial out. §3 already forbids it.
- MVP transport is the carried bundle. The downstream's "sync" is
-
Promotion already ships in the MVP binary while not being declared MVP. The CLI
Promotesubcommand carries no#[cfg]gate, soholger-mvphas it today. Declaring it MVP makes the declaration honest rather than adding weight. Requirement for the recipe owner (I do not ownholger-recipe/or.nornir/SUITES.md): themvp/servercell's capability string should name promotion + attestation + verify-on-import alongsidesecure_update, and themvptier row atholger-build/src/lib.rs:11already says "provenance receipts" and "modgunn content-verdict" — those are this capability under another name.
6b. TUF metadata expiry inside an airgap — the real problem
This is the hardest thing in the document and there is no clean answer.
Repository metadata expires on purpose: expiry is what converts "I have not heard from the repository" into a refusal rather than into indefinite trust in a stale listing. It is the defence against a freeze attack, where an adversary who controls the transport simply stops delivering updates and the client never learns that a revocation happened.
Inside an airgap there is, by construction, nobody to hear from. So:
- Short expiry (days): correct against freeze, and the downstream bricks the first time a courier is late. In a protected facility, "late" is the normal case.
- Long expiry (a year): the downstream keeps working, and a compromised artifact stays trusted for up to a year after the cloud side revoked it, because the revocation cannot arrive.
- No expiry: a freeze attack is free and silent.
What this design does, and what it refuses to pretend.
- Expiry is enforced, and a refusal names expiry as expiry.
require_expiryandmaximum_bundle_age_secsalready exist (policy.rs:93-97) and the refusal must say "this repository metadata expired on ", never "signature verification failed". gunnar'srequiremakes the same distinction for gates vs grants and it is the right instinct (gunnar-core/src/auth.rs:1602-1620). - Monotonic sequence does the work expiry cannot.
check_admissible(rollback.rs:65) refuses a lower sequence, a replayed sequence with different bytes, and a reused id with different bytes — so a freeze attack cannot roll back, only stall. Stalling is detectable; rolling back is not. The sequence guard is the load-bearing control and the expiry is the alarm. - An expired-metadata downstream degrades to read-only, it does not brick. Serving what is already on disk while refusing to accept anything new is the behaviour that matches the posture: the facility keeps building, and nothing new crosses the boundary until a courier arrives.
OperationMode::ReadOnlyalready exists (server/lib/src/lib.rs:342) and is the mechanism. This is a decision Rickard must ratify, because the alternative reading — refuse to serve — is defensible and some customers will want it. It should be a config field with no default, so nobody gets it by accident. - The clock is the unstated dependency and it is not solved here. Every expiry check is a claim about
now, and an airgapped box's clock is exactly what an attacker with physical access would move. gunnar solved this and holger has not looked at it:peer://signed time,GUNNAR-TIME/1 <nonce>→<nonce> <unix_ms>+ SSHSIG under namespacegunnar-time-v1@gunnar.rs, with nonce anti-replay and a bounded round trip (gunnar/crates/gunnar-clock/src/peer.rs:12-20,68), the peer key taken from the kernel command line rather than the key table (:50-57), and a skew refusal that triggers a resync (gunnar/crates/gunnar-grpc/src/auth.rs:1377). OPEN: holger should adoptgunnar-clock'speer://rather than trustSystemTime. Not in this design's scope; named so it is not discovered later.
6c. The other open problems
- What is a "dependency closure" for eleven ecosystems?
ClosureGateis easy for Rust (the sparse index is right there) and hard for Docker, Helm and Deb. The honest first shipment is: the gate declares whichArtifactFormats it can compute a closure for, and refuses —undecided, therefore a refusal atBlock— for a format it cannot. A closure gate that silently passes formats it does not understand is worse than no closure gate. - Attestation storage cost. N artifacts × M gates envelopes per promotion. They dedup by content address, but a 50 000-crate promotion with five gates is 250 000 small files. Measure before shipping; the znippy archive is the obvious home and
bench-scenarios/src/promote.rsis the obvious place to measure it. ron_trusted_rootsis per-repository and the promotion trust policy is per-server. Two trust configurations that must agree. They should be one; the design above pointstrust:at the same fingerprint vocabulary, but the code paths are still separate and that is drift waiting to happen.- Key rotation. monetize has none and says so (no key id in the signed form, no multi-key trust set).
TrustPolicy.approved_signersis a list, so holger is structurally better off — but nothing rotates it, and "how does a new root reach an airgapped downstream" is the same courier problem as 6b. - The
signfeature is reachable but nothing names it. No variant listssignin its own feature list; it rides the dependency edge (server/cli/Cargo.toml:71). That works and is documented in prose at:69-70, but a variant that stopped inheriting it would silently lose provenance verification, and the only thing that would notice iswire_verified_znippy's refusal at boot — which only fires if a repo actually setsron_trusted_roots. Worth a guard.
7. The implementation plan — ordered, sliced, each independently landable
Each slice is a commit or a small series, is green on its own, and leaves the tree shippable. Slices 1–3 change no crypto and add no dependency.
| # | Slice | Deliverable | Proof it worked | Risk |
| 1 | Attach the StageLadder | PromotionSettings on the RON root (#[serde(deny_unknown_fields)]), Holger::promotion_engine() calls with_ladder. Absent ⇒ byte-for-byte today. |
A RON config with two stages refuses a backwards hop and a skipped rung through Holger::promotion_engine(), not through a hand-built ladder. Red first: the test fails before the field exists. |
Low. StageLadder is already Serialize + Deserialize (stages.rs:169) and the empty ladder is already the no-judgement path (mod.rs:412). |
| 2 | Reconcile the digests | PromotionRecord carries sha256 and blake3 (the update-bundle Digests shape, digest.rs:56). blake3 stays the ref key so no existing seal changes. |
A sealed ref round-trips both digests; an old ref with only content_address still reads back and still verifies. |
Low. Additive field; the integrity check on read is unchanged. |
| 3 | Make the promoted store readable | Holger::fetch consults the promotion store for a coordinate sealed into that repo, so PromotionEngine::fetch (mod.rs:699) has a caller outside tests. Integrity-on-read (blake3 vs ref) becomes a live property. |
Promote an artifact into rust-b, fetch it over gRPC, get the bytes. Corrupt the blob, fetch, get the integrity refusal with its error code. |
Low-medium. Touches a serve path; guarded by the existing fetch tests. |
| 4 | The Gate trait + PolicyEngine |
promote/gate.rs, copied from gunnar-policy/src/lib.rs:173-340. PolicyGate re-expressed as ScannerGate with identical behaviour. Err ⇒ refuse. All gates run. Refusal names the gate. New holger-promote-<n> codes, wired (the registry's guard fails a wired row nothing raises — errcode/src/lib.rs:31-35). |
The existing gate_blocks_vulnerable_and_passes_clean and warn_gate_permits_even_a_blocking_verdict tests pass unchanged through the new engine. A gate returning Err refuses. Two gates both refuse in one promotion. |
Medium. Behaviour-preserving refactor of a live gate. |
| 5 | Gates see bytes; sets exist | PromotionContext carries &[u8] per artifact as a required constructor argument (gunnar's reason, context.rs:289-293). PromoteSet RPC over N artifacts; Scope::WholeSet gates run once over the set. |
A per-artifact gate that digests the bytes; a whole-set gate that counts members. A set promotion is atomic: one refusal writes nothing. | Medium. New RPC; the atomicity is the part to test hard. |
| 6 | The gate set in RON + ListGates |
The §4 gates: block. Only named gates are built; an empty-but-present list is a boot error; an absent block is unconditional promotion; an unreadable one refuses everything (GUN-007). PromotionService.ListGates answers what is live. |
A config naming an unknown gate is refused by name at boot (monetize's rule, registry.rs:388-398). An unreadable gate document refuses a promotion and the message says so. A config with gates and no emit warns loudly. |
Low. Config plumbing over slice 4. |
| 7 | The evidence plane | Depend on znippy-common's DSSE (sign.rs:1347,1375) — already in the graph via the sign feature. One in-toto Statement v1 per verdict, subject bound by sha256. AttestationService.Attach inbound door. |
A verdict produces an envelope dsse_verify accepts and a tampered one it rejects. A statement whose subject digest ≠ the bytes is refused. PAE is checked against the spec vector znippy already has. |
Medium-high. First crypto slice. No new algorithm, no new key handling — reuse only. |
| 8 | The VSA | SLSA VSA predicate emitted at the promotion boundary, DSSE-signed, subject = sha256. AttestationGate verifies one on the downstream side. |
Downstream accepts an artifact on its VSA alone, with no scanner run and no advisory feed present. Downstream refuses the same artifact when the VSA is signed by a key not in the trust policy, and the message says "not a trusted verifier". | Medium. |
| 9 | The repository plane | holger.repo-metadata/v1 = BundleCore + ReleaseManifest + TrustPolicy over repo B's target list. Re-sign cadence. RollbackStore (rollback.rs:112) applied to repo metadata, not just bundles. |
Freshness refusal names expiry. A lower sequence is refused. A reused id with different bytes is refused as a forgery signal, which is what BundleIdReuse already means (rollback.rs:73). |
Medium. Reuses an enforced format. |
| 10 | One verb | transfer(Transfer{pair, engine, evidence}). Promotion = gated+attested transfer between two in-process backends. Sync = the same call with a remote source backend and an AttestationGate. Today's replicate_pairs = empty engine, no sink. |
Existing replication tests pass through transfer unchanged. A promotion and a sync are the same function with different arguments, shown by a test that calls it twice. |
High. The architectural slice. Land it last, on top of nine green ones. |
| 11 | (post-MVP) Pull-based sync | A RepositoryBackendTrait impl that speaks gRPC to a remote holger, with gunnar-style per-call method-bound single-use credentials (gunnar-grpc/src/auth.rs:13-17). |
Two holgers, one transfer. | Needs online; not MVP (§6a). |
Slice 1 is starting now. It is the only slice that is both self-proving and zero-risk: the ladder type is complete and tested, its empty case is already the unchanged path, and the gap it closes — "a full StageLadder exists and is never attached outside tests" — is the exact unwired-to-wired step that makes every later slice have somewhere to attach to.
8. What this design deliberately does not do
- It does not enable wasm plugins or bump wasmtime. §3 argues the gate seam should not be wasm at all; the bump remains worth doing for package handlers and is a different project.
- It does not adopt a TUF crate, a sigstore crate, or an in-toto crate. Every primitive is already in the constellation and a second key vocabulary is a liability (§2b).
- It does not define a seventh envelope. DSSE, in-toto Statement v1 and SLSA Provenance v1 are implemented spec-exact at
znippy/znippy-common/src/sign.rs:1190andedda/crates/nornir-build-thing/src/provenance.rs:297. Reuse, do not re-declare. - It does not touch
.nornir/SUITES.mdorholger-recipe/. The MVP declaration is another owner's; §6a states the requirement instead. - It does not shell out, fork, exec, or link a Python interpreter. House law, and §3's choice of (iii) is what makes obeying it easy rather than painful.