AI BOM Trust and Validity
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.
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.
requireBOMTrustis not a configurable setting yet. There is no namespace or workspace key to set it, and nothing reads a floor.maxBOMAgeis 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.
| Check | Rule | Failure code |
|---|---|---|
| Binding | subject.digest equals the digest actually running | bom-mismatch |
| Freshness | The generation time is within maxBOMAge (default 90 days) | bom-stale |
| Schema | The document parses, and behavior uses only known primitives | bom-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.
| Code | Meaning | Blocking |
|---|---|---|
bom-invalid | The 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-mismatch | Within 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-stale | The 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-nonmonotonic | A 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-widened | The behavior contract broadened for an unchanged image digest. | Advisory — recorded and surfaced. A namespace may choose to require approval for it |
bom-no-digest | The 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
| Status | When |
|---|---|
200 | The 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 |
400 | The document does not parse, or behavior uses an unknown primitive. The body names the failing check with bom-invalid |
401 | No credential was presented, or the one presented is unknown, revoked, or expired |
403 | The 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 |
409 | A 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 |
413 | The document exceeds the maximum accepted size |
422 | A blocking finding fired. The body names every finding, blocking and advisory, so you can see which check rejected the push |
500 | The 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 |
503 | Storage 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.
| State | Meaning |
|---|---|
bound | The declared digest is confirmed running, and every pod running it is internally consistent |
mismatch | Inside a pod running the declared image, another container of the same repository is running a different digest |
unknown | The check could not be performed. The reason is always stated |
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".
bound does not saybound 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.imageis 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 debugcontainer 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.
SucceededandFailedpods are not running. - If
subject.imageis absent, no containers can be identified and binding reportsunknown.
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-blockingbom-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. Addsubject.imagealongsidesubject.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.
| Level | What establishes it | Drives enforce mode | Admission may block | Alerts |
|---|---|---|---|---|
attested | A signature or in-toto attestation that an external verifier confirmed | Yes | Yes | no |
authenticated | Pushed to the API with a scoped credential, with the pusher identity and push timestamp recorded independently of the document | Yes | no | no |
declared | Present in-cluster with no signature and no authenticated push — or an unsigned artifact in a registry | No — monitor only; promotion requires an explicit, recorded operator acknowledgement | no | no |
unverified | A signature was present and did not verify | No | no | Yes |
none | No BOM at all | No | no | no |
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:
| Outcome | Meaning |
|---|---|
absent | No signature was presented |
valid | An external verifier confirmed the signature |
invalid | An 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 path | What it is | absent | valid | invalid |
|---|---|---|---|---|
api-push | POST to the ingest endpoint with a scoped credential. The default path, and the one that needs no cosign. | authenticated | authenticated | unverified |
attestation | A cosign attestation or in-toto statement attached to the image | declared | attested | unverified |
oci-referrer | An OCI 1.1 referrer, or the cosign tag convention | declared | attested | unverified |
in-cluster | A ConfigMap, Custom Resource, or annotation in the workload's namespace | declared | declared | unverified |
Two rows are worth reading twice:
- An attestation nobody verified is
declared, notattested. 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
attestedby 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 atauthenticated.
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
trustLevelfield, 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.
generatedAtis 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 ageneratedAtnewer 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
behaviorsection 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:
| Value | What it gets you |
|---|---|
declared | The most permissive floor. Any BOM present — including an unsigned in-cluster document — clears it. Suitable for building inventory coverage before tightening. |
authenticated | Requires 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. |
attested | Requires 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.
| Protection | What it does |
|---|---|
Monotonic generatedAt per digest | A 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 flagged | For a fixed digest, a behavior contract that broadens raises bom-widened naming every path that grew. |
| Scoped ingest credentials | The ingest endpoint requires an authenticated, revocable token. Anonymous pushes are refused, and the endpoint can be disabled entirely. |
| Audited pushes | Every accepted push records who pushed it, when we received it, and what changed against the previous document for that digest. |
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.
| Previous | New | Verdict |
|---|---|---|
network: {dns: [a.com]} | section removed | Widened — every constraint is gone |
network: {dns: [a.com]} | network: {dns: [a.com, b.com]} | Widened — b.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 absent | network: {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.
exectreats absent and empty as the same value. It is a bare list, so an emptyexecreads as "no constraint declared", matching the section-absent case above.- The rule applies to individual lists, not just whole sections. Dropping
network.cidrswhile keepingnetwork.dnsremoves a constraint just as surely as droppingnetworkentirely. kubernetes.apiAccessis ranked, and a raise is a widening:none(or unset) is lowest, thenread, thendeclared.declaredoutranksreadbecause a declared rule set may include writes.kubernetes.rulesare 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 thoughsecretsandgeteach 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
subjectandbehaviorfield reference, and the schema rules behindbom-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