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

nordisk/znippy

.nornir/elf-design.md

ELF binaries as a znippy format — design

Class: design · siblings: plugins-design.md · signing-design.md · signed-bundle-design.md · ../plugins/native/znippy-plugin-skidbladnir/.nornir/znippy-plugin-skidbladnir.md.

Status: §1–§5 are BUILT and green (plugins/native/znippy-plugin-elf, 53 unit tests

  • an end-to-end round trip); §6–§8 are design. It states the shape we ship and, where it departs from the order that commissioned it, says so by name in §10.

1. The order, and what it is for

A built binary should be storable, searchable, verifiable and refusable by name.

Tonight nobody could answer "which artifacts were built with the wrong toolchain image" when :latest had drifted from the pin. That question is the reason this format exists. Every column below is judged against a question somebody actually had to answer and could not. A column that carries no question does not ship.

Three deliverables, in order. This note covers all three plus the follow-on the owner added:

# what
1 this note
2 znippy-plugin-elf — a native builtin, --format elf, metadata columns only
3 provenance in a DSSE envelope, in-toto Statement + SLSA v1 predicate, signed through the path that already exists
4 holger speaks debuginfod, so a lean appliance ships no debug info and a developer can still get it

2. The one law that shapes everything: the facts come from the binary

A path is a claim. A header is a fact.

The existing elf handler is a skeleton — znippy-common/src/plugins/native/skeletons.rs:161 — and its extract_metadata parses the filename (skeletons.rs:124, parse_coords). It declares no schema_fields(), so --format elf today writes a name and a version guessed from a file name and nothing else. That is precisely the thing this format exists to stop.

So the rule, stated once and obeyed everywhere below:

E-0 — a path may only widen candidacy; it may never assert a fact. matches_path is a cheap superset. The authority is \x7fELF in the bytes and the structures behind it. A member whose bytes are not an ELF gets no ELF columns — not a guess.

The single exception is deliberate and is itself a column: arch_claim records what the path claims, precisely so the archive can be asked which members lie (§4.6).


3. Which type_id — and why the order is challenged here

The order said "its own fresh type_id". I am taking type_id = 7 instead, and this is the one instruction in the brief I am not following. The reason:

elf is already taken. type_id 7, name "elf", aliases binary / so (skeletons.rs:161-168), and it is listed in .nornir/plugins-design.md §f3 as a reserved id awaiting promotion.

find_handler takes the first match in builtin_handlers() order, and that vector is sort_by_key(|h| h.type_id()) (znippy-cli/src/handlers.rs:57,64-69). A fresh type_id 43 named elf would therefore sit behind the skeleton at 7: --format elf would silently select the filename-only stub and seal the archive with pkg_type = 7 and no columns. That exact failure has already happened once in this repo — the image alias collision between docker (12) and media (25), written up in skeletons.rs:193-200. Repeating it knowingly would be worse than the first time.

So: promote the skeleton, do not shadow it. znippy-plugin-elf takes type_id 7, the name elf and the aliases binary / so, and the ElfPlugin skeleton is deleted in the same change. One id, one handler, no ambiguity — which is what "a fresh type_id" was asking for; only the number is different.

Tier. It needs a real ELF parser and a zlib inflate, so rule P-5 (.nornir/plugins-design.md §f3) puts it in its own crate: plugins/native/znippy-plugin-elf/, not in znippy-common. Promotion out of skeletons.rs and promotion across tiers are the same move here.


4. The columns

4.0 The two constraints that shape the schema

  1. The index writer understands Utf8 and UInt32 and nothing else. build_ext_column (znippy-common/src/index.rs:222-256) builds a UInt32Builder for DataType::UInt32 and a StringBuilder for everything else; StrList and Bytes fall through to null. A List<Utf8> column would be a change to znippy's core writer, and a plugin that changes the core writer is not obeying P-2. Every list below is therefore a canonical, documented, splittable string.
  2. The base index already carries relative_path, uncompressed_size and a 32-byte blake3 checksum (index.rs:45-52). Duplicating them is forbidden by the same precedent skidbladnir set ("the per-entry BLAKE3 checksum already lives in the base index columns, so it is not duplicated here").

