Skip to main content
Version: 0.1.0-alpha.5 (alpha)

Build observations and current builder trust

LSF provides a maintained echo build observer and separate builder-provenance verification in latent-signing. The observer executes selected committed source and records its component output. A trusted builder signs that observation after binding it to an independently inspected package. BuilderVerifier checks exact evidence against explicit current builder policy and revocations. This grants no catalog, tenant, publisher, guest-execution or native AOT authority.

Observe an actual build​

From the configured Python 3.13 environment and pinned toolchain:

python tools/build_provenance.py --repository https://github.com/KirilsTurkins/latent-service-fabric --output-dir target/capsules/echo-provenance
cargo run -p latent-packaging --example package --locked -- build-with-sbom target/capsules/echo-provenance/package-source.json target/capsules/echo-provenance/sbom-inputs.json target/capsules/echo-provenance target/package-echo-provenance
cargo run -p latent-packaging --example package --locked -- inspect target/package-echo-provenance

Output directories must be fresh children of the configured target root. --revision selects an exact 40- or 64-character lowercase Git commit identity; the default resolves committed HEAD. Worktree edits are excluded. --verify-reproducible performs two actual builds and records byte equality only when their components match; otherwise the default claim is not-checked. This builds a small component and does not run a load campaign.

--legacy-output-dir optionally exports the complete unsigned echo fixture from the same build for existing containment/conformance tools. It must name a distinct fresh child of the target root. The default export retains only the observation and exact package-ready inputs, including normalized sbom-inputs.json. Neither export authenticates legacy build.json.

tools/validate_contracts.sh regenerates its two fixed echo output directories. Its reset helper checks the expected file inventory, fixture markers and component hash before removal, and refuses unknown files, links or changed associations. The standalone observer never replaces an existing output directory.

The observer captures a bounded committed-source allowlist with git archive, checks workspace membership and rejects unsafe paths, links and nonregular files. It builds in a temporary owned directory. A sorted compact inventory identifies each selected file's portable path, SHA-256, size and regular-file mode. The source snapshot digest hashes this inventory, not tar metadata or physical paths. Inputs, recipe files and selected tool binaries are checked again after the build. Intermediates are removed before publishing successful small outputs; failed observations are not published.

Required unique material names are source-snapshot, dependency-lock, build-recipe, toolchain-config, cargo, rustc and wasm-tools. The recipe material identifies the current driver/helper sources that actually performed the observation, rather than pretending they came from an older selected commit. Tool materials hash selected executable bytes. Names are bounded logical labels, never private filesystem paths.

The maintained observer also records dependency-inventory: the exact hash and size of sbom-inputs.json. It reuses Cargo's actual compiler-artifact records, captured lock/source manifests and bounded observed cache manifests. Guest, build dependency, procedural macro, build script, WIT, output and tool roles stay separate. The SBOM profile documents available attribution and the conservative producer license vocabulary. The packager encodes and embeds the inventory before computing the package digest; the same BOM bytes can subsequently be attached as detached evidence.

The repository URL is explicitly operator-asserted; local commits and inventory do not authenticate remote repository ownership. This profile always reports hermetic: false and dependencyCompleteness: "lockfile-only". Compiler sysroots, caches and network dependency retrieval are outside a complete isolated input closure. Two matching outputs do not establish reproducibility on every machine. No SLSA level or SLSA conformance is claimed.

Guest build recipes​

ADR-0034 adds https://latent.dev/build/rust-guest/v1 and https://latent.dev/build/c-guest/v1. Their requirements need separate explicit builder approval; an echo approval does not authorize either recipe. The guest workflow builds nine Rust examples and one C fixture with the pinned tools, then tests real package inspection, publisher/builder signing, enforced admission and guest execution.

