The asset is the thing being protected
An asset is not a build and not a blob. For a container image it is “the production build of this service”. Its identity isregistry/repository:tag — not the digest:
myapp:staging and myapp:production
are separately protected things, and alpine:3.18 and alpine:3.21 are
separately remediable. A digest-pinned reference with no tag falls back to
registry/repository, the most specific durable name available.
Assets are upserted, not created
The key is(organisation, kind, identity), and it is a database uniqueness
constraint rather than an application check.
So scanning alpine:3.19 a second time does not create a second asset — it
finds the existing one and updates it. You never create an asset explicitly;
there is no endpoint for it. Requesting a scan is what brings one into
existence.
The constraint being in the database matters more than it looks. Two concurrent
requests can both pass a SELECT before either INSERTs — a webhook
redelivery racing a user’s click is not hypothetical — and the result would be
two assets for one image, splitting its history permanently.
Identity carries no tenant component. Two customers scanning alpine:3.19
get two asset rows with the same identity string, and therefore identical
fingerprints for the same CVE. Isolation is enforced by scoped queries, never by
identity — see Tenant isolation.
The scan is one observation
202 means accepted, 200 means deduplicated
202 — the scan was queued. Pulling and unpacking an image takes seconds to
minutes, so the response describes work accepted, not work done. The scan row
is committed before the response is written, which is what makes the wakeup
message that follows an optimisation rather than a step that can lose work.
200 — an existing scan was returned. Your Idempotency-Key matched one
already reserved, so nothing new was queued.
The two codes are distinct on purpose: a client can tell a deduplicated retry
from a new scan without inspecting the body. The key is reserved before any
work begins, so two concurrent retries — the normal case, because that is what a
timed-out client does — cannot both queue a scan. Idempotency keys are scoped
per organisation, never global, so one tenant’s key cannot suppress another’s
scan.
Use your build ID. See Idempotency.
What the response contains
The scan carriestarget_reference — the canonical, fully-qualified
reference actually handed to the engine, not the string you typed. alpine in,
index.docker.io/library/alpine:latest out. The scanner is never left to apply
its own defaulting rules to a short name, which is how “scan alpine” ends up
meaning different things on two differently configured hosts.
It also carries trigger (manual, scheduled or system), because “why did
this run at 3am” is a question a scan list has to be able to answer, and
scanners — what was requested. A container scan runs vulnerability detection
unless you ask for more.
Polling to a terminal status
There is deliberately no way to cancel a running scan, and no scan ever reaches
a
canceled status. A scan you no longer want will finish or fail on its own;
it is bounded by a timeout, so it cannot run indefinitely.
Poll until the status is terminal. A few seconds between polls is plenty; the
work takes seconds to minutes and polling faster only spends your rate-limit
budget.
Work in queued is never dropped. If a scan sits there, the worker is not
running or your per-tenant concurrency limit is saturated — see Scan
failures. It has not been lost.
A completed scan also carries scanners_run, which may be narrower than what
was requested, and warnings, which is where a non-fatal problem is recorded.
Read both. A scan can succeed and still be trying to tell you something.
Reading database_state
This is the field to check before trusting a clean result.
Vulnerability data ages, and a scan run against an old database succeeds, exits
cleanly, and reports nothing — with a recent timestamp and nothing in the result
to suggest the answer is wrong. A stale scanner’s silence is indistinguishable
from good news. So TRUSTIVAN surfaces the age rather than hiding it:
unknown is expected on a brand-new deployment’s first scan and is treated as
usable, because refusing it would make every new install look broken. Persistent
unknown is worth investigating.
If you are gating a pipeline, branch on this field. A build that treats
stale the same as fresh has quietly opted out of the guarantee the hard
limit exists to provide. A clean result with a state other than fresh is not
evidence of a clean image. See Vulnerability
database.
Note what the scan deliberately does not tell you: the engine name, the
engine version and the database version are recorded internally — because “why
did this scan miss that CVE?” must be answerable after the fact — but they are
stripped before any customer-facing response, and a CI check enforces that the
API layer never names the scanning engine. database_state is the part you
need, and it names nothing internal.
The findings
A successful scan writes findings against the asset, not against the scan. One row per logical issue per asset, not one row per observation. That is the whole distinction: a scanner emits a fresh report every night with no link to the previous one, and this row is the link. Rescanning the same image matches each finding to its existing row by fingerprint, so a decision you recorded last week is still attached tonight. A finding that was present and is now absent is resolved by that absence; a resolved finding that comes back is reopened rather than created fresh.order=recent for anything that enumerates. It orders by immutable keys, so
the traversal is exhaustive: every finding present when you started and not
deleted is visited exactly once.
Where to go next
- Reading findings — the anatomy of a finding, and how to work a backlog.
- Assets and scans — the object model in full.
- Scan failures — when one of the above does not happen.