4.1 Identity — what this thing is

column type read from the query it makes possible
member_kind Utf8 e_type + PT_INTERP + DT_SONAME + .modinfo WHERE member_kind = 'shared-object' — the sibling convention, and it answers what elf_type cannot: PIE executable vs shared library are both ET_DYN. Values: executable · pie-executable · shared-object · relocatable · kernel-module · core · debuginfo.
elf_build_id Utf8 .note.gnu.build-id (NT_GNU_BUILD_ID) the single most load-bearing column. It is the only identifier that survives stripping, copying and renaming. It is the join key between a shipped stripped binary, its separate .debug file, a core dump and a debuginfod request (§8). WHERE elf_build_id = '…' is how "what is this thing on the crashed box" gets answered.

elf_build_id is null for a binary built without --build-id. Null is a finding, not a shrug: WHERE elf_build_id IS NULL is "which of our artifacts can never be debugged after the fact".

4.2 The header — what it can run on

column type read from the query
elf_machine Utf8 e_machine, canonical name (x86-64, aarch64, riscv64) WHERE elf_machine <> 'aarch64' — "what in this archive cannot run on the new nodes"
elf_class Utf8 e_ident[EI_CLASS] → ELF32/ELF64 the 32/64 split; on its own it is thin, but it is one byte and it is the difference between "won't run" and "won't run for a different reason"
elf_osabi Utf8 e_ident[EI_OSABI] → SysV/GNU/FreeBSD/… WHERE elf_osabi <> 'SysV' — the rare-but-real case where a binary was linked for the wrong OS personality
elf_type Utf8 e_type → exec/dyn/rel/core the raw fact behind member_kind, kept because member_kind is an interpretation and the raw value must stay auditable
elf_interp Utf8, nullable PT_INTERP null = static. WHERE elf_interp IS NULL is "which artifacts can boot on a box with no libc", and WHERE elf_interp LIKE '%musl%' is the musl/glibc split. This is the whole basis of the elf-libc refusal.
elf_abi_level Utf8, nullable .note.gnu.property, GNU_PROPERTY_X86_ISA_1_NEEDED → x86-64-v2/v3/v4; on aarch64 GNU_PROPERTY_AARCH64_FEATURE_1_AND → bti/pac WHERE elf_abi_level = 'x86-64-v3' — "which artifacts will SIGILL on the older hosts". This is the fact behind elf-cpu.

4.3 Dependencies — the libssl question

column type read from the query
elf_needed Utf8, nullable PT_DYNAMIC → DT_NEEDED sonames, each augmented with its symbol-version requirement from .gnu.version_r "which binaries link libssl 3.0.x"

Canonical form: space-separated, each entry soname or soname(VERSION), sorted:

libc.so.6(GLIBC_2.34) libcrypto.so.3(OPENSSL_3.0.0) libssl.so.3(OPENSSL_3.0.0)
SELECT relative_path FROM elf WHERE elf_needed LIKE '%libssl.so.3(OPENSSL_3.0%';

The soname carries the ABI major (libssl.so.3); .gnu.version_r carries the minor the binary actually demands (OPENSSL_3.0.0). Neither alone answers the question, so both are in the string. It is a flat string only because the writer has no list type (§4.0.1) — that is a limitation being recorded, not a preference.

elf_rpath / DT_RUNPATH is deliberately not a column (§10).

4.4 Cost — what a fetcher pays before it fetches

column type read from the query
text_bytes UInt32 sum of sh_size over allocated SHF_EXECINSTR sections "how much of this artifact is actually code" — the number that does not move when someone adds a 40 MB asset
debug_bytes UInt32 sum of sh_size over .debug_* / .zdebug_* the debuginfod question: how much does the appliance save by not shipping this, and how much will the developer pay to fetch it (§8)
has_debug UInt32 (0/1) .debug_* present or .gnu_debuglink present has_debug = 1 AND debug_bytes = 0 is exactly "stripped, symbols live elsewhere" — the case a single byte count cannot express. has_debug = 0 is "there is nothing to fetch, ever".

