Gunnar

mikronova/modgunn

.nornir/provenance-policy-and-diff.md

<!-- nornir:class: design -->
# provenance policy & ledger-diff — design

> **Class:** `design` · **Function axis:** `f0` verify→policy→ledger · `f1` the
> `ProvenanceRequirement` policy + finding codes · `f2` commit-pinning (A3) · `f3` the
> attestation delta (A25) · `f4` the ledger diff + regression gate · `f5` the CDC/VEX
> scan plumbing that feeds it. Strict: only the shape we run. The base attestation
> model is in [`provenance-design.md`](provenance-design.md); the warehouse it reads
> in [`warehouse-design.md`](warehouse-design.md); the system view in
> [`design.md`](design.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:

1. **VERIFY** — cryptographically check a carried
   `<artifact>.build-provenance.znippy` seal against trusted roots
   ([`verify_sealed_provenance`](../src/provenance.rs), feature `provenance-verify`).
   Produces a signed/rejected verdict. This is the only network-adjacent hop and it
   is optional at the crate boundary.
2. **POLICY** — score the verified [`Attestation`](../src/provenance.rs) against a
   [`ProvenanceRequirement`](../src/provenance.rs) (`provenance.rs:46`): does it meet
   min-SLSA, come from a trusted signer, claim the expected repo AND the expected
   commit? Any shortfall is a `core::Finding`.
3. **LEDGER** — append every verdict (verified or not) to the append-only
   `build_provenance` Iceberg table
   ([`Warehouse::append_build_provenance`](../src/warehouse.rs)). Time-travellable.
4. **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`](../src/warehouse.rs), `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`](../src/warehouse.rs) (`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)`](../src/provenance.rs) (`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`](../src/provenance.rs) (`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`](../src/provenance.rs), `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)`](../src/provenance.rs) (`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 → Some` appearance is not
  a "change"), and
- only when the **repo held steady** — a repo move already carries the stronger
  `SOURCE-CHANGED` finding, 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)`](../src/warehouse.rs) (`warehouse.rs:769`) — the
  ledger's state at snapshot `to` (`None` = current). The table is append-only, so
  skade's CDC `read_delta(None, to)` — every row from the beginning up to `to` — *is*
  the state at `to`.
- [`provenance_diff(from, to)`](../src/warehouse.rs) (`warehouse.rs:797`) — reads both
  sides and feeds the pure fold. `from = None` is the **empty beginning** (nothing
  attested yet), which is *distinct* from `to = None` (the current snapshot); so a
  `None` from short-circuits to an empty ledger rather than reading "current".
- [`provenance_ledger_delta(old, new)`](../src/warehouse.rs) (`warehouse.rs:1346`) — the
  pure fold. Each side is first reduced to its **newest verdict per `artifact_id`** by
  `ts_micros` ([`newest_per_artifact`](../src/warehouse.rs), `warehouse.rs:1326`); then
  the union of ids is partitioned into
  [`ProvenanceLedgerDelta`](../src/warehouse.rs) (`warehouse.rs:1301`) `{ added,
  removed, changed }`, each sorted by id for a stable, diffable record.

A `changed` entry is a [`ProvenanceTransition`](../src/warehouse.rs)
(`warehouse.rs:1274`) `{ artifact_id, before, after }`, raised only when
[`same_verdict`](../src/warehouse.rs) (`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`](../src/warehouse.rs) (`warehouse.rs:1282`) — `verified → not
  verified`. The alarming signal.
- [`is_recovery`](../src/warehouse.rs) (`warehouse.rs:1286`) — `not verified → verified`.
- [`signer_changed`](../src/warehouse.rs) (`warehouse.rs:1291`) — builder-takeover /
  key-rotation even when the verified flag holds.

[`ProvenanceLedgerDelta::regressions()`](../src/warehouse.rs) (`warehouse.rs:1317`) is
the gate-worthy subset; [`is_unchanged()`](../src/warehouse.rs) is the "provenance held"
fast path. The CLI [`cmd_provenance_diff`](../src/main.rs) (`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`](../src/main.rs), `main.rs:242`):

- **`--since <snapshot>`** — an incremental CDC delta: only the `sbom_components`
  *added* since that snapshot are scanned (`scan_sbom_delta_with`), and the run prints
  the `next --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 asserted
  `known_not_affected` are dropped before printing
  ([`vex::collect_suppressions`](../src/vex.rs)); 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`](rollup-design.md)'s
`render_fleet_summary`, printed by `scan` and by the standalone `warehouse rollup`.

## Cross-references

- Base attestation / Scorecard model — [`provenance-design.md`](provenance-design.md).
- Fleet-health rollup (`render_fleet_summary`) — [`rollup-design.md`](rollup-design.md).
- Warehouse tables / CDC — [`warehouse-design.md`](warehouse-design.md).
- Hands-on CLI — [`provenance-diff-guide.md`](provenance-diff-guide.md); dead-simple
  walkthrough — [`provenance-diff-for-idiots.md`](provenance-diff-for-idiots.md).
</content>
</invoke>