Skip to main content

AI BOM Trust and Validity

Reference

An AI BOM is only worth acting on if two separate questions have answers: is this document valid — does it describe the image that is actually running, is it recent, does it parse — and how do we know who asserted it. The first is a set of checks that need no cryptography at all. The second is a graded trust level determined entirely by how the document reached us. This page is the reference for both.

Current status

Running today. POST /api/v1/bom/ingest runs every validity check on every push — schema, binding, freshness, monotonicity, and widening — and every finding code on this page is produced by this release. A blocking finding rejects the document; a non-blocking one is returned alongside a successful ingest. Each stored document is assigned a trust level from how it arrived, and every push that reaches validation — whether accepted, or rejected by a blocking finding or by the monotonicity guard — is audited with the pusher, the arrival time, the trust level, the findings, and any widened paths. A push rejected earlier than that, by a malformed document or a credential that does not authorise it, is answered but not audited. Pushing no longer needs an administrator token: a bom_ingest credential scoped to a namespace, and optionally to a project, is accepted.

Digest binding needs the runtime integration. The digest a container is actually running is read from the cluster's pod status, which is only observed where the runtime guard integration is running. Everywhere else binding reports unknown with that as its stated reason — see Binding states for every reason and what each one means. unknown is never a pass.

Also not yet running.

  • The assigned trust level is not displayed anywhere. It is recorded on every stored document and included in the ingest response, but no screen surfaces it.
  • requireBOMTrust is not a configurable setting yet. There is no namespace or workspace key to set it, and nothing reads a floor.
  • maxBOMAge is not configurable yet. The 90-day window is the default the freshness check uses; there is no setting to change it.
  • Admission-time blocking is not wired at all.

The trust and validity model does not change what a valid behavior section should look like — see AI BOM Behavior Section, which carries its own status note about behavior being recorded but not yet compiled into enforcement.

Signing is a strength multiplier, never a prerequisite

The design point worth stating before any table: you do not need cosign to get value from an AI BOM.

An organization that already signs artifacts in CI will be able to set requireBOMTrust: attested and get the strictest behavior available, including admission-time blocking. An organization that will never adopt signing sets authenticated instead and still gets the full runtime enforcement ceiling, with named accountability for who pushed the document and when.

The API push is the default path and needs no cosign, no registry write, and no key material. Everything on this page except admission blocking is reachable from it.

That is deliberate. A governance feature that begins with "first, roll out artifact signing across every pipeline" is a feature most teams never turn on. The cost of not requiring signatures is stated plainly below rather than buried: it is admission blocking, and only admission blocking.

The always-on validity checks

Three checks are always on, and none of them requires cryptography. They are available to a customer with no signing infrastructure whatsoever.

CheckRuleFailure code
Bindingsubject.digest equals the digest actually runningbom-mismatch
FreshnessThe generation time is within maxBOMAge (default 90 days)bom-stale
SchemaThe document parses, and behavior uses only known primitivesbom-invalid

Two further codes come from the risk-mitigation set on the push path, and one reports a state that is not a failure at all. Both of those checks run today.

Every code, and whether it blocks

Blocking is a field on each finding, not a property of the code's severity in the abstract. A blocking finding denies the document any enforcement authority; an advisory one is recorded and surfaced, and the document still counts.

CodeMeaningBlocking
bom-invalidThe document does not parse, or behavior uses a primitive this build does not know. Produced at parse time, before any other check.Blocking — the document is rejected outright, not stored
bom-mismatchWithin a single pod running the declared image, another container of the same image repository is running a different digest. See Binding states for why it is scoped to one pod.Blocking
bom-staleThe effective generation time is older than maxBOMAge. Also raised when the document has no usable generation time at all — neither generatedAt nor metadata.timestamp, and no recorded push time.Blocking
bom-nonmonotonicA second BOM for the same subject.digest carried a generation time no newer than the one already stored. A replayed or backdated document.Blocking
bom-widenedThe behavior contract broadened for an unchanged image digest.Advisory — recorded and surfaced. A namespace may choose to require approval for it
bom-no-digestThe document declares no subject.digest.Not a failure — see below