total_bytes is not a column. The base index already carries uncompressed_size (index.rs:49), and the precedent against duplicating a base column is established (§4.0.2). A fetcher gets total_bytes from the base index in the same SELECT.

4.5 Recipe — read from the binary, authority elsewhere

These four are the reason the format was ordered.

column type read from the query
toolchain_image_sha256 Utf8, nullable .note.package JSON "which artifacts were built with the wrong toolchain image" — WHERE toolchain_image_sha256 <> '<the pin>'. The column that closes tonight's hole.
source_commit Utf8, nullable .note.package JSON "which artifacts predate the fix" — WHERE source_commit = '<bad sha>'
recipe_variant Utf8, nullable .note.package JSON "show me the metered build, not the open one" — WHERE recipe_variant = 'gunnar-installer-metered'
recipe_features Utf8, nullable .note.package JSON, sorted, space-separated "which artifacts shipped with containers on"

Where they come from — and the section we did not know about. The estate already embeds a build receipt in an ELF section, and it is by far the best source for all four columns:

section .nordisk.buildthing — nornir_build_thing::contract::RECEIPT_SECTION (edda/crates/nornir-build-thing/src/contract.rs:44, RECEIPT_VERSION = 1 at :51)
reader receipt.rs:101 read_receipt(artifact) / :126 decode
payload struct Receipt { receipt_version, build_thing: Coordinate, atom, target, settings: BTreeMap, inputs: BTreeMap } (contract.rs:189)

Receipt.build_thing is a Coordinate { krate, version, hash } (contract.rs:123) whose hash is always populated in a receipt — the recipe's content hash. settings are the resolved settings, sorted; inputs carry commit, toolchain and triple. It is BTreeMap on purpose, so two identical builds serialize byte-identically.

So the read order is, per column, most authoritative first, and null rather than a guess:

column 1st 2nd
recipe_variant Receipt.atom + Receipt.build_thing .note.package
recipe_features Receipt.settings (sorted, already) .note.package
source_commit Receipt.inputs["commit"] .note.package
toolchain_image / toolchain_image_sha256 Receipt.inputs toolchain entry .note.package

Two extra columns fall out of the receipt and earn their place immediately:

column type read from the query
recipe_coordinate Utf8, nullable Receipt.build_thing.to_string() → krate:version:hash "which artifacts were built from which recipe code" — WHERE recipe_coordinate NOT LIKE '%:<the good hash>'. The hash is SHA-256 over the recipe crate's src/**/*.rs in sorted path order (znippy-build-thing/build.rs:1-45, consumed at znippy-build-thing/src/recipe.rs:56). It changes when the recipe changes, which no other column detects.
recipe_target Utf8, nullable Receipt.target — exe / container / iso-installer WHERE recipe_target = 'iso-installer' — the kind of thing that was built, from the build's own words rather than from where it landed

.note.package stays as the second source, not as a curiosity: it is the systemd/Fedora ELF package metadata note (owner FDO, type 0xcafe1a7e, NUL-terminated JSON), its spec permits additional fields, and a binary carrying it is legible to coredumpctl and to Fedora tooling outside the estate. A nordisk-built binary should carry both; a foreign binary will carry at most one; a stripped-of-notes binary carries neither and every one of these columns is null, which is itself the finding.

Spelling, so nothing new is invented:

This column already has a live disagreement to find, which is the whole point. .nornir/build-variants.toml in znippy (:60-61) and in gunnar (:2367-2368) both pin nordisk-cargo-toolchain at 3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143. dwarves' own template constant (dwarves/crates/dwarves/src/build_thing/code.rs:855) says f29e517c1ba34b920c1234fbd628046d64c0f1072d85ae7dee52b5cce054a917 for the same image and the same rust_version. Two pins, one image, nobody's binary says which it got. That is tonight's question with a file:line, and toolchain_image_sha256 as a column is the thing that turns it into one SELECT.

We are not inventing a section or a field name anywhere in this table.

