Skip to content

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 (code or compliance)

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 match git_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) ships git_clone_config: null and 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 (or evidence/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, not verified)."
  • "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.json or an evidence stub is a declaration, recorded as declared_unverified unless 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=evidence receipt (sha256, status, retention hours, legal_hold, integrity_verified) from inspect_evidence / load_evidence. Availability polls are not download integrity. retained is 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.