These profiles capture bounded explicit worktree inputs rather than a committed Git archive. Their revision is the 64-digit source inventory hash and must match snapshotDigest; it is not a Git commit. Common required materials are source-snapshot, build-recipe, toolchain-config, wasm-tools and wit-bindgen. Rust additionally requires cargo, rustc and dependency-lock; C requires zig. The exact recipe parameters and capture/completeness claims are checked together. Arbitrary Cargo examples and mixed C/Rust recipes fail.

The driver limits each command to 600 seconds and 4 MiB of captured output, the build to 900 seconds, and source capture to 4,096 files, 4 MiB per file and 32 MiB total. It uses the existing Python 3.13 process owner and environment allowlist. Inputs and actual tool binaries are checked again before writing BUILD-COMPLETE.json, which identifies every completed observation. The signing test checks that marker and the source inventory before signing.

Guest observations say hermetic: false, reproducibility: not-checked and dependencyCompleteness: declared-inputs-incomplete. They do not establish a complete dependency inventory or sandbox trusted tools. Keep private signing keys outside build children; approve the intended recipe and source explicitly.

Observation and signing authority​

observation.json and BuildObservation are unsigned assertions. decode_build_observation checks their bounded closed format, selected exact recipe, required materials and snapshot association. It cannot prove that a compiler ran. An approved builder remains responsible for the truth of its signed claims; an approved key holder can lie.

The Python observer accepts no private key and passes no signing key through build arguments or environment. Children receive an explicit environment allowlist that removes arbitrary variables, wrappers and caller-supplied build flags. Host tools and the maintained recipe remain trusted: this is not a sandbox hiding all host files from malicious compilers or build scripts. LocalBuilderSigner uses the same checked, bounded, zeroizing PKCS#8 v2 import and approved-public-key match as the publisher signer, with independent builder approval.

Construct PackageSigningSubject from exact manifest/config bytes after the packager has inspected content. LocalBuilderSigner::sign_build requires a capsule subject whose config component digest and size equal the observed output. The statement binds the component and the exact package: repackaging unchanged Wasm with different metadata requires new evidence. Browser/SSR build recipes are outside these profiles.

The packaging receipt records supplied-artifact packaging rather than compilation. Original input hashes in a received receipt remain assertions; its presence or a legacy echo receipt cannot substitute for authenticated build provenance.

Statement and detached evidence​

The profile uses the in-toto Statement v1 structure with exactly one lsf-package subject and custom predicate type https://latent.dev/provenance/v1. The predicate contains format version 1, packageSubject, builderId, issuedAt, expiresAt and the observation. The in-toto subject SHA-256 equals the package digest without its prefix. Issuance cannot precede build completion; signed lifetime is positive and at most 31 days. The supported build type is https://latent.dev/build/echo-capsule/v1.

One strict Ed25519 signature authenticates DSSE PAE of application/vnd.in-toto+json and the exact decoded statement bytes. Verification interprets those bytes without reserializing their identity. keyid is a SHA-256 raw-public-key fingerprint and only a lookup hint among explicit approved anchors.

The OCI evidence format uses artifact type application/vnd.latent.provenance.v1, one application/vnd.latent.provenance.payload.v1+json layer and exact empty config bytes {}. Outer subject, config and payload hashes/sizes are checked separately. Evidence remains detached to avoid hashing it into the package it signs. ProvenanceEvidence::from_envelope and inspect_provenance check syntax and association, not trusted authorship.

Builder policy and current proofs​

BuilderPolicy maps approved raw public keys to builder IDs. Requirements are an OR of explicit builder/build-type/repository combinations, optional exact sourceRevision and sourceSnapshotDigest, and the required requireReproducible boolean. Absent pins are omitted, not null. Repository labels are compared exactly without URL normalization. Empty keys or requirements deny every build. A PublisherPolicy anchor alone cannot authorize builders; approving one key for both roles requires two independent policies.

BuilderRevocationSnapshot is mandatory even with empty lists. Its scope and policyDigest bind the exact canonical policy, including source requirements. Use policy.digest() instead of hashing unsorted input JSON. Keys, requirements and revocations are sorted for identity; duplicates are rejected. Both snapshots have positive generations and half-open validity. Failed refreshes never create fresh empty revocations or extend earlier expiry.