Authority. These columns are searchable copies, not the record. The authority is the build-thing provenance artifact the statement points at (§6). Two records that can disagree are worse than one that is pointed at — so this note says, and the plugin's doc comment repeats, which is which:

The .note.package fields in the index answer "which ones" in one SQL scan. The referenced build-thing attestation answers "is that true". When they disagree, the attestation wins and the refusal is elf-recipe.

4.6 The lie — the owner's extra query

column type read from the query
arch_claim Utf8, nullable the path — a target triple or arch token in it (…/x86_64-unknown-linux-gnu/release/…, …/aarch64/…) the claim, recorded as a claim
arch_lies UInt32 (0/1) arch_claim vs elf_machine through the triple→e_machine table "which members lie about their architecture"
SELECT relative_path, arch_claim, elf_machine FROM elf WHERE arch_lies = 1;

arch_lies is not derivable in SQL by a reader, because the triple→machine mapping lives in the plugin. That is what earns it its own column rather than leaving the reader to CASE WHEN. arch_claim IS NULL means the path claimed nothing — which is not a lie, and arch_lies is 0.

This is the same condition as the elf-machine refusal (§5), seen from the other end: the column is the archive-wide sweep, the refusal is the per-start check. One rule, two vantage points, no second implementation.

4.7 The audit root

column type read from the query
auditable_root Utf8, nullable .dep-v0 (cargo-auditable), zlib-inflated JSON, the root: true package → "name version" WHERE auditable_root IS NULL — "which shipped binaries cannot be swept for a CVE at all", and WHERE auditable_root LIKE 'gunnar %' to find every build of one crate regardless of where it was filed

.dep-v0 is cargo-auditable's embedded dependency tree: a section holding zlib-compressed JSON ({"packages":[{"name","version","source","kind","dependencies", "root"}]}). Again — an existing convention, read rather than reinvented. The inflate uses linflate (znippy-zoomies/linflate/src/lib.rs:236, inflate_to_vec), which znippy already depends on through znippy-common — no new decompressor, LAW-conformant reuse.

The whole tree is not a column (§10). The section's bytes are in the archive; the column is the entry point.

4.8 The full schema — 22 columns

member_kind  elf_build_id
elf_machine  elf_class  elf_osabi  elf_type  elf_interp  elf_abi_level
elf_needed
text_bytes  debug_bytes  has_debug
recipe_coordinate  recipe_variant  recipe_features  recipe_target
toolchain_image  toolchain_image_sha256  source_commit
arch_claim  arch_lies
auditable_root

UInt32: text_bytes, debug_bytes, has_debug, arch_lies. Everything else Utf8, all nullable.

logical_path is not among them. skidbladnir and rust-toolchain carry it because their logical key differs from the stored path (dokument.fil_nyckel). ELF has no such second name; the base index's relative_path is the path. Copying a column because the siblings have it is exactly the cross-linking the brief forbids.


5. The refusals

House style, verbatim from the estate (gunnar/xtask/src/isoinstaller.rs, edda/crates/korp-installer/src/installer_source.rs:257):

REFUSED [<kebab-name>] <one sentence naming BOTH sides and the fix>

The elf-* namespace is free — nothing in gunnar, holger or edda uses it today.

None of these excludes hardware. Every one of them turns a box that starts and dies into a box that says why, before it starts.

name fires when the sentence names
elf-machine e_machine disagrees with the coordinate (or with the host) the header's machine, the coordinate's machine, and that one of the two is wrong — this is arch_lies = 1 at start time
elf-libc PT_INTERP present where the coordinate says static, or the named interpreter does not exist on this box the interpreter the binary asks for, the path that is missing, and whether the coordinate or the binary is the one that changed
elf-cpu elf_abi_level is above the host's detected level the level the binary needs, the level this CPU has, and that this is a host limit, not a policy
elf-recipe the build coordinate is absent, or contradicts the archive's signature which of the four recipe fields is missing, or which field the .note.package and the referenced attestation disagree on

How each fact is obtained — pure Rust, no shelling (LAW):