bom-no-digest is reported so a screen can explain why a workload will never reach an enforced state, rather than leaving the reader to infer it from an absence. The document is valid and still confers inventory; it simply has nothing to bind a declaration to.

The freshness verdict is computed against an effective generation time, which is reported alongside the findings so you can see which timestamp the check actually used.

What the ingest endpoint answers

StatusWhen
200The document was accepted. Non-blocking findings — bom-widened, bom-no-digest — are returned in the same response body, alongside the trust level, the binding state, and the reason binding could not complete
400The document does not parse, or behavior uses an unknown primitive. The body names the failing check with bom-invalid
401No credential was presented, or the one presented is unknown, revoked, or expired
403The credential resolved but is not permitted to assert about this namespace and project, or the ingest endpoint is turned off. Re-presenting the same token will not help, which is why this is not a 401
409A document at or newer than this one is already stored for the same namespace and subject.digest. Losing this race is a normal outcome, not a fault
413The document exceeds the maximum accepted size
422A blocking finding fired. The body names every finding, blocking and advisory, so you can see which check rejected the push
500The server could not store the document, or the endpoint is misconfigured. Safe to retry: the inventory record is written before the trust ledger and is idempotent, so a retry of the same document is not refused as a replay
503Storage is not configured on this server

An accepted push is recorded per source commit: pushing the same commit twice stores one inventory row and answers "ingested": false. That is not a general "push twice, nothing happens" — a digest-bound document pushed a second time is refused by monotonicity, because its generation time is no newer than the one already stored for that digest.

Binding states — unknown is not a pass

The binding check has three outcomes, and only one of them is success.

StateMeaning
boundThe declared digest is confirmed running, and every pod running it is internally consistent
mismatchInside a pod running the declared image, another container of the same repository is running a different digest
unknownThe check could not be performed. The reason is always stated
warning

unknown is not a pass. An unperformed check and a passed check are different facts, and an unknown result is always reported as unknown with a reason attached — never rendered as success, and never quietly folded into "no findings".

What bound does not say

bound does not mean "everything on this repository is on the declared digest". It means the declared digest was found running, and no pod running it contains a container of the same repository on something else. Other pods on that repository — an older rollout, a replica still pulling — do not change the verdict, for the rolling-update reasons below.

When the observation was incomplete outside those pods, bound states its own scope: the reason field names how many containers, in how many other pods, had not reported a digest. A bound with no reason attached is a complete observation; a bound with one is a confirmed digest plus a stated gap.

Which containers are compared

The declaration is compared against the containers running the repository named by subject.image, in the namespace the push names.

  • The tag in subject.image is never part of the comparison — it is mutable. Only the repository half is read, and the comparison itself is digest against digest.
  • Selecting by repository is what keeps other workloads in the namespace, and mesh or telemetry sidecars inside the same pod, out of the comparison — no sidecar exclusion list is needed.
  • Regular containers and init containers participate. An init container built from the workload's own repository (a migration step, or a native sidecar) is part of the same declared artifact.
  • Ephemeral containers do not. A kubectl debug container is operator-injected tooling with a separate lifecycle. Including it would let someone debugging an incident turn the next CI push into a blocking rejection.
  • Pods in a terminal phase do not. Succeeded and Failed pods are not running.
  • If subject.image is absent, no containers can be identified and binding reports unknown.

Why mismatch is scoped to a single pod

A false bom-mismatch is worse than a missed one. It is blocking — it rejects the push with 422 — and downstream it moves a workload toward violating, which the trust model treats as a stronger negative signal than never having declared at all.

Two completely ordinary deployment states would produce a false mismatch under the naive rule "every running digest must equal the declared one":

  • The declared build has not rolled out yet. CI pushes the BOM for the image it just built, and the deploy lands afterwards — so at push time the repository is still running the previous digest. Under the naive rule, every redeploy of an existing service would fail the pipeline.
  • A rolling update is in progress. New pods are on the declared digest while old pods are still on the previous one. Expected, and transient.

