Product evidence mapping and provenance limits¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
This runbook is for product-mode CRA audits: one supported release built from several code repositories plus a dedicated compliance repository. It does not replace security-audit-presets.md. That guide still owns the four preset contracts. This page is the mapping and publication contract those presets rely on when the unit of analysis is a product.
What the mapping is¶
An optional trigger payload field product_provenance
(preloop.cra.product_provenance/v1) names:
- the product
- the supported-release identifier
- the build identity your CI already has (id, optional URL)
- the SBOM digest (
sha256:plus 64 hex) and the workspace path of the supplied SBOM artifact - every constituent repository with its credential-free HTTPS remote,
exact 40-hex SHA, clone path, and role (
codeorcompliance)
When the field is absent the flow keeps legacy behaviour: one bound repository, or an artifact-only audit with no git checkouts.
What a mapping must contain¶
A mapping has to bind the evidence to something identifiable, so it needs at least one of:
repositories[], when the flow clones them. The remotes must matchgit_clone_config; naming a repository the flow does not clone is refused, because an unauthorized remote is the one thing the mapping exists to prevent.sbom.digest(sha256:plus 64 hex), which is what an SBOM-only flow supplies. SBOM Verify (preset 004) shipsgit_clone_config: nulland has no constituent repositories to name.
repositories used to be mandatory, which made the mapping unusable on
exactly those SBOM-only flows: omitting it was rejected as an incomplete
mapping and supplying it was rejected as unauthorized, so no accepted
body existed (preloop/preloop#509). A mapping with neither is still
refused: it names a product without identifying it, which is not
provenance.
Every shape error names three things: the schema, the offending key, and
this page. Shape errors are answered by the trigger with a 400,
before an execution row exists, so a body the platform was always going
to refuse no longer costs a run or leaves a FAILED row in the flow's
history. Checks that need runtime facts (does the mapped SHA match the
checkout this run actually got, does the digest match the supplied
bytes) still happen during the execution, because that is where the
facts are.
Where the mapping goes in the body¶
product_provenance is read from payload first, then from the top
level of the trigger body, and workspace_files follows the same rule.
See where workspace_files goes.
Example (synthetic)¶
Two code repositories and one compliance repository for
example-product release 1.4.2:
{
"product_provenance": {
"schema": "preloop.cra.product_provenance/v1",
"product": { "name": "example-product" },
"release": { "identifier": "1.4.2", "channel": "supported" },
"build": { "id": "build-2026-09-07.14" },
"sbom": {
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"path": "sbom/image.spdx.json"
},
"repositories": [
{
"remote": "https://github.com/example/firmware.git",
"sha": "1111111111111111111111111111111111111111",
"clone_path": "firmware",
"role": "code"
},
{
"remote": "https://github.com/example/companion-app.git",
"sha": "2222222222222222222222222222222222222222",
"clone_path": "companion-app",
"role": "code"
},
{
"remote": "https://github.com/example/product-compliance.git",
"sha": "3333333333333333333333333333333333333333",
"clone_path": "compliance",
"role": "compliance"
}
]
},
"workspace_files": [
{
"path": "sbom/image.spdx.json",
"content_base64": "<base64 of the CI-emitted SBOM>"
}
]
}
Attach the same remotes on the flow's git_clone_config.repositories
(with clone_path: compliance for the compliance repo) and isolated
publication mode. The platform checks:
- every mapped remote is an authorized repository on this account/flow
- clone paths are unique and match the flow config
- each mapped SHA equals the immutable commit the control plane pinned
and later observed in a frozen checkout bundle. A moving branch tip
is never a verified checkout. The mapping names intended remotes and
SHAs; it is not a signed build attestation. Read-only
maintenance/product audits freeze HEAD into the same
evidence/branch.bundle(orevidence/repos/<clone_path>/branch.bundle) path without writer credentials when isolated publication is off. - the mapped SBOM digest equals the sha256 of the supplied artifact bytes
Ambiguous, duplicate, or mismatched mappings fail the execution. A repository outside the manifest or account is never published.
True provenance limits¶
These are the claims this mapping can support:
- "This audit ran against these remotes at these SHAs, which matched
frozen checkout bundles (or, before freeze, a controller-resolved pin
recorded as
pin_matched, notverified)." - "This SBOM file, with this digest, was the artifact supplied to the run."
- "These platform approval ids, reviewers (user ids), times, and decisions are copied from Preloop approval rows."
- "These remote commits / pull requests were published by the isolated publisher, with a receipt per repository."
These are claims it cannot support, and must not be written as if it could:
- Cryptographic build attestation that the SBOM was produced from
those SHAs. That link is only as strong as the build metadata your CI
delivers. An agent-written SHA in
result.jsonor an evidence stub is a declaration, recorded asdeclared_unverifiedunless the platform matched it against a trusted fact. - Human approval invented from model output. Reviewer names and timestamps come from the approval audit trail, or they are omitted.
- Certification, CE marking, or a completed conformity assessment.
- Object-lock or WORM retention of evidence blobs. A Preloop legal hold
does exist now (see
evidence storage): it
blocks purge and payload expiry inside Preloop, and it is not a
storage-layer guarantee. The dossier copies a server-owned
kind=evidencereceipt (sha256, status, retention hours,legal_hold,integrity_verified) frominspect_evidence/load_evidence. Availability polls are not download integrity.retainedis true only after a verified receipt. - A successful product publication when any one repository failed to push or open its pull request. Local commits are not success. Partial remote receipts stay on the execution; the run is failed. Retry is idempotent: a remote already at the verified head is not published again, and a remote that is not in the manifest cannot be added on retry.
- Automatic merge. Existing branch and pull-request approval gates still apply. Isolated publication never merges.
Isolated multi-repo publication¶
Hosted and private isolated publication export one git bundle per
authorized checkout (evidence/repos/<clone_path>/branch.bundle),
verify each bundle, then publish with a repository-scoped GitHub App
lease. Write credentials never enter the agent environment. The private
runner protocol repeats freeze/verify/publish per target; credentials
stay on the controller. Partial remote failure keeps the per-repo
receipt. A later execution resume reuses each original
branch/base/expected head/history. Already-published remotes are not
opened as duplicate pull requests. Adding, removing, or remapping
constituent repositories on resume is refused.
When git_clone_config.publication_approval is true, required, or
require, a human platform approval must cover every candidate that is
about to receive a writer lease: repository URL, destination branch,
base branch, and the frozen head SHA that will be pushed. An approval
bound only to the original source base does not authorize a later
candidate head. Unpaired repository and commit lists, swapped
repo-to-SHA pairings, expired rows, declined rows, and AI-decided rows
do not authorize. A human decision has no auto_approved_reason; an
empty or whitespace reason is not human. If the saved execution, flow,
or clone config cannot be read, the writer lease is refused. Default
flows omit this field and keep existing publication behaviour.
Supported tool: request_approval¶
Call the builtin request_approval tool with optional
publication_candidates. That parameter is the only publication
authority. Text or JSON in context is not. Ordinary
request_approval callers that omit the parameter are unchanged and
cannot authorize a writer lease.
Runnable payload (synthetic remotes and SHAs):
{
"operation": "publish isolated product repositories",
"context": "frozen checkouts are ready to receive writer leases",
"reasoning": "human review of destinations and commits before mint",
"publication_candidates": [
{
"repository_url": "https://github.com/example/firmware.git",
"branch": "preloop/change",
"base": "main",
"head_sha": "1111111111111111111111111111111111111111"
},
{
"repository_url": "https://github.com/example/companion-app.git",
"branch": "preloop/change",
"base": "main",
"head_sha": "2222222222222222222222222222222222222222"
}
]
}
The tool stores action: isolated_publication plus those exact
(repository_url, branch, base, head_sha) tuples on a normal pending
ApprovalRequest for the current execution. Invalid tuples return an
error and do not create a row.
Human workflow: a reviewer opens the pending request in the console
(/console/approval/<id>) or the in-session notice, confirms the listed
destinations and frozen SHAs, and approves through the ordinary
approval surface. Managed execution and agent API keys cannot decide a
publication-authority request (canonical isolated_publication and
legacy publish forms). Auto-approved and AI-decided rows still cannot
satisfy publication. After approval, isolated publication compares the
saved tuples to the candidates about to be minted; a modified SHA, a
swapped pairing, a source-base tuple, or a row from another execution
is refused.
Per-repo receipts include the remote URL, PR URL, number, branch, base,
records, and head SHA. trusted_publication.complete is true only when
every authorized repository published. Hosted isolated success and
partial receipts persist as the top-level trusted_publication record
on the execution result so resume can rebind; agent-authored copies are
stripped and are not authority. When clone_path is omitted, the first
repository is workspace and later repositories are workspace-2,
workspace-3, matching isolated bind and resume.
Dossier manifest¶
The control plane writes dossier_manifest
(preloop.cra.dossier_manifest/v1) only when the run has an explicit
product mapping, isolated publication, a CRA result schema, or a caller
that supplied product-evidence context. Ordinary flows keep their
existing result objects and do not load approval or evidence records
for a dossier.
The dossier records separate digests for the raw agent result and the
annotated control-plane result (mapping and publication receipts).
Sensitive fields are redacted. The dossier does not hash itself.
Evidence fields come from a kind=evidence receipt; missing or
unverified evidence is reported as not retained.
One SBOM per mapping (known limit)¶
sbom is a single artefact: one digest, one path. A mapping can
therefore bind exactly one SBOM to a release. That is the right shape
for a single image or firmware build and the wrong shape for a product
that ships several artefacts at one release (an image plus an installer,
or one SBOM per architecture).
Today, supplying several SBOM-shaped files without naming
sbom.path fails with Multiple SBOM-like workspace files supplied;
name sbom.path. That message is accurate and the behaviour is the safe
one: guessing which artefact the digest refers to would put an unearned
claim in the evidence pack. But naming one path only lets you verify
one of them, and the others are then delivered to the run without ever
being bound to the release.
The proposed shape, not implemented, is an optional artefacts[]
alongside sbom, with sbom remaining the single-artefact spelling:
{
"artefacts": [
{
"name": "firmware-image",
"kind": "sbom",
"format": "spdx-2.3",
"path": "sbom/image.spdx.json",
"digest": "sha256:<64 hex>"
},
{
"name": "installer",
"kind": "sbom",
"format": "cyclonedx-1.5",
"path": "sbom/installer.cdx.json",
"digest": "sha256:<64 hex>"
}
]
}
Each entry would be digest-verified against its own supplied bytes and
recorded with its own status, so a partially verified set is reported as
such rather than collapsing to one verdict. Open questions before
building it: whether a release verdict is the worst of its artefacts or
one verdict per artefact, whether sbom becomes an alias for a
single-entry artefacts[] or stays a separate field, and what the
dossier manifest lists when one artefact verifies and another does not.
Deciding those is the work; the schema above is the easy part.