Three-way, never binary — the shape installer-source-guard already proved. edda/crates/korp-installer/src/installer_source.rs:236-268 has Verdict { Match, Unchecked, Unpinned }: an unpinned build is recorded, never silent, and only a genuine contradiction refuses. elf-recipe takes the same three states, and Unchecked is printed as a named skip, because GATE-IDENTITY's rule (gunnar/deploy/upcloud/src/banner.rs:17-31) is the estate's: a check nobody can see not running is worse than no check at all.

GATE-IDENTITY's other rule applies directly here and is worth stating as the reason this whole format is built the way it is: the only trusted evidence is what the artifact says about itself. An API saying 200 OK can mean a brick. A path saying x86_64 can mean an aarch64 binary. The ELF header cannot lie about the ELF header.

Where they live. A typed enum in the plugin crate with a Display writing the operator sentence, the shape gunnar/crates/gunnar-server/src/registry.rs:39 (enum Refusal { NotAttested, ProvenanceForged(String), … }) already uses for provenance admission. The refusal check is a pure function of (ElfFacts, HostFacts, Coordinate) -> Vec<Refusal> so every arm is unit-testable without a box, a binary or a boot.


6. Provenance: reference the build-thing artifact, do not restate it

6.1 Almost all of this already exists — build the binding, not a second scheme

piece where it already is
DSSE envelope ({payload, payloadType, signatures:[{sig,keyid}]}), PAE, base64 znippy-common/src/sign.rs:1276 — znippy's own
in-toto Statement v1 emit + parse, subject binding holger/server/lib/src/attest.rs:318,328,348,996
SLSA v1 statement from a real build record edda/crates/nornir-build-thing/src/provenance.rs:297 to_slsa_statement, :362 slsa_statement_bytes (canonical, key-sorted, compact)
DSSE sealing seam + signer from PKCS#8 + cert provenance.rs:420 trait DsseSealer, attest.rs:1139 AttestSigner::from_pkcs8, :1197 AttestStore::emit_signed
digest-keyed attestation store, trust roots, policy attest.rs:700-930, <data_root>/holger-attestations/<alg>/<hex>
payloadType application/vnd.in-toto+json, predicateType https://slsa.dev/provenance/v1 attest.rs:95-128 — and SLSA_PROVENANCE_V1 is already on the accept allowlist (attest.rs:618-660)

So: no new predicate, no new envelope, no new signer, no new store, no allowlist change. The brief said "do not add a second signing scheme"; the correct reading is stronger — do not add a second anything. What is genuinely missing is three things, and only three.

6.2 Missing thing 1 — the reference to the build-thing artifact

The answer to "is there a stable digest to point at" is: yes, but not the one you would expect, and the link is missing at one specific joint. Three candidates, judged:

candidate digest? signed? verdict
the emitted .nornir/build-variants.toml no — emit.rs:107 first_difference compares it as text; nothing hashes it no not usable. Its only identity is a git blob id, and it carries an "editing this changes nothing" header. Do not try to digest it.
Coordinate { krate, version, hash } (contract.rs:123) yes — SHA-256 over the recipe crate's src/**/*.rs, sorted, framed rel \0 len_le \0 bytes (znippy-build-thing/build.rs:1-45) not on its own this is the build-thing's identity. krate:version:hash, renderable and parseable (contract.rs:142). Caveats: it is a build-time env constant, and it covers src/**/*.rs only — not Cargo.toml, not path-dep crates.
Attestation::digest (provenance.rs:435-490) yes — SHA-256 over BuildProvenance::canonical_bytes(), key-sorted compact JSON yes — CMS or DSSE, via nornir/src/build/seal.rs:27 ZnippySealer this is the signed record, and digest_matches_record() re-derives it.

(A fourth exists and is a trap: dwarves .../build_thing/code.rs:244 code_key covers the recipe crate and every in-repo path dep transitively — strictly better coverage — but it is truncated to 8 bytes / 16 hex (code.rs:267) because it is a wasm cache key. Truncated is not an identity. Not usable as a statement reference.)