BuilderVerifier::verify_package privately constructs VerifiedBuildProvenance after checking exact package/component/evidence associations, strict signatures, builder authorization, source requirements, revocations and time. The proof binds source identities, builder/key, evidence/payload, both trust identities and its verification/expiry times. Expiry is the earliest of signature, key, policy, revocation and configured proof-age bounds; maximum proof age is one hour. Equality with expiry is expired.

Call check_current before reuse, and match expected package/evidence identities. Changed snapshots, clock rollback, time before original verification and expired proofs fail. replace_trust requires the expected current state, unchanged scope and monotonic generations. Changed contents at the same generation are rejected; identical fresh state is idempotent without renewing validity. A policy change requires a newly bound higher revocation generation. The verifier rechecks captured state after cryptography and advances its clock high-water mark even on rejected operations. Contention fails through the bounded API, without a queue, network discovery or positive verification cache.

The durable admission owner persists clock/generation floors and atomically compares proof state at final publication and activation start. Serialized fields, referrer presence and matching subjects cannot recreate authority. SBOM content policy checks presence and selected attribution requirements; it grants no builder or publisher authority. The implemented trusted native compiler and cache use a separate protected host key and exact runtime/compiler compatibility binding. A builder signature cannot replace that local native-output authority.

Bounds and validation​

ResourceDefault / hard ceiling
Unsigned observation / decoded statement32,768 / 32,768 bytes each
DSSE envelope / outer referrer49,152 / 49,152 bytes; 4096 bytes for referrer
Policy / revocations65,536 / 65,536 bytes each
Materials64 / 64; 256 MiB per material
Component / observed build duration64 MiB / 3600 seconds
Keys / source requirements64 / 256 each
Revoked keys / revoked builders256 / 256; 64 / 256
Captured files / explicit archive directories4096 / 4096
Per-file / total source / archive4 MiB / 32 MiB / 40 MiB

Configured Rust limits must be positive and within hard ceilings. JSON rejects unknown fields, duplicate keys, floats, negative integers and null, and bounds depth to 16, nodes to 4096 and decoded statement/policy strings to 1024 bytes before retaining typed values. Builder IDs/scopes are nonempty ASCII of at most 128 bytes using letters, digits and ._:/@-. Material names use letters, digits and ._-, beginning with a letter or digit, with the same 128-byte limit. Repository labels are at most 512 ASCII bytes: HTTPS, a DNS-shaped host and nonempty portable path segments, without credentials, ports, escaping, query, fragment or ./.. segments. The five schemas describe wire shapes; raw byte limits, integer token spelling, cross-field associations, signatures and current authority need the Rust API.

run_bounded is a main-thread trusted-recipe helper with a combined stdout/stderr cap of at most 64 MiB, no reader threads or log files, a command deadline of at most 3600 seconds and five seconds for cleanup. Normal signal handlers record bounded pending cancellation; explicit checkpoints deliver it after safe acquisition or during protected capture, and cleanup defers delivery until release. Windows starts hidden and suspended, assigns a kill-on-close Job before resuming and accounts for descendants. Linux retains the leader unreaped while terminating and checking its group, avoiding PID reuse. Linux containment covers ordinary descendants, not deliberate session escape. Competing reapers, forced asynchronous exception injection, abrupt supervisor death and uninterruptible OS process creation are outside this boundary. The caller owns the single active build slot and evidence/proof retention budgets.

Run cargo test -p latent-signing --locked, python -m unittest tools.tests.test_build_provenance_schemas and python -m unittest tools.tests.test_build_process tools.tests.test_build_provenance. Tests cover bounded shapes, independent builder authority, altered claims, expiry, revocations, state races, committed snapshots, process overflow, cancellation and cleanup. The existing bounded authenticated OCI test covers evidence transfer and verification in its disposable registry.

See ADR-0023.