provenance policy & ledger-diff — design
Class:
design· Function axis:f0verify→policy→ledger ·f1theProvenanceRequirementpolicy + finding codes ·f2commit-pinning (A3) ·f3the attestation delta (A25) ·f4the ledger diff + regression gate ·f5the CDC/VEX scan plumbing that feeds it. Strict: only the shape we run. The base attestation model is inprovenance-design.md; the warehouse it reads inwarehouse-design.md; the system view indesign.md.
f0 — the model: verify → policy → ledger → diff
modgunn's supply-chain integrity story is four layers, each pure and each testable without the one below it:
- VERIFY — cryptographically check a carried
<artifact>.build-provenance.znippyseal against trusted roots (verify_sealed_provenance, featureprovenance-verify). Produces a signed/rejected verdict. This is the only network-adjacent hop and it is optional at the crate boundary. - POLICY — score the verified
Attestationagainst aProvenanceRequirement(provenance.rs:46): does it meet min-SLSA, come from a trusted signer, claim the expected repo AND the expected commit? Any shortfall is acore::Finding. - LEDGER — append every verdict (verified or not) to the append-only
build_provenanceIceberg table (Warehouse::append_build_provenance). Time-travellable. - DIFF — compare the ledger's newest verdict per artifact at two snapshots and classify each artifact as added / removed / verdict-transitioned; a verified→not-verified transition is a regression — the gate-worthy signal (
Warehouse::provenance_diff,warehouse.rs:797).
Layers 2 and 4 are the same idea (A25 supply-chain drift) at two altitudes: layer 2 compares one attestation to a policy; layer 4 compares the whole ledger at time from to the ledger at time to.
Reuse: the policy layer is provenance.uses = [core, scan] (pure logic); the diff layer is warehouse.uses = [core, provenance] plus skade's CDC. The pure diff fold provenance_ledger_delta (warehouse.rs:1346) takes two plain &[BuildProvenanceVerdict] slices, so it is exercised with no warehouse at all.
f1 — ProvenanceRequirement + the finding codes
ProvenanceRequirement::evaluate(att) (provenance.rs:66) returns (scan::Provenance, Vec<Finding>). signed on the returned Provenance is findings.is_empty() && signer.is_some() — a policy miss un-signs the artifact for Engine A. The policy knobs and the finding each raises:
| Policy field / CLI flag | Fires when … | Finding id | Severity |
| (no attestation at all) | evaluate(None) |
MODGUNN-PROV-NO-ATTESTATION |
High |
require_min_slsa / --min-slsa <n> |
att.slsa_level < n |
MODGUNN-PROV-SLSA-TOO-LOW |
High |
trusted_signers / --trusted-signer <id> |
signer present but not in the trusted set | MODGUNN-PROV-UNTRUSTED-SIGNER |
High |
| (any policy set, signer absent) | attestation has no signer identity | MODGUNN-PROV-NO-SIGNER |
Medium |
expected_source_repo / --expected-repo |
att.source_repo ≠ expected |
MODGUNN-PROV-SOURCE-MISMATCH |
High |
expected_source_sha / --expected-commit |
att.source_sha ≠ expected (or absent while pinned) |
MODGUNN-PROV-COMMIT-MISMATCH |
High |
Each knob is independent and all fire together — a fully-wrong attestation raises four findings at once (the verify_tests::… fixtures assert exactly this stacking). Empty / zero = "don't care": the all-default ProvenanceRequirement accepts anything signed.
f2 — commit-pinning (A3): expected_source_sha
expected_source_sha (provenance.rs:58) is the new pin that closes the "right repo, wrong commit" gap. Verifying the repo alone lets a build off an unmerged branch or an attacker-pushed commit on the trusted repo pass; pinning the reviewed/tagged revision rejects it (provenance.rs:116). Matching is case-insensitive — git revisions are hex and producers differ on case, so DEADBEEF matches deadbeef (sha_eq, provenance.rs:142). An attestation that carries no commit while one is pinned is a mismatch, not a silent pass — the finding still fires with <none> as the observed value.
f3 — attestation delta (A25): the same-artifact drift
attestation_delta(old, new) (provenance.rs:153) compares two observations of the same artifact and reports what moved:
| Change (old → new) | Finding id | Severity |
| attestation present → absent | MODGUNN-PROV-ATTESTATION-DROPPED |
High |
| absent → present | MODGUNN-PROV-ATTESTATION-ADDED |
Info |
| signer (builder) identity changed | MODGUNN-PROV-SIGNER-CHANGED |
High |
| claimed source repo moved | MODGUNN-PROV-SOURCE-CHANGED |
Medium |
| same repo, source commit changed | MODGUNN-PROV-COMMIT-CHANGED |
Medium |
COMMIT-CHANGED (provenance.rs:200) is the new signal: an immutable version ought to be reproducible from a single source commit, so a commit move for what should be the same artifact is a rebuild-from-elsewhere flag. It is deliberately narrow to avoid noise:
- only when both sides attest a commit (a first-time
None → Someappearance is not a "change"), and - only when the repo held steady — a repo move already carries the stronger
SOURCE-CHANGEDfinding, so the commit diff would be redundant and is suppressed.
f4 — the ledger diff + the regression gate
The warehouse lifts A25 from "two attestations" to "two snapshots of the whole ledger".
read_build_provenance_as_of(to)(warehouse.rs:769) — the ledger's state at snapshotto(None= current). The table is append-only, so skade's CDCread_delta(None, to)— every row from the beginning up toto— is the state atto.provenance_diff(from, to)(warehouse.rs:797) — reads both sides and feeds the pure fold.from = Noneis the empty beginning (nothing attested yet), which is distinct fromto = None(the current snapshot); so aNonefrom short-circuits to an empty ledger rather than reading "current".provenance_ledger_delta(old, new)(warehouse.rs:1346) — the pure fold. Each side is first reduced to its newest verdict perartifact_idbyts_micros(newest_per_artifact,warehouse.rs:1326); then the union of ids is partitioned intoProvenanceLedgerDelta(warehouse.rs:1301){ added, removed, changed }, each sorted by id for a stable, diffable record.
A changed entry is a ProvenanceTransition (warehouse.rs:1274) { artifact_id, before, after }, raised only when same_verdict (warehouse.rs:1382) is false — i.e. the outcome moved (verified / signer / reason / blob / repo). ts_micros is a capture timestamp, not part of the verdict, so a re-verify that reproduces the same outcome is not a transition. The transition predicates:
is_regression(warehouse.rs:1282) —verified → not verified. The alarming signal.is_recovery(warehouse.rs:1286) —not verified → verified.signer_changed(warehouse.rs:1291) — builder-takeover / key-rotation even when the verified flag holds.
ProvenanceLedgerDelta::regressions() (warehouse.rs:1317) is the gate-worthy subset; is_unchanged() is the "provenance held" fast path. The CLI cmd_provenance_diff (main.rs:451) exits 3 when --fail-on-regression is set and any regression fires — a promotion check blocks on it, distinct from a usage error (exit 2).
f5 — the scan plumbing this rides on: CDC --since + VEX
The same append-only-plus-CDC discipline powers warehouse scan (cmd_scan, main.rs:242):
--since <snapshot>— an incremental CDC delta: only thesbom_componentsadded since that snapshot are scanned (scan_sbom_delta_with), and the run prints thenext --since <watermark>to poll from. Cheap steady-state polling instead of a full fleet re-scan.--vex <dir>— CSAF VEX suppression: findings whose advisory a consumer assertedknown_not_affectedare dropped before printing (vex::collect_suppressions); the summary reports the suppressed count. Suppression is applied after the scan, so it never hides the watermark math.
Both are surfaced at fleet altitude by rollup's render_fleet_summary, printed by scan and by the standalone warehouse rollup.
Cross-references
- Base attestation / Scorecard model —
provenance-design.md. - Fleet-health rollup (
render_fleet_summary) —rollup-design.md. - Warehouse tables / CDC —
warehouse-design.md. - Hands-on CLI —
provenance-diff-guide.md; dead-simple walkthrough —provenance-diff-for-idiots.md.