So the ELF statement carries two resolvedDependencies references, neither a copy:

{ "name": "build-thing-provenance",
  "uri":  "nornir.build-provenance/v1/<BuildProvenance.id>",
  "digest": { "sha256": "<Attestation::digest_hex()>" } },

{ "name": "build-thing-recipe",
  "uri":  "nornir+buildthing://<krate>@<version>",
  "digest": { "sha256": "<Coordinate.hash>" } }

externalParameters carries only the minimum needed to find them. A verifier loads the referenced attestation (from the same archive, or a local store — §7) and re-derives the recipe instead of trusting a transcription. One record, pointed at, twice.

6.3 Two joints are missing, and both are the owner's call

Stated plainly, at once, because together they are what blocks step 3 — and neither blocks the columns, the refusals or debuginfod.

(a) BuildProvenance has no build-thing field at all. provenance.rs:182 carries schema, id, repo, variant, inputs, toolchain, host, outcome, artifacts, built_at, builder — and no Coordinate. to_slsa_statement (:297) names the variant only as a buildType string, https://nordisk.dev/nornir/build-thing/variant/{name}. The recipe→artifact link exists only inside the binary, in the .nordisk.buildthing receipt (§4.5). Today the signed statement therefore cannot reference the recipe by digest. The fix is build_thing: Option<Coordinate> on BuildProvenance, threaded through capture (:212) and to_slsa_statement (:297).

(b) The toolchain image never reaches the record either. BuildProvenance records Toolchain { rustc, cargo, host } in internalParameters. That is the rustc on the build host; it is not the container image. CargoToolchain::image_ref() already produces the exact name@sha256:hex string (cargo_container.rs:541) — and it reaches no attestation. The fix is a resolvedDependencies entry, where SLSA puts materials:

{ "uri": "<toolchain_image>", "digest": { "sha256": "<toolchain_image_sha256>" } }

Both are changes in edda, another lane's repo. I am not making them. Consequence, said out loud: until they are made, the referenced build-thing artifact cannot answer tonight's question either — the ELF columns make it searchable from the binary's own receipt, which is the half that works today, but elf-recipe must report Unchecked for the toolchain image, by name, rather than pretend.