Neither is evidence that the document describes a different image; both are facts about deployment timing, which a point-in-time check cannot tell apart from the real thing across separate pods. Inside a single pod it can: a pod's containers are created together from one spec, so two containers of the same repository running different digests is never a rollout artifact. That is the condition mismatch is reserved for.

The reason field is grouped by pod and names each affected pod, the container inside it, and the digest that container is running — because the pod is the thing you go and look at. When more than one pod is affected, each is listed separately rather than flattened into a single list of containers.

A definite in-pod disagreement outranks an incomplete observation elsewhere — a container that has not reported a digest cannot suppress a divergence that was positively observed.

Every reason binding reports unknown

Each carries its own reason string, so a screen can say which one applies:

  • The document declares no subject.digest. There is nothing to compare. This is the inventory-only case, and it also raises the non-blocking bom-no-digest.
  • The digest actually running is not observed on this deployment. It is read from pod status by the runtime guard integration, which is not active here.
  • The cluster's running images have not been observed yet. The observation refreshes periodically and has not completed its first pass since this replica started.
  • The document declares no readable subject.image. The containers the declaration describes cannot be identified. Add subject.image alongside subject.digest.
  • No running container was found for the declared image. The namespace runs nothing on that repository — commonly a BOM pushed for a workload that is not deployed here.
  • The declared image is not currently running. Containers on that repository exist, but none is on the declared digest. This is the normal state when the document was pushed before the deployment rolled out. The reason counts every container on the repository, split into those observed on a different build and those whose digest could not be read — a namespace with one of each reads as "of the 2 containers on this image, 1 is running a different build and 1 has not reported a digest".
  • No running digest has been reported yet. The containers exist but the images are still being pulled, or the runtime reported a local image ID rather than a manifest digest.
  • The comparison is incomplete. One or more pods running the declared image have a container that has not reported a digest, so those pods cannot be confirmed bound. The reason is grouped by pod and names each one.

A digest that does not match what is running does not mean the document is a weaker description of the same thing. It means the declaration belongs to a different build, which is why the finding blocks rather than degrades.

Trust levels

Trust is graded, not binary. The level is a property of how the document arrived — never of its contents.

LevelWhat establishes itDrives enforce modeAdmission may blockAlerts
attestedA signature or in-toto attestation that an external verifier confirmedYesYesno
authenticatedPushed to the API with a scoped credential, with the pusher identity and push timestamp recorded independently of the documentYesnono
declaredPresent in-cluster with no signature and no authenticated push — or an unsigned artifact in a registryNo — monitor only; promotion requires an explicit, recorded operator acknowledgementnono
unverifiedA signature was present and did not verifyNonoYes
noneNo BOM at allNonono
Which levels a push can reach today

A document pushed to the ingest endpoint is assigned authenticated and stored with that level. It is the only level reachable in this release: no attestation, OCI referrer, or in-cluster acquisition path is wired, and no signature is presented on an API push, so attested, declared, and unverified cannot currently be produced. The table describes the model in full; treat the other four rows as the contract, not as states you will see. The level is also not displayed anywhere in the UI yet — it is recorded on the stored document and returned in the ingest response.

unverified is not the same as "no BOM"

unverified confers exactly what none confers — nothing. It ranks with none for every floor comparison, and no enforcement follows from it.

But the two must never be collapsed, because unverified alerts and none does not. "Nobody signed this" is an adoption gap. "Someone signed this and the signature does not check out" is an incident. A broken signature is a stronger signal than a missing one, and treating it as ordinary absence inverts the signal — the loudest case becomes the quietest.

This is why an invalid signature yields unverified regardless of how the document arrived. It cannot fall back to whatever the unsigned form of that path would have conferred; a signature that does not check out is worse than no signature at all.

AIControls never verifies signatures itself

AIControls does not implement cryptographic verification, and is not planned to. Signature checking stays with Kyverno's ImageValidatingPolicy, which already handles signing keys, keyless Fulcio identities, Rekor transparency-log inclusion, timestamp authorities, and air-gapped key material.

