SBOMFlow Exit Codes
SBOMFlow uses stable exit codes for CI and operator scripts.
| Code | Meaning | Typical cause | |
|---|---|---|---|
| 0 | Success | Command completed, or a release gate was informational/not enforced. | |
| 1 | Enforced release gate failed | A manufacturer-selected --fail-on-* policy blocked the run. | |
| 2 | Usage, input, IO, or config error | Missing target directory, malformed config, bad arguments, unwritable output, a directory that is not a usable custody set for a sbomflow custody command (E031), or an ingest-preserved-artifact destination that already holds a different record — a regular file, or the regular file a symlink there resolves to (E019; nothing is written, and a byte-identical rewrite still exits 0; no other destination changes its exit status). Almost every code here is retry-safe — fix the reported input and re-run — with one exception: E042 (a signature or digest that could not be created or verified) is retry_safe=false, so investigate the key material or the artifact rather than looping on the same bytes. Also sbomflow review / review-view when a file presented as reviewer decisions could not be read: absent is the ordinary case and exits 0, but present and unusable is an input error, because a human decision that quietly stops applying is indistinguishable from one that was never made. review writes review-queue.json (with decision_intake.complete: false and one bounded record per rejected file) before the non-zero exit, so the accounting is never lost to the refusal. | |
| 3 | Init refused / bundle verification failed | sbomflow init found an existing sbomflow.yaml without --force, or sbomflow verify-bundle found a tampered bundle / failed signature. Under verify-bundle --layered (full four-layer content/envelope/semantics/authority verification of a directory or .zip), exit 3 means any dimension failed — a tampered listed member, a wrong DSSE payload type, or a valid-but-unauthorized signer — so a signature that verifies over the manifest can never mask altered contents. Per-dimension detail is in the output, not new exit codes. A verification failure carries E046 on stderr — plain, --layered and --json alike — so a pipeline reading only the code can tell a refused init from a failed bundle; before that, this was the only verification exit with no code, no Fix: line and no docs link. Re-running never changes the verdict (E046 is retry_safe=false): a stale signature on an output you produced clears by re-auditing and re-signing, and a bundle you received does not clear until you obtain a fresh copy from its producer. | |
| 4 | Structural validation failure | sbomflow validate or sbomflow audit found malformed/tampered artifacts, or an output directory holding every required artifact but no run-completion.json whose evidence-pack.json records a producer (tool_version 0.5.0 or later) that always writes it — the run stopped after its required artifacts, or the seal was removed (re-run analyze/audit; a pack from an older producer still exits 0, with a note), sbomflow index validate found an index that fails its schema, integrity or foreign-key check (a missing index database is 2), sbomflow store verify found a stored release missing/tampered, sbomflow passport verify found an attested artifact that no longer matches its manifest or contains a member its manifest does not attest, sbomflow custody verify found a custody set whose record copies, kept originals, audit chain, index or seal no longer agree, or sbomflow case show found a case journal whose hash chain no longer verifies. Re-running clears corruption you can regenerate; it does not clear a hash-chain or tamper finding (E043, E044, E045) — that reproduces until the bytes change, and a case journal has no upstream copy to regenerate from at all. | |
| 5 | Strict warnings-as-errors | analyze --strict (or --strict=code1,code2) matched one or more scan warnings after artifacts were written. | |
| 6 | Diagnostic control receipt not healthy | sbomflow doctor --analysis-control PATH wrote the receipt and this installation's control_health is degraded, failed or indeterminate. The receipt was written — this code says the diagnostic did not come back clean, never that it could not run (an unwritable path is 2). Which of the three states it is, and which named control caused it, lives in the receipt and the human summary rather than in more exit codes. It is an installation-health observation: it says nothing about a product, and no release gate consults it. | |
| 7 | Required verification layer expectation not met | sbomflow verify-bundle --layered --require-layers content,envelope,semantics,authority produced a well-formed layer report in which nothing failed, but a layer the operator required did not reach a trusted status — most often an absent envelope/semantics on an unsigned pack, or an authority layer with no operator trust policy supplied. The full layer report is still printed. This code exists because a report can be honestly produced and still not answer the question a pipeline asked: without --require-layers, NOT TRUSTED on three layers exits 0. A genuine verification failure stays 3 and outranks this code; unknown layer names, and --require-layers without --layered, are refused as input errors (2) before verification runs. | |
| 8 | Release chain ordering incomplete | sbomflow release-chain verify read every record, none was tampered or unreadable, but a record after the first carries no release_chain link, so nothing proves it follows the record before it. The report marks it GAP. Detachment is not corruption and gets its own code rather than being folded into 4: the records are individually valid, and the repairs differ — a tampered link is an investigation, a detached one usually means a release was analyzed without --previous-output. Before this code, a detached last record exited 0, indistinguishable from genesis, while the summary line still read "chain verified: every link matches the preceding record" with the GAP row printed above it. A genuine break stays 4 and outranks this; genesis and a single-record verify still exit 0. | |
| 130 | Interrupted | The operator pressed Ctrl-C (SIGINT). The sbomflow console script reports this conventionally instead of a Python traceback. | |
| 141 | Broken pipe | A downstream reader closed the pipe (e.g. `sbomflow … | head). Conventional 128 + SIGPIPE. Covers both shapes the operating system reports: the POSIX BrokenPipeError (EPIPE) and the plain OSError with EINVAL that Windows raises for a write to a closed pipe handle. A genuine write failure such as ENOSPC` is not folded in here — it stays an error. |
Codes 130/141 are emitted only by the installed sbomflow console entry point (and python -m sbomflow), which wraps the command runner to turn normal operator interruptions into conventional shell codes without a traceback. The programmatic sbomflow.cli.main(...) still raises KeyboardInterrupt / BrokenPipeError for callers and tests.
141 holds regardless of how stdout is buffered. Redirected output (> file, | head, a CI log) is block-buffered, so nothing is written until a flush; the console entry point performs that flush itself rather than leaving it to interpreter shutdown, where the failure would land outside every handler and the process would exit 120 with Exception ignored while flushing sys.stdout. 120 is not an SBOMFlow exit code and appears nowhere in this table.
No subcommand is treated as usage error: sbomflow prints help to stderr and exits 2. Gate failures are distinct from structural validation failures so CI can separate manufacturer policy decisions from broken artifact integrity.
Release gate reason codes#
Exit code 1 says a release gate blocked; the reason codes below say why, in a stable machine-readable form. Every entry in release-gate.json under violations[] and drift_violations[] carries a reason_code alongside its human description, so CI can branch on a code (GATE_KEV_PRESENT) instead of parsing prose. Codes are registered in a versioned registry (sbomflow-gate-reason-codes-v1, sbomflow.gate_reasons) and are additive: they are never renamed once shipped.
| Reason code | Policy flag | Blocked because |
|---|---|---|
GATE_ADVISORY_FINDINGS | fail_on_vulnerabilities | Unresolved advisory findings at/above the severity threshold. |
GATE_DATA_UNAVAILABLE | policy_data_unavailable | An enforced policy could not be evaluated: the data it depends on was never loaded. Not a finding — see below. |
GATE_DENIED_LICENSE | fail_on_denied_license | Component(s) declare a license on the operator's own deny list (engineering context, not legal advice). |
GATE_DRIFT_UNAVAILABLE | release_drift_unavailable | A release-drift policy was enforced with no previous release supplied to compare, or with a previous output that could not be read as a release (release-drift.json summary.comparable: false, every change set empty because nothing was compared). |
GATE_EPSS_THRESHOLD | epss_threshold | Finding(s) at/above the configured FIRST EPSS threshold. |
GATE_EPSS_UNINTERPRETABLE | uninterpretable_epss | Finding(s) carry an EPSS score with no model/score date, so it cannot be compared against the threshold. Not a finding above the threshold — see below. |
GATE_EVIDENCE_GAPS | fail_on_gaps | Missing-evidence gaps at/above the severity threshold. |
GATE_EVIDENCE_REGRESSION | fail_on_evidence_regression | Previously-accepted evidence regressed vs the previous release. |
GATE_KEV_PRESENT | fail_on_kev | Finding(s) in the CISA KEV catalog (known exploited in the wild). |
GATE_MISSING_APPROVALS | fail_on_missing_approvals | Required multi-role release approvals are incomplete. |
GATE_NEW_ADVISORY_FINDINGS | fail_on_new_vulnerabilities | New advisory finding(s) vs the previous release. |
GATE_NEW_CRITICAL_OR_HIGH | fail_on_new_critical_or_high | New or escalated critical/high finding(s) vs the previous release. |
GATE_NEW_KEV | fail_on_new_kev | Newly known-exploited finding(s) vs the previous release. |
GATE_NEW_REACHABLE_FINDINGS | fail_on_new_reachable_vulnerabilities | Finding(s) that became source-referenced (reachable) in this release. |
GATE_REACHABLE_FINDINGS | fail_on_reachable_vulnerabilities | Source-referenced (reachable) advisory finding(s) meeting the threshold. |
GATE_SEVERITY_UNDETERMINED | undetermined_severity | Item(s) have no determinable severity, so they could not be compared against the threshold. Not a finding above the threshold — see below. |
GATE_STALE_VULNERABILITY_DATA | fail_on_stale_vulnerability_data | Consulted vulnerability source(s) are older than the operator's freshness SLA (or of unknown age). Opt-in and off by default — see below. |
GATE_SUPPORT_PERIOD_MISSING | fail_on_support_period_missing | The current release does not declare a support period. |
GATE_SUPPORT_PERIOD_SHORTENED | fail_on_support_period_shortened | The declared support end date moved earlier than the previous release declared. Compared by parsed instant, so the same date written two ways is not a move; a value that cannot be parsed is warned about under invalid_support_period_release_metadata and never reads as moved earlier — and, with the knob enforced, blocks as GATE_DATA_UNAVAILABLE rather than passing, as does a declared end removed in this release. Opt-in, policy-file only, off by default; without a previous release it reports GATE_DRIFT_UNAVAILABLE like every drift policy. |
GATE_UNREVIEWED_EVIDENCE | fail_on_unreviewed | Observed evidence items are not yet human-reviewed. |
GATE_UNATTRIBUTED_EVIDENCE | fail_on_unattributed_evidence | An evidence decision was recorded with no named reviewer. Distinct from GATE_UNREVIEWED_EVIDENCE: a human decision exists and it answers nobody, so it can neither say who accepted the item nor be held to any separation-of-duties rule. The list is reported on every run; opt-in to block. |
GATE_STALE_ACCEPTED_EVIDENCE | fail_on_stale_evidence | Evidence a reviewer accepted has aged past its own recorded validity window. An acceptance that has aged out is not a current acceptance. |
GATE_APPROVAL_BINDING_MISMATCH | fail_on_approval_binding_mismatch | A release approval was granted against a different evidence pack or a different gate policy, or records no binding at all. An approval is a decision about specific evidence under a specific policy; re-approval is a human act, never an automatic re-bind. |
GATE_AI_MODEL_CHANGED | fail_on_ai_model_changed | A model artifact does not hold the same bytes as in the previous release. Opt-in — a model changing is ordinary for a product that ships models; this blocks only where an operator decided it should. |
GATE_AI_DECLARED_FACT_CHANGED | fail_on_ai_declared_fact_changed | A material declared AI fact moved since the previous release — supplier, licence, base model, dataset provenance, pinned digest, external provider or data region, an agent's tools or permissions. Opt-in. |
GATE_AI_EVIDENCE_CONFLICT | fail_on_ai_evidence_conflict | An observed fact and a declared fact disagree about the same AI entity and the disagreement is unresolved. The disposition is a human one; the gate only refuses to ignore it. Opt-in. |
GATE_AI_UNSUPPORTED_FORMAT | fail_on_ai_unsupported_format | A model artifact was recorded and hashed but deliberately never parsed (its only faithful reader would execute code). Opt-in. |
GATE_AI_DRIFT_UNCOMPARABLE | fail_on_ai_drift_uncomparable | The AI evidence of this release was never compared against a previous one — no baseline, an unreadable one, or no scan. The absence of a comparison, not agreement. Opt-in — see below. |
GATE_AI_INVENTORY_PARTIAL | fail_on_ai_inventory_partial | A traversal budget cut the AI walk short, so parts of the release tree were never examined and the inventory is partial. Opt-in — see below. |
GATE_COMPONENT_SUPPORT_GAP | fail_on_component_support_gap | A lifecycle snapshot supplied under component_support.snapshots states an end date for a component's cycle that is before the release's declared support end (its security_updates promise end, else its kind-less support_period_ends_at; never a hardware date) — the support_gap rows of component-support.json. Opt-in, policy-file only, off by default; off, it adds nothing to release-gate.json. With the knob on and no snapshot supplied, or no declared release end, the gate says GATE_DATA_UNAVAILABLE instead — no comparison is not a pass. store verify-gates and gate simulate replay the document the run recorded. |
GATE_PRESERVED_ARTIFACT_EXPECTATION_UNMET | fail_on_preserved_artifact_expectation_unmet | An entry of the evidence plan written under preserved_artifact_expectations: in sbomflow.yaml has fewer rows declared for this run than its minimum (state absent or not_evaluable); under the key's second value unreviewed, also an entry whose rows are there without an applied accepted decision on every one (present_unreviewed, present_not_accepted). A met_and_accepted entry never blocks. The violation's ids name the plan entries. Opt-in, policy-file only, off by default, and the one place a preserved artifact's plan reaches the gate; off, it adds nothing to release-gate.json. With the key on and no plan declared, no evaluated entry, or a refused entry, the gate says GATE_DATA_UNAVAILABLE instead. It compares declared rows with a plan the operator wrote and is not a judgement of the evidence. store verify-gates and gate simulate replay the index the run recorded. |
GATE_UNREVIEWED_CPE_CANDIDATES | fail_on_unreviewed_cpe_candidates | A CPE candidate in cpe-candidates.json has no human disposition recorded through the match-dispute channel, or a human confirmed that it applies. Only disputed clears a candidate: a confirmation never makes the verdict more favourable than no review, and no finding is created from it automatically — a confirmed candidate blocks until the component leaves the affected range. A candidate is never a finding; this blocks only an operator who asked it to. Opt-in and off by default. With the knob on and no NVD snapshot read, the report not enabled, no component declaring a CPE the report can match on, or no NVD record carrying a configuration to evaluate, the gate says GATE_DATA_UNAVAILABLE instead, with the counts — zero candidates from zero examination is not a pass. store verify-gates and gate simulate replay the report the run recorded, so a release gated on it verifies rather than reading as tamper. |
GATE_AI_CONTROL_UNEVALUATED | ai_control_unevaluated | An enforced AI control could not be evaluated, or raised while being evaluated. Its own code, not the control's: "we could not answer" is a different fact from "we answered and found something". |
Reason codes describe engine policy outcomes only. A code means a manufacturer-selected policy blocked a release — never a claim about CRA conformity, and never a substitute for the human review that owns every evidence, VEX, and approval decision.
GATE_DATA_UNAVAILABLE: an enforced policy that could not be evaluated#
Exit code 1 means "do not release". It does not mean "a vulnerability was found" — it means at least one enforced policy said no. GATE_DATA_UNAVAILABLE is the case where the policy could not even be evaluated, because an input it depends on was never loaded.
--fail-on-kev counts findings flagged by the CISA KEV catalogue. If the catalogue was never fetched, nothing is flagged — so the old gate reported a clean pass for a question it never asked. Zero known-exploited findings in a run that never loaded KEV is not a clean bill of health; it is an unasked question. Any enforced policy whose data is absent is now indeterminate: it renders no verdict (policy_outcomes[].outcome is not_evaluated, never pass), emits a policy_data_unavailable violation naming the policy and the missing input, and exits 1. The detail is in release-gate.json under indeterminate_policies[], and sbomflow explain <dir> --gate prints it in full.
There is deliberately no new exit code: 1 already means "an enforced gate said no", CI already treats it as a build break, and inventing a third state would let a pipeline that only checks == 1 silently ship an unevaluated release. Automation that needs the distinction branches on the reason_code. This matches GATE_DRIFT_UNAVAILABLE, which has always refused to let a drift policy pass with no previous release to compare against.
An inventory nobody read is the same unasked question. Every vulnerability policy (fail_on_vulnerabilities, fail_on_kev, epss_threshold, the reachable and new_* variants) also requires inventory: it is unmet (state absent) when the run inventoried no component at all, and (state not_read, the documents named) when a requested SBOM document — --import-sbom, sbom.import, a declared supplier SBOM — was not read because it was missing, unreadable, empty, not parseable, over a resource limit or in a version this build does not interpret. One unread document of two is enough: the inventory is incomplete. Before this, a truncated SBOM from a killed or disk-full generator gave gate PASS (enforced) over 0 components. The policy renders not_evaluated with policy_data_unavailable; policies that do not read components are unchanged, and --strict keeps its own exit status. A gate that applied this rule records data_requirement_rules: ["inventory"] in release-gate.json. A historic release replayed by store verify-gates whose recorded PASS came from an empty or unread inventory, whose record carries no such marker, and whose recorded outcome reproduces exactly under the earlier rule, reads recorded_before_inventory_rule and exits 0: the rule changed, not the record. Re-running the release records an outcome under the current rule. A record that carries the marker, or that the earlier rule does not reproduce either, stays outcome_mismatch (exit 4).
A run that assessed no requirement regime is the same rule, one layer up. requirement_regime: none (config, or --requirement-regime none) means nothing was assessed, so evidence_gaps and requirement_assessments are empty because no question was asked. fail_on_gaps therefore declares a data requirement of its own (requirement_graph, state not_applicable_by_operator_choice), and so do the new_gap: and status_regression: parts of fail_on_evidence_regression. Enforced over such a pack, each renders not_evaluated and emits policy_data_unavailable — never exit 0 with an empty violation list. review_recheck: is a review fact, not a requirement fact, and stays evaluable; so do fail_on_kev and fail_on_support_period_missing, which --strict also forces and which are not CRA concepts.
analyze/audit refuse an explicit --fail-on-gaps (or a policy file enforcing it) up front rather than letting it reach the gate. What --strict and the shipped --policy-profile presets force is dropped instead of refused — an operator who picked a preset for other reasons should not have to re-pick it — and every drop is recorded in release-gate.json under regime_excluded_policies[] with the policy, the parts, the origin (audit --strict, policy-profile:<name>) and the reason requirement_regime_none. A dropped knob you cannot read about is the same defect as a vacuous pass. The key is written only on a none run, so a cra release-gate.json is byte-identical.
fail_on_evidence_regression is dropped and recorded rather than refused, even when the operator gives it explicitly (--fail-on-evidence-regression, or a policy file that enforces it — the origin names which). Only its new_gap: and status_regression: parts are regime-derived: refusing the whole knob would disarm review_recheck:, which is a review fact and stays enforced under none. fail_on_gaps has no such part, which is why it is refused outright.
Missing data is not a network requirement. A pinned offline snapshot satisfies every one of these policies — --kev-file, --epss-file, --nvd-file, --osv-snapshot, or a vulndb store. Only an absent or failed source triggers the block. A stale source does not, by default: the catalogue was genuinely consulted and every finding really was compared against it, so its age is a separate fact — the stale_vulnerability_data warning (which --strict can promote to a failure). Blocking on staleness by default would push operators onto the network, which offline-first forbids.
GATE_STALE_VULNERABILITY_DATA: an opt-in freshness SLA#
GATE_DATA_UNAVAILABLE is "the source never loaded". This is its counterpart for a source that did load and was genuinely consulted, but is older than a freshness service level the operator chose to enforce. It is off by default and set only in the gate policy — fail_on_stale_vulnerability_data: true, with an optional uniform max_snapshot_age_days. With no day count, each source is judged by its documented per-kind window (the same threshold the stale_vulnerability_data warning uses), so the bare knob simply promotes that warning to a gate.
When it is on, a consulted source older than the maximum blocks — and a source whose data date is unknown also blocks, because unknown age can never be shown to be within the SLA (unknown is never fresh, the same rule GATE_DATA_UNAVAILABLE applies to absent data). The reason names each source, its recorded as-of date, its computed age, and the maximum it was judged by, so the block is replayable. Clear it by refreshing the source (a newer pinned offline snapshot is enough — this never forces the network), raising max_snapshot_age_days, or not enforcing the policy. Like every gate reason it is an engineering policy outcome, not a compliance determination, and exit 1 is unchanged.
The knob is enforced on the primary gate path — sbomflow analyze --policy <file> and --policy-profile <preset> (and the read-only gate simulate) all honour it, so a real release run over a stale consulted source blocks with this reason.
Per-policy data requirements — what each --fail-on-* flag needs, and what happens when it is absent — are tabulated in the CI runbook.
GATE_SEVERITY_UNDETERMINED / GATE_EPSS_UNINTERPRETABLE: values the gate could not interpret#
GATE_DATA_UNAVAILABLE is the same honesty rule at source granularity — "we never loaded the catalogue". These two are that rule at finding granularity: the source loaded, and it told us nothing usable about this item.
An unknown severity is not a low severity. It is the absence of one. The severity scale runs none < low < medium < high < critical, and unknown is not on it: none is a determined zero-impact answer, while unknown means no source gave us a rankable band at all (no CVSS vector, no advisory severity). Ranking the two the same — which the gate did until this was fixed — let a finding the engine never scored slide under every threshold, so --fail-on-vulnerabilities --severity-threshold low reported a clean pass over it. It now blocks with GATE_SEVERITY_UNDETERMINED: the gate will not report a finding it never ranked as sitting below your threshold.
The same rule covers the release-drift high band: fail_on_new_critical_or_high is a threshold policy over the drift rows, and a NEW (or severity-changed) finding whose severity cannot be determined used to silently not count toward it. It now blocks with GATE_SEVERITY_UNDETERMINED — never counted as critical/high (that is not known), and never silently below the band. GATE_NEW_CRITICAL_OR_HIGH itself still fires only for findings whose stated band determinably meets critical/high. A recorded human VEX review (not_affected/fixed) clears the drift block on the same terms as every other drift blocker.
An undated EPSS score is not a low EPSS score. FIRST re-fits the EPSS model periodically and the score distribution moves with it, so a bare probability is not interpretable without the score_date it was produced under. A finding carrying a score with no model date blocks an enforced epss_threshold with GATE_EPSS_UNINTERPRETABLE rather than quietly counting as "below the threshold". (The daily FIRST CSV records its own score_date; SBOMFlow stamps it onto every finding it scores, so real enrichment never trips this.)
Neither code is a claim that the item is severe — only that it could not be cleared. Both are visible on every run, enforced or not: release-gate.json always carries undetermined_severity_vulnerability_ids, undetermined_severity_gap_ids, and uninterpretable_epss_vulnerability_ids, so "how many findings have no determinable severity?" is answerable from any output. With no --fail-on-* flag they block nothing — the gate stays informational and exits 0.
They clear the way every other blocking item clears — through a recorded human decision, never through silence:
- supply the missing data (an NVD/OSV snapshot carrying a CVSS vector; an EPSS snapshot that records its
score_date); - record a human VEX review of the finding (
fixed, ornot_affectedwith a valid CISA justification); or - waive the item with a reviewer, a reason, and an expiry — it stays visible in
waiver_statusand in the report.
The two AI honesty controls#
fail_on_ai_model_changed and its siblings can only ever report what an AI comparison found. If the previous release carried no AI evidence record, or one that could not be read, or this run performed no scan, then every one of them is vacuously empty — and an operator enforcing an AI policy over an inventory that was never gathered would get the most reassuring possible green.
fail_on_ai_drift_uncomparable and fail_on_ai_inventory_partial are the answer: they make "nothing was compared" and "the walk was cut short" into blockable facts. They are the AI face of the rule GATE_DATA_UNAVAILABLE states for advisory sources — not looked at is not clean — and, like every AI control, they are off until an operator turns them on.
Every AI control is evaluated independently. One control that cannot be evaluated, or that raises, is recorded as GATE_AI_CONTROL_UNEVALUATED for itself and never stops the others from being evaluated and enforced: a gate that keeps closing while most of its controls have quietly stopped running is the failure this design exists to prevent.
No AI reason code asserts that a change is harmful, that a model is unfit, or that a release is unsafe. Each says a manufacturer-selected policy blocked on something the AI evidence recorded. AI controls are configured in the gate policy file only (fail_on_ai_*: true), like the other recent knobs — there is no CLI flag.
Stable error codes on failure output#
Every user-reachable CLI failure prints a stable `[E0xx] code with a one-line fix and a docs link (see ERRORS.md, or offline: sbomflow help error <code>`). Exit code 2 covers usage/input/operation failures (families E01x usage & input, E02x review workflow, E03x outputs & artifacts, E04x integrations & integrity, E05x store & vulndb); integrity verification failures that must not read as ordinary usage errors — a broken audit-log hash chain (E043), store tamper, gate outcome mismatch — exit 4. A structural test in the offline suite fails if a new failure path ships without a code.
Exit 4 is reserved for a record that does not reproduce, never for a record that has merely been overtaken: store verify-gates reports a release whose recorded outcome replays exactly but which humans have decided more about since as outcome_superseded, and exits 0. A verification signal that fired on the ordinary review workflow would train operators to ignore it. The same holds for a record made before a gate rule existed: recorded_before_inventory_rule exits 0.
Nor is it reserved for data that is merely gone. store verify-gates also reports, beside each verdict, whether the release's vulnerability result can still be recomputed from the advisory bytes it was computed from — a separate vulnerability_replay state of replayable, unreplayable or unknown. An unreplayable release exits 0: its recorded gate outcome reproduced, and a pruned advisory snapshot is a fact about data availability, not a tamper signal. The verdict word verified is unchanged by it; see the store verify-gates section of docs/RELEASE_HISTORY.md.
Troubleshooting first-run failures#
Common mistakes are designed to self-explain with an actionable message:
- Missing/file target (
exit 2) — "target directory not found … Fix: pass an existing product directory." - Malformed or unknown-key config (
exit 2) — the parser error plus "Fix: check syntax/keys withsbomflow validate-config <file>or runsbomflow doctor .." - Unknown
--policy-profile(exit 2) — lists the available shipped presets. - Malformed
--kev-file/--epss-file/--nvd-filesnapshot — never silently ignored: it surfaces avulnerability_enrichment_errorscan warning (and fails under--strict). An--nvd-filethat is not JSON, gzip or zip data as its name says (.json,.json.gz,.zip) takes the same path with a plain-language message naming the file, never a decoder error or anE019exit.
sbomflow doctor . is the first stop: it reports Python compatibility, optional extras, the external CycloneDX CLI, and any discoverable config (including parse errors and unknown-key warnings) — all offline.
--strict is opt-in and runs after all artifacts are written, so the evidence pack is always produced; the distinct exit code 5 lets CI fail on warnings without conflating them with a --fail-on-* gate decision (1). Each scan-warning code is registered in a stable, versioned warning catalog (sbomflow-warning-catalog-v1); --strict=<codes> is validated against it.