A caveat travels with (b), from dwarves/.nornir/build-thing-model-plan.md:79: image_sha256 is only reproducible for images whose recipe is itself pinned (edda's kernel-toolchain.Containerfile — digest-pinned base, Debian snapshot, pinned pip versions, SOURCE_DATE_EPOCH, OCI manifest format — four mints, one digest). nordisk-cargo-toolchain has had none of that work. Pinning it is a real check; reproducing it is not yet. And toolchain_archive.rs:61,720 warns the kernel-side value can be a per-box image id rather than a manifest digest — so the column must record what it found and never normalise the difference away.

6.4 Missing thing 3 — verification must look at the disk

The statement's subject[].digest must be compared against the bytes actually on the disk, recomputed at verification time. Statement::attests(&self, want: &ArtifactDigest) (attest.rs:410) is the comparison; supplying want from a fresh read of the file is the part that must not be skipped. "The payload is signed" proves a statement exists — it proves nothing about the file next to it. The verifier therefore:

  1. read the file on disk → SHA-256 → ArtifactDigest
  2. AttestStore::accept(envelope, certs, Some(&that_digest)) — subject binding before cryptography, which is already the order accept enforces (attest.rs:768+)
  3. follow resolvedDependencies[build-thing-provenance], load it, digest_matches_record()
  4. compare the ELF index row's recipe columns against the re-derived record → elf-recipe
  5. anti-rollback floor

6.5 The anti-rollback floor

holger's floor (plugin/host/src/floor.rs) is keyed (supplier, product) over the module's own MAJOR.MINOR.PATCH, persisted at <dir>/<supplier>/<product>.floor, with Floor::{InForce, Absent} — absence is a value, not an Option, and an unreadable floor is a refusal (plugin-11), never Absent. That is the right shape and the right implementation and an ELF artifact does not get a second one.

But the floor's lane is (supplier, product) from ModuleIdentity, which a plain ELF binary does not have. Extending FloorStore to a second kind of lane is a change to holger's plugin host. This note records the need and stops there — it is the same class of decision as §6.3 and it is named here so it cannot be forgotten.

6.6 Where the envelope lives

One statement per artifact, as its own member of the archive:

provenance/<elf_build_id>.dsse.json

elf_build_id is the key because it is the one identifier that survives stripping and renaming (§4.1) — and it is the same key debuginfod uses (§8). One key, three uses.

The archive as a whole is still sealed by znippy's existing detached CMS over the archive_root merkle fold (.nornir/signing-design.md). Two layers, both already built: CMS answers "was this archive tampered with", DSSE answers "where did this one binary come from". Neither replaces the other; this is the same additive shape Attestation already uses, where dsse sits beside seal rather than replacing it (provenance.rs:450-456).


7. Airgap first, connected supported — one format, two modes

The requirement: nothing may need a network to be trusted. A connected box must use the same objects the ordinary way.

Airgap (the default). The DSSE envelope is a member of the archive (§6.6). The referenced build-thing attestation is a member too. The signer certificates ride in the envelope's signer_certs — DsseSeal carries them precisely because "a DSSE envelope itself carries only a keyid" and airgap verification must recover the public key with no directory to call (provenance.rs:394-410). Trust roots are local DER files (attest.rs:551). The store is a local directory. Every step of §6.4 runs with the NIC down.

There is also an existing on-disk convention for the sealed record and it is kept: <artifact>.build-provenance.znippy beside the binary (nornir/src/build/seal.rs:223 dump_beside_artifact), with ProvenanceMode { Cms (default), Dsse } at :104 selecting which seal is applied. The ELF statement rides in the archive (§6.6); the sibling file remains what a loose artifact carries. Same bytes, two places.

Connected. The same envelope bytes are published as an OCI 1.1 referrer, hanging off the binary's digest, with artifactType set from the payloadType. An SBOM hangs off the same subject the same way. A connected box does a plain GET /v2/<name>/referrers/<digest> and gets the identical envelope it would have found in the archive.

That door does not exist yet — say it plainly. referrers appears nowhere in holger, dwarves or gunnar. What exists is OCI manifest handling only: gunnar/crates/gunnar-server/src/oci.rs:350-353, gunnar/crates/gunnar-http/src/registry.rs:314, holger/server/repository/file-oci/, holger/server/repository/znippy-docker/. None implements /v2/<name>/referrers/<digest>. So the connected mode is a new door on holger, built alongside debuginfod in step 4 and following the same rules (§8): new door, existing hyper pattern, nothing else edited, no deploy without telling the owner. Until it exists, the connected case is served by the airgap objects over ordinary HTTP — which works, and is not an excuse to invent a second format for it.

The property that makes this one format and not two: the envelope is content-addressed by its subject, which is the binary's own digest. The archive and the registry are two transports for one object. Nothing is re-signed, re-encoded or re-shaped in transit; a byte comparison of the two must succeed, and the test says so.


8. holger speaks debuginfod

debuginfod is the standard HTTP protocol for fetching debug information by build-id. Three endpoints, and that is the whole protocol:

GET /buildid/<build-id-hex>/debuginfo
GET /buildid/<build-id-hex>/executable
GET /buildid/<build-id-hex>/source/<path>

200 with the bytes, 404 with nothing. gdb, perf, eu-*, coredumpctl and rustc's backtraces all speak it already, out of the box, by setting DEBUGINFOD_URLS.

Why it belongs to this lane. §4.4 already splits .debug_* out and measures it, and §4.1 already keys on elf_build_id. A lean appliance ships no debug info at all — debug_bytes of saving — and a developer still gets a symbolized backtrace, because holger can serve exactly the split this format already made. The three endpoints are a SELECT … WHERE elf_build_id = ? against the index this note designs.

Constraints, in order:

This is step 4. It is designed here so the columns in §4.4 are chosen for it, and built after §6.


9. Laws honoured

law how
P-1 one archive = one ecosystem --format elf selects this single handler; the skeleton is deleted so the name resolves once (§3)
P-2 writes only declared columns 18 schema_fields, nothing else; no change to the core writer, which is why elf_needed is a string (§4.0.1)
P-3 discovery by record meta(), one line in builtin_handlers(), no second name table
P-4 never panics a truncated, hostile or non-ELF member still writes its row with every ELF column null. A decompress bomb in .dep-v0 is bounded by linflate's explicit output size. There is no unwrap on parsed data anywhere in the crate.
P-5 tier by dependency needs object + linflate → its own crate under plugins/native/ (§3)
E-0 the path may not assert a fact §2; arch_claim is the one path-derived column and it is labelled a claim
LAW pure Rust, no shelling, no Python object (pure Rust ELF reader, already in Cargo.lock at 0.39.1), linflate (nordisk), std::arch feature detection. Zero subprocesses.
LAW reuse the nordisk repos znippy's DSSE + CMS, holger's attest store, nornir-build-thing's SLSA statement and its .nordisk.buildthing receipt reader, znippy-zoomies' linflate. The only genuinely new code is the ELF reader, the refusal table and the binding.

10. Deliberately left out — and why

left out why
total_bytes the base index already has uncompressed_size (index.rs:49); duplicating a base column is the thing skidbladnir's doc explicitly refused
logical_path ELF has no second name. The siblings carry it because theirs do. Copying it because the siblings have it is exactly the cross-linking the brief forbids (§4.8)
elf_rpath / DT_RUNPATH it is real and it is tempting, but no question in hand needs it, and no refusal uses it. It will be added the day elf-libc needs to explain why an interpreter resolved differently — and not before
the full .dep-v0 tree as a column kilobytes per row in an index meant to be scanned. The bytes are already in the blob; auditable_root is the entry point (§4.7)
symbol tables, exported symbols, relocations, PLT/GOT megabytes, and answering "who exports foo" is a different tool's job
.note.package's name / version as columns the archive coordinate already says which product and which version. Challenged and dropped
a List<Utf8> column type would be a change to znippy's core writer (index.rs:222-256). A plugin that edits the core writer is not obeying P-2. Recorded as a limitation, not adopted as a preference
a shared column group between plugin types znippy has no mechanism for it. member_kind is duplicated between siblings by copy, and that stays true. Not invented here.
ELF facts inside znippy-plugin-rust-toolchain a rust-dev bundle's toolchain/bin/rustc is an ELF, so the question is real — and it is a separate question. Stated, left.
a second signing scheme, a new predicate type, a new envelope all four already exist (§6.1)
a second anti-rollback floor holger's is right; it needs a second lane kind, which is holger's to decide (§6.5)
writing build_thing: Coordinate and the toolchain image into BuildProvenance two changes in edda, another lane's repo. Named as the first things to fix; the owner's decision (§6.3)
digesting .nornir/build-variants.toml it is emitted, committed and compared as text (emit.rs:107). Its only identity is a git blob id, and Coordinate.hash is strictly better because it covers the code that is the source (§6.2)
dwarves' code_key as the recipe reference better coverage (recipe crate + transitive in-repo path deps + Cargo.toml), but truncated to 16 hex (code.rs:267) because it is a wasm cache key. Truncated is not an identity (§6.2)
debuginfod federation to an upstream server airgap first. v1 serves what is local

11. Build order

  1. this note ✔
  2. plugins/native/znippy-plugin-elf — type_id 7, 22 columns, the skeleton deleted, the refusal enum, a test per column and a test per refusal, each shown red before green
  3. the DSSE binding — the resolvedDependencies references to the build-thing attestation and recipe coordinate, and verification that recomputes the subject digest from the disk
  4. holger speaks debuginfod, and the OCI 1.1 referrers door beside it

Three owner decisions block the authority half of step 3 — §6.3(a) build_thing on BuildProvenance, §6.3(b) the toolchain image in the record, §6.5 a second floor lane. None of them blocks step 2, step 4, or the searchable columns — which is the half that answers tonight's question, and it answers it from the binary's own receipt.