AIControls records the outcome an external verifier reported, and nothing more. There are three outcomes:

OutcomeMeaning
absentNo signature was presented
validAn external verifier confirmed the signature
invalidAn external verifier rejected the signature

How a level is assigned

A level is a function of exactly two inputs — the acquisition path and the recorded verification outcome. The document itself is not an input.

Acquisition pathWhat it isabsentvalidinvalid
api-pushPOST to the ingest endpoint with a scoped credential. The default path, and the one that needs no cosign.authenticatedauthenticatedunverified
attestationA cosign attestation or in-toto statement attached to the imagedeclaredattestedunverified
oci-referrerAn OCI 1.1 referrer, or the cosign tag conventiondeclaredattestedunverified
in-clusterA ConfigMap, Custom Resource, or annotation in the workload's namespacedeclareddeclaredunverified

Two rows are worth reading twice:

  • An attestation nobody verified is declared, not attested. An unverified attestation is just an unsigned artifact sitting in a registry. It does not earn attested-level trust on the strength of its own claim to be an attestation.
  • An API push does not become attested by also carrying a valid signature. The ceiling is a property of the path: an API-pushed document is invisible to image-signature verification at admission no matter what else is true of it, so it stays at authenticated.

Admission can only gate attested BOMs

This is the concrete, single cost of not requiring cosign, and it is stated here rather than left to be discovered.

Admission-time blocking is available for attested documents only. An API-pushed or in-cluster BOM is invisible to image-signature verification at admission — there is nothing at the admission decision point that can confirm it — so blocking on one would be a gate that does not hold. It is therefore not offered rather than offered and unreliable.

Every other consequence of a trust level, including full runtime enforcement, is reachable at authenticated.

What a document cannot do to its own trust level

  • A document cannot raise its own trust level. There is deliberately no trustLevel field, and no field a producer could set that influences the assignment. Level assignment does not read the document at all.
  • A document cannot self-attest freshness. generatedAt is an assertion by the producer, not a fact established by it. When an independently recorded push time is available and the two disagree, the push time wins: a producer cannot buy itself freshness by writing a generatedAt newer than the moment it actually handed us the document. The ingest endpoint takes that arrival time before a single byte of the document has been read, and it is the timestamp both freshness and monotonicity are measured against — so a future-dated push cannot park itself permanently ahead of every honest scan of the same image. When no independent timestamp is available, the document's own claim is used and the check is correspondingly weaker.
  • A document cannot widen anything. See What a behavior section cannot do.

requireBOMTrust — the per-namespace floor

requireBOMTrust is the minimum trust level a document must reach in that namespace. Only three values are settable:

ValueWhat it gets you
declaredThe most permissive floor. Any BOM present — including an unsigned in-cluster document — clears it. Suitable for building inventory coverage before tightening.
authenticatedRequires that someone authenticated pushed the document and that we recorded who and when. Full runtime enforcement, named accountability, no cosign required. This is the recommended floor for most organizations.
attestedRequires an externally verified signature. Adds admission-time blocking on top of everything authenticated gets. Choose this only if artifact signing is already in your pipelines.

none and unverified are outcomes, not policies — they describe what happened to a document, and nothing sensible is expressed by requiring them, so they are rejected as configuration values.

A floor that is empty, unrecognized, or one of the two non-settable values is treated as declared — the most permissive configurable floor. A typo in configuration must not silently raise the bar and pull a fleet out of enforcement, and must not silently lower it below the weakest thing an operator could have deliberately chosen either. Validate the configured value at load time rather than relying on that fallback.

Push protections

The API push path deliberately removes the cryptographic barrier — no cosign, no registry write. These protections are what replace it. They are not optional extras layered on a path that is already safe; they are the reason the path is safe enough to be the default.

The threat they exist for is specific: a permissive BOM pushed by a compromised or over-scoped CI token.

