Observed vs reviewed
The most important boundary in SBOMFlow: machine observation is never human review.
The engine observes
- components and composition
- advisory matches — each stating its match basis
- evidence presence and gaps
- exploitation signals: KEV, EPSS, reachability
Deterministic facts with provenance. Never a judgement.
review files
the only crossing
observation → status
never automatic
Only a human decides
- VEX status —
not_affectedneeds a valid justification - evidence acceptance
- approvals, waivers, risk acceptance
- what blocks a release
Named humans, recorded in the tamper-evident trail.
What the engine observes#
The deterministic engine can observe facts: a component exists, a file is present, an advisory matches a version, a requirement has no attached evidence. These are observations. An observed status is not a judgement that anything is acceptable.
Each advisory finding also states how it was matched, so you can decide what to verify first. A purl-exact match (the advisory was resolved for the exact package URL) is stronger evidence than a name-version-heuristic match (the component's name and version strings matched, but no package coordinate took part). An osv-ecosystem-name match is as strong as purl-exact — the OSV server still evaluated the exact version — but the coordinate was an ecosystem + package name rather than a full package URL, because OSV serves that ecosystem (Bioconductor is the first) without a package-URL type; the label says so instead of claiming a purl match the query never made. A purl-version-range match carries the range engine's own verdict, which drops to low when a version could not be ordered or a distribution rebuild was compared against an upstream range.
This is context for prioritising your review, not a decision. A low-confidence finding is never dropped, never downgraded, and never suppressed — it is exactly the finding most worth a human look. Nothing about the match basis changes the release gate or the VEX status.
Why audit and review print different numbers#
They count different things, and each says which.
analyze and audit count observed rows — one line per observation the scan recorded, in evidence-pack.json. review counts distinct evidence decisions — one line per decision key a reviewer must rule on, in review-queue.json. Several observations of the same requirement, from different sources, share one decision key, so a single ruling settles all of them. That is why the review queue's number is usually the smaller one.
| Command | What it prints | Unit | Source artifact |
|---|---|---|---|
sbomflow audit | 6 observed rows … await human review, carrying 3 distinct evidence decisions | observed rows | evidence-pack.json |
sbomflow review | awaiting_review=3 | distinct evidence decisions | review-queue.json |
Neither number is a verdict, and neither contradicts the other. evidence gaps (requirements with no evidence at all), requirements missing evidence and artifacts are three further units again — a count is only comparable with another count in the same unit.
The review row states what is missing first#
When a run recorded requirements with no evidence at all, the review row opens with that count:
review 7 requirements missing evidence (cra-coverage.json) · 6 observed
rows (evidence-pack.json) await human review, carrying 3 distinct
evidence decisions (review-queue.json) — the number `sbomflow
review` showsThe row is one wrapped paragraph, and a cropped view — a CI log excerpt, a chat paste, a terminal narrower than the row — keeps only its first line. Stating the absence first means that line cannot report how much evidence is waiting while leaving out the requirements that have none. A run that recorded no such absence keeps the original order, because leading with a zero would put the reassuring number where the qualifier belongs. Order is the only thing that changes: no count, artifact, exit code or decision moves with it.
What only a human can decide#
- Whether a vulnerability is
not_affectedorfixed(a VEX determination). - Whether an evidence item is accepted.
- Whether a release may proceed.
These come only from review files and approvals, never from the engine. A not_affected claim without a valid justification is downgraded.
Important
Reachability, release drift, KEV, and EPSS are **context for a human
determination** — never the decision. reachability.json can show source
references or manual evidence, but it never sets VEX status and never suppresses
a gate by itself.
Your own analysis of a finding can be attached to the run, hashed and carried with the evidence, without crossing that line: see Supplying your own reachability evidence for the file shape. It is recorded as unreviewed context — a decision still comes only from a review file.
AI is assistance, never authority#
AI, where used at all, is optional assistance at the edges. It is never the source of evidence, a VEX decision, or a release decision.
Two reviewers, one decision file#
Reviewer decisions live in flat, key-addressed JSON (reviews.json, vulnerability_reviews.json, waivers.json). SBOMFlow refuses a concurrent write that would silently clobber someone else's decision — so when two people (or a laptop copy and the CI copy) both recorded decisions, combine them with a domain-aware merge rather than a line-based git merge, which can interleave one reviewer's status with another's justification:
# 1. Merge by decision key. Non-overlapping keys are unioned in and identical
# decisions deduplicate.
sbomflow review-merge --ours alice/vulnerability_reviews.json --theirs bob/vulnerability_reviews.json --output merged/vulnerability_reviews.json
# 2. If both reviewers decided the SAME key DIFFERENTLY, nothing is guessed: the
# key is omitted from the merged file, written to merge-conflicts.json, and the
# command exits 1. Read the side-by-side report:
cat merged/merge-conflicts.json
# 3. A human chooses each conflicting key explicitly, then re-runs.
sbomflow review-merge --ours alice/vulnerability_reviews.json --theirs bob/vulnerability_reviews.json --output merged/vulnerability_reviews.json --resolve <finding-id>=theirsPass --base (the file both reviewers started from) for a three-way merge: a side that changed relative to the base wins over a side that did not, including deletions, so only a genuinely divergent decision is a conflict. Any parse failure, or an attempt to merge decisions recorded against different products, aborts the whole merge — there are no partial merges. The merged file records both inputs and their SHA-256 so the merge itself is auditable.
Claiming an item before you decide#
Each evidence item, finding, or gap in the review queue moves through a small lifecycle: open (untouched) → in_review (someone is looking at it) → decided (a decision is recorded — accepted/rejected/needs_more_evidence for evidence, a VEX status for a finding). decided is reached only by recording an actual decision, exactly as before; the state itself carries zero evidence authority.
The open → in_review step is new: claim an item so a second reviewer sees it is already being looked at, instead of silently duplicating the work.
sbomflow review ./evidence --start secure_boot_configured --as security --reviewer sec@example.comClaiming an item that is already claimed is refused unless you pass --force (which reclaims it and records the prior claimant alongside the new one — both claim events stay in the append-only audit log). Claiming an item that already has a recorded decision is also refused: decisions are immutable for the release, so a claim can never move one back to open/in_review — the next release starts fresh. The review queue (and the reviewer console) show the claimed role next to an in-review item.
The reviewer console (sbomflow review-view) opens with a Reviewer workload table: one row per role that has claimed or decided something (plus an "unassigned" row for everything nobody has touched), with open/in-review/decided counts and how long the oldest in-review item has sat claimed. Rows are roles, never reviewer names — this is a workload snapshot, not per-person analytics.
Every review-view render re-reads reviews.json, vulnerability_reviews.json, and review-claims.json from the output directory at render time — it never shows a decision or claim as pending after sbomflow review has already recorded it, even if analyze has not been re-run since.
Bulk triage, one row at a time#
A 200-finding queue needs multi-select convenience, but a blanket "accept all" would destroy the point of per-finding review. review --bulk gives you the speed without the blanket authority: it consumes a pre-reviewed .csv or .json worksheet — one row per finding, each with its own status and justification — and records every row as its own independent decision with its own audit event, exactly as if you had run --triage that many times.
A worksheet is a plain array of rows:
[{"finding_key": "CVE-2024-…", "status": "fixed", "reviewer": "you@example.com"}]Then run sbomflow review ./evidence --bulk worksheet.json --reviewed-at <timestamp>.
finding_key is the vulnerability id — the same value --triage takes. Columns/keys: finding_key, status, justification, reviewer, note, evidence_ref (a per-row reviewer/note overrides the --reviewer/CLI default; justification is required for not_affected exactly as --triage requires it). A row targeting an unknown id, or missing finding_key/status, is listed and skipped — valid rows still apply, and the exit code reflects whether anything failed. There is no "accept all": every row states its own status, and the item-115 justification-quality lint (copy-pasted rationale reused across findings) still fires on the result exactly as it would for individually-recorded decisions.
Why this matters#
A security or audit buyer trusts a tool that is honest about what it knows. Keeping observation and review distinct is both an ethics rule and the reason the evidence is credible.