# Probity ML-BOM (Execution-Grounded Model Bill-of-Materials), v1

**predicateType:** `https://getprobity.dev/predicate/v1/ml-bom`

An ML-BOM is a signed, offline-verifiable **execution-grounded** bill of the model weights a frozen
microVM actually loaded. Unlike a declared software BOM (which lists what an artifact is *supposed* to
contain), this records what a guest process *demonstrably mapped into its address space* at the moment
the VM was frozen - the ACCESS proof - and resolves each mapped region to a full-file identity through
host-side sources - the IDENTITY proof. The distinguishing property is that **intent** comes from the
guest (a VMA walk of what the process mmap'd) while **identity** comes from a host layer the guest
cannot forge (a dm-verity manifest, an egress-proxy stream hash, or a host-block read), so a guest
cannot misrepresent which weights it ran.

An ML-BOM is consumed by the [a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md) as the per-member
`model_bom` bound by digest. This document specifies the ML-BOM shape and the resolution rule; the
signing envelope is in [signing-envelope.md](signing-envelope.md). The Go shape this mirrors is the reference
implementation's `ModelBOM`, whose host-side identity sources are cited below.

The key words MUST, MUST NOT, REQUIRED, SHOULD, MAY, and OPTIONAL are to be interpreted as in
[RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).

## Subject

When an ML-BOM is the subject of its own attestation, the subject is the model surface it describes,
bound by the BOM's canonical SHA-256 digest. More commonly the ML-BOM is referenced *by digest* from
an [a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md) member entry (`model_bom_digest`), and the full BOM is
served alongside, keyed by `member_id` and that digest.

```json
"subject": [
  { "name": "<member-or-snapshot-label>", "digest": { "sha256": "<64-hex-model-bom-digest>" } }
]
```

## BOM shape

```json
{
  "entries": [
    {
      "pid": 101,
      "comm": "router-agent",
      "vm_start": 140737488355328,
      "vm_end": 140737505132544,
      "inode": 524289,
      "mapped_bytes": 16777216,
      "full_file_size": 16777216,
      "identity": {
        "source": "host-block",
        "algorithm": "sha256",
        "digest": "<64-hex full-file digest>",
        "full_size": 16777216,
        "note": "full-file sha256 from host path /var/lib/.../model.safetensors"
      }
    }
  ],
  "unresolved": [
    {
      "region": { "pid": 202, "inode": 524290, "vm_start": 0, "vm_end": 0 },
      "reason": "verity-catalog: (s_dev, inode) is not a declared model file; host-block: no host path for inode 524290"
    }
  ]
}
```

## Field semantics

### `entries` (REQUIRED, array)

Each entry is one file-backed region a guest process had mmap'd (the ACCESS proof from a VMA walk),
resolved to a full-file identity (the WHAT). Fields:

- **Access proof (from the guest VMA walk):**
  - **`pid`** (REQUIRED, integer) - the guest process id that mapped the region.
  - **`comm`** (REQUIRED, string) - the process command name.
  - **`vm_start`** / **`vm_end`** (REQUIRED, integers) - the mapped virtual-address range.
  - **`inode`** (REQUIRED, integer) - the inode of the file backing the mapping.
  - **`mapped_bytes`** (REQUIRED, integer) - `vm_end - vm_start`: how much of the file was mapped into
    the address space. This is **NOT** how much was resident in RAM, and **NOT** the identity basis -
    see the partial-residency rule.
  - **`full_file_size`** (REQUIRED, integer) - the inode `i_size`: the size of the whole backing file,
    carried explicitly so a reader can contrast the full file against the mapped/resident bytes.

- **Identity proof (from a host-side source), under `identity`:**
  - **`source`** (REQUIRED, string) - which host source resolved the identity and, for the verity path,
    the CONFIDENCE with which the guest device was paired to its signed lower (see the source rule). One
    of `verity-exact-root-digest`, `verity-heuristic-size`, `proxy-stream`, `host-block`.
  - **`algorithm`** (REQUIRED, string) - the digest algorithm (for example `sha256`).
  - **`digest`** (REQUIRED, 64-hex for sha256) - the model file's own **full-file** digest, lowercase
    hex. It is NEVER a hash of resident RAM, and NEVER the dm-verity root hash (the root hash identifies
    the LOWER and pairs the device; the identity digest is the per-file content hash).
  - **`full_size`** (OPTIONAL, integer) - the number of bytes the digest covers (the full file), or 0
    if unknown.
  - **`note`** (OPTIONAL, string) - provenance detail (the model name, or the backing host path).
  - **`copied_up`** (OPTIONAL, bool) - true when the model was copied up to the writable upper and
    re-attributed by host-side hashing (a `host-block` source), bypassing the dm-verity block layer. A
    policy MUST treat a `copied_up` entry's `host-block` source as authoritative over any run-level verity
    pairing confidence: its bytes were host-hashed, not verity-enforced.

### `unresolved` (OPTIONAL, array)

The file-backed model-candidate regions for which **no** host source could establish a full-file
identity. Each entry carries the access proof (the `region`) and a `reason` aggregating why each
source declined. This array is the fail-loud record the partial-residency rule requires: an unresolved
region is recorded honestly, **never** silently dropped and **never** assigned a hash of resident RAM.
An absent `unresolved` field (or an empty array) means every candidate region resolved.

## The host-resolution rule

A mapped region's identity is established by trying host-side sources **in order**; the first source
that resolves wins. The rule is satisfied if ANY host source speaks for the region. The sources, in the order the
reference implementation's host-identity resolver tries them:

1. **`verity-exact-root-digest`** / **`verity-heuristic-size`** (image-baked catalog) - the inode is a
   DECLARED model file on a dm-verity workload lower. Its identity is the model file's per-file SHA-256,
   computed by the builder from the root-hash-pinned uncompressed EROFS bytes and looked up O(1) by the
   VMI-recovered `(s_dev, inode)` key (`PrecomputedVerityTable`). The two forms differ ONLY in how the
   guest device was paired to its signed contract lower: **`verity-exact-root-digest`** when the enforced
   dm-verity root hash recovered from guest RAM (the dm-mapper walk) exactly matched the signed lower's
   root hash (cryptographically unambiguous), **`verity-heuristic-size`** when that walk was unavailable
   and the device was paired by a bidirectional-unique data-region size (the honest-degrade fallback,
   which a policy may down-weight). **Status: live.**

2. **`proxy-stream`** (download-time) - the egress proxy computed a streaming SHA-256 of the model
   file as it was downloaded in transit; the BOM consumes that per-inode (or per-URL) streaming hash.
   **Status: interface seam only in v1** - the proxy lives on a separate boot/proxy effort; until that
   path is wired the proxy-stream source resolves nothing, and a region it would have covered falls
   through to the next source or to `unresolved`.

3. **`host-block`** (host-view read) - a full-file SHA-256 read from the host's own view of the backing
   store (block device / overlay upperdir), fully independent of what is resident in guest RAM. It is
   also the source for a model copied up to the writable upper and re-attributed by digest (which sets
   `copied_up` true), bypassing the dm-verity block layer. The inode-to-host-path mapping is
   environment-specific (an overlay scan, a `/proc/<vmm-pid>/maps` cross-reference, or a known
   model-cache path). **Status: live.**

A source that cannot speak to an inode returns "unresolved" and the resolver tries the next source. A
source that hits a *real* read failure (for example a host-block read error) is surfaced LOUDLY as the
reason - it is never masked into a silent unresolved. A region no source resolves goes to `unresolved`.

## INTENT-via-VMI, IDENTITY-via-host-layer (the grounding argument)

The security property an ML-BOM provides, and the reason it is execution-grounded rather than declared:

- **INTENT comes from the guest, via VM introspection (VMI).** Walking the guest VMAs tells you which
  files a process actually mapped - not what a manifest claims it would use. A model file present on
  disk but never mapped does not appear; a file mapped under an unexpected process does. The BOM
  reflects the run, not the declaration.

- **IDENTITY comes from a host layer the guest cannot forge.** The full-file digest is established by
  a host-side source (a verity-anchored per-file digest, an egress-proxy stream hash, or a host-block
  read), none of which the guest can tamper with from inside its own address space. So the BOM records
  WHAT was run with an
  identity basis a compromised guest cannot rewrite.

The two halves together defeat the failure mode of a declared BOM: a guest cannot claim to have loaded
benign weights while running tampered ones, because the mapped inode's identity is read from outside
the guest.

## Partial-residency rule (honesty)

A mapped region is not necessarily fully resident in RAM, and the BOM **never** hashes resident RAM.
The identity digest is always a **full-file** digest from a host source. This is why `mapped_bytes`
and `full_file_size` are both carried and are explicitly distinguished from the digest: the digest
covers the whole file (or the verity-covered image), regardless of how many pages were resident at
freeze time. A region whose full-file identity no host source can establish is recorded in
`unresolved` with the access proof intact - the BOM fails loud on an unresolvable region rather than
substituting a partial or RAM-derived hash.

## Digest-alignment stability contract (training-provenance join)

An entry's identity `digest` under `algorithm: sha256` is the SHA-256 of the **raw, uncompressed,
full-file bytes** of the model weight file (a `.gguf` / `.bin` / `.safetensors`), taken in logical
byte order. It is NOT a hash of an OCI layer tarball, NOT a hash of a compressed blob, and NOT a
HuggingFace repo hash. This is the identical byte string an ML training or publishing pipeline hashes
when it records `sha256(weight file)`, so the ML-BOM digest and the pipeline digest are equal by
construction. (In the Go shape this is `ModelBOMEntry.Identity.Digest`.)

For this equality to hold the weight file MUST be stored **uncompressed** in the substrate's EROFS
verity lower: a compressed extent would make the on-image bytes differ from the raw file bytes and
break the equality. The substrate builder already enforces this - the workload lowers are built
without compression and the extent reader fails closed on any compressed extent - so a conforming
producer never emits a `sha256` identity digest computed over compressed bytes.

This equality is a **stability contract**, not an incidental implementation detail. It exists so a
downstream training-provenance attestation - for example an
[in-toto](https://github.com/in-toto/attestation) attestation whose subject is the weight file keyed
by its raw-file SHA-256 - can JOIN to this execution evidence purely by digest equality: the ML-BOM
states "process P mapped a file whose raw-file SHA-256 is D at freeze time", the provenance
attestation states "the file with raw-file SHA-256 D was trained by pipeline L", and a consumer chains
"an agent ran model M" to "model M's training lineage is L" with no shared naming and no live
environment. Changing the digest basis (hashing an OCI layer, a compressed blob, a repo manifest, or a
Merkle root instead of the raw file) silently breaks that join and is therefore a **breaking change**
to this predicate, held to the same bar as any change to the full-file-only identity rule.

The contract applies to EVERY entry carrying a full-file `sha256` identity digest - which is every
verity-anchored entry (`verity-exact-root-digest` / `verity-heuristic-size`, whose identity digest is
the builder's per-file SHA-256 of the raw uncompressed EROFS-lower bytes, NOT the lower's root hash)
and every `host-block` entry. All are raw-file-SHA-256 join-ready. The dm-verity root hash pins the
lower's integrity and pairs the device to its signed lower (the `verity-exact-`/`verity-heuristic-`
distinction) but is never itself an entry's identity digest.

## Verification (third-party, offline)

After the envelope checks in [signing-envelope.md](signing-envelope.md) succeed (when the ML-BOM is a
standalone subject), or after resolving the BOM referenced by an
[a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md) member's `model_bom_digest`:

1. Confirm the served BOM canonically hashes (SHA-256 over sorted-key, compact-separator JSON) to the
   bound `model_bom_digest`.
2. For each entry, confirm the identity `digest` is a full-file digest (its `algorithm` is declared
   and, for `host-block`, the `full_size` matches `full_file_size`), and that the `source` is one of
   the three registered sources.
3. Confirm every `unresolved` entry carries its access proof and a non-empty `reason` - an unresolved
   region MUST NOT be silently absent.
4. If the consumer has an auditor-approved set of model-file digests, compare each entry's identity
   digest against it (value-binding, as in the [security-verdict](security-verdict.md)).

No live execution environment is required.

## What a verifier can and cannot independently check (honesty)

- A verifier CAN confirm the BOM is authentic and bound to its subject digest, that every resolved
  entry carries a full-file identity from a declared host source, and that unresolved regions are
  recorded rather than dropped.
- A verifier CANNOT, from the BOM alone, confirm that the VMA walk reflects the real guest address
  space, nor that a `host-block` digest was read from the genuine backing file. These are host
  self-assertions carried under the signature. The BOM's guarantee is integrity, provenance, subject
  binding, and the *structural* honesty rules (full-file-only identity, fail-loud unresolved) - not
  external re-execution of the VMI walk.

## Why no perimeter sandbox can emit it (rationale)

A perimeter sandbox sees an application's API calls, not its address space. It cannot perform a VMA
walk of what a guest process mmap'd (no VMI from outside the guest at the API layer), and it cannot
resolve a mapped inode to a host-side full-file identity (no host view of the guest's backing store).
The INTENT half requires VM introspection at the hypervisor boundary; the IDENTITY half requires a
host layer below the guest. A competitor can copy the BOM JSON; it cannot fill an entry's access proof
or a host-sourced full-file identity without the hypervisor-boundary substrate.

## Status and maturity

- **Format and the BOM shape: stable, real.** The `entries` / `unresolved` structure, the host-side
  resolution rule, the full-file-only identity rule, and the partial-residency honesty rule are
  implemented in the reference implementation's Go introspection layer.
- **Identity sources: verity catalog and host-block live, proxy a seam.** The verity-anchored catalog
  (`verity-exact-root-digest` / `verity-heuristic-size`) and `host-block` are live resolvers;
  `proxy-stream` is an interface seam only in v1 (the egress-proxy path is a separate effort) and
  resolves nothing until wired.
- **VMI heap/region extraction maturity: partial.** The VMA-walk access-proof half is groundwork; the
  fail-loud `unresolved` recording is the honest disclosure for regions the current extraction cannot
  resolve. A live ML-BOM from an N>=2 jailed mesh run is bound by an
  [a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md); the shipped scorecard now binds a real N=2 jailed run
  whose members carry an honestly-empty ModelBOM (`model_bom.entries == null` — the bench guests are
  static-Go init images with no model-weight regions), not synthetic illustrative entries.

## Related standards

- **[CycloneDX](https://cyclonedx.org/)** - RELATED, not overlapping. CycloneDX describes what an
  artifact *declares* it contains (including its ML-BOM extension). This predicate describes what a
  frozen VM *demonstrably mapped and ran*, with identity grounded in a host layer. A CycloneDX BOM and
  an execution-grounded ML-BOM are complementary: the former is the declaration, the latter is the
  execution-time evidence.
- **[a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md)** - the consumer that binds a per-member ML-BOM by
  digest.
- **[in-toto](https://github.com/in-toto/attestation) / [DSSE](https://github.com/secure-systems-lab/dsse)**
  - the envelope; see [signing-envelope.md](signing-envelope.md).

## Versioning

This is the ml-bom v1 predicate. Adding a new optional field - including an additive identity-source
kind or an additive per-entry field - is additive and stays within v1; a verifier ignores fields it
does not recognize. A rename or removal of a field, a change to the identity-resolution rule, or a
change to the full-file-only identity rule is breaking and requires a new major version.

## Changelog

- **2026-07-19** - ModelIdentity wire keys changed from PascalCase to snake_case (including
  `CopiedUp` -> `copied_up`) in the reference producer's host-identity resolver, changing the
  ModelBOM / EnrichmentDigest JCS digest basis. Pre-release and box-gated; no committed golden
  pinned the old shape.