ProtectionWhat it does
Monotonic generatedAt per digestA second document for the same subject.digest must carry a strictly newer generation time. A replayed or backdated push raises the blocking bom-nonmonotonic.
Widening is flaggedFor a fixed digest, a behavior contract that broadens raises bom-widened naming every path that grew.
Scoped ingest credentialsThe ingest endpoint requires an authenticated, revocable token. Anonymous pushes are refused, and the endpoint can be disabled entirely.
Audited pushesEvery accepted push records who pushed it, when we received it, and what changed against the previous document for that digest.
How the credential is scoped

All four protections run on the push path today.

The ingest credential is a bom_ingest token scoped to one namespace, and optionally to one project. It is checked against the namespace and project in the request being authorized, not against whatever the token was issued for, so a staging token cannot authorize a push that names production. A token with no scope authorizes nothing — it fails closed rather than defaulting to every namespace. An administrator credential is also accepted and is unscoped, but a scanner running in CI should be given the narrow token, not an administrator one.

bom_ingest is a distinct token kind: a token of any other kind, however it is scoped, is not an ingest credential. Asserting a governed declaration about a workload is a different capability from anything else a token grants, and one token should never hold both.

Widening — an absent section is the widest value

The rule that makes widening detection more than a set difference, and the one most likely to surprise:

Each behavior section is an allow list, so an absent section does not declare nothing — it declares no constraint, which is the widest possible value.

The consequence is that removing network entirely is a bigger widening than adding one destination to it, and a naive new-minus-old comparison would score the removal as zero change.

PreviousNewVerdict
network: {dns: [a.com]}section removedWidened — every constraint is gone
network: {dns: [a.com]}network: {dns: [a.com, b.com]}Widenedb.com added
network: {dns: [a.com]}network: {dns: [a.com, b.com]}Widened — b.com added
network: {dns: [a.com, b.com]}network: {dns: [a.com]}Not widened — one value removed, the list still constrains
network: {dns: [a.com]}network: {dns: []}Widened — emptying the list removes the constraint
section absentnetwork: {dns: [a.com]}Not widened — a new constraint appeared

The same logic applies to the whole behavior section: removing it outright from a document that previously declared constraints is a widening of everything at once.

Further rules:

  • The first BOM for a digest never widens. With nothing to compare against, a first document may declare whatever it likes. Widening is a statement about a change to a fixed image's declaration.
  • exec treats absent and empty as the same value. It is a bare list, so an empty exec reads as "no constraint declared", matching the section-absent case above.
  • The rule applies to individual lists, not just whole sections. Dropping network.cidrs while keeping network.dns removes a constraint just as surely as dropping network entirely.
  • kubernetes.apiAccess is ranked, and a raise is a widening: none (or unset) is lowest, then read, then declared. declared outranks read because a declared rule set may include writes.
  • kubernetes.rules are compared as the individual (apiGroup, resource, verb) grants they authorise, not rule by rule. Narrowing a rule — dropping a verb or a resource — is correctly silent, and splitting or merging rules without changing what they permit is silent too. Recombining values already declared separately is reported: declaring {pods: get} and {secrets: list} and then adding {secrets: get} grants access that was never authorised, even though secrets and get each appeared before.
  • Removing some values from a list that stays non-empty is never widening. Emptying it entirely is, because that removes the constraint.

One known false positive. Paths and globs are compared as literal strings — there is no glob containment — so replacing /app/** with /app/x, a genuine narrowing, is reported as an addition. Implementing containment across glob dialects is disproportionate to the benefit when widening is advisory rather than blocking, so the finding text says so itself rather than the limitation being hidden.

Every widened path is reported by name — behavior.network.dns (added [b.com]), behavior.mcp (constraint removed) — so the finding says what grew, not just that something did.

See also

  • AI BOM Behavior Section — the subject and behavior field reference, and the schema rules behind bom-invalid
  • Runtime Guard — the runtime API and the three-state capability contract
  • Shadow AI — what an AI BOM push contributes to discovering ungoverned agents
  • Security Posture — where findings surface alongside other posture signals