Evidence outputs
Every SBOMFlow run writes plain files into a directory you choose. There is no database and no hidden state: what you see in the output directory is the system of record. This page explains each artifact — what it contains, whether it is a default or opt-in output, whether it carries an observation or a human decision, and who should look at it.
Note
Observation vs decision is the most important column. An observation is
what the engine derived from files and hashes. A decision exists only when
a named human recorded one. The two are never merged.
The default artifacts#
Produced by every analyze/audit run, offline, with no flags (the exact count is pinned by a drift-guard test that runs a real analysis and compares it against the published figure, so this page never needs to hard-code it):
| Artifact | What it is | Observation or decision | Who reads it |
|---|---|---|---|
evidence-pack.json | The complete machine-readable record: components with provenance, dependency edges, evidence items, gaps, findings, signals, and observed license evidence. | Observations, plus reviewer status fields that default to unreviewed | Engineers and integrators; everything else derives from this |
artifact-manifest.json | SHA-256, size, and classification for every scanned file, with whether it contributed components or evidence. With scan.file_hash_algorithms: [sha256, sha512] each row the project scan wrote also carries sha512, a second digest of the same bytes from the same read — beside sha256, never instead of it, and absent (not empty) when the run did not opt in. Rows recorded by other readers (imported SBOMs and VEX, test results, reviewer evidence) keep SHA-256 only. | Observation | Anyone verifying that evidence traces to exact bytes |
scan-warnings.json | Every malformed or recognized-but-unsupported input, with a stable code and exact path. Nothing is silently dropped. It also lists approval_binding_mismatch, which is derived from the gate's own verdict and so is reported here and in release-gate.json, never inside evidence-pack.json's scan_warnings — the list the gate's digest covers. | Observation | The engineer running the scan; CI in strict mode |
cyclonedx-sbom.json | CycloneDX 1.6 SBOM with purls, licenses, and the dependency graph. Structurally validated. | Observation | Customers, importers, security tooling |
spdx-sbom.json | SPDX 2.3 SBOM with purl external references and relationships. Every enumerated field — primaryPackagePurpose, relationshipType, referenceCategory, checksum algorithm — is written from the enum in the vendored official schema, so an OS package is OPERATING_SYSTEM (SBOMFlow up to and including v0.4.0 wrote a non-standard OPERATING-SYSTEM; see the changelog if you stored one). | Observation | Customers and license workflows |
cra-coverage.json | Observed evidence mapped to a versioned model of CRA Annex I. Manual-only areas are labelled not_automatically_assessable — never failed, never passed. | Observation | Product-security and compliance owners |
reachability.json | Conservative source-reference triage per finding. not_observed means "our deterministic scanners did not observe a reference" — never "safe". | Observation | Vulnerability triage |
release-gate.json | The gate evaluation: which policies were enforced, what blocked, what was suppressed by reviewed VEX, and the exit code. Informational unless you enforce --fail-on-*. When an enforced policy needs the component inventory it also records data_requirement_rules: ["inventory"], the rule it applied, so a later replay knows which rule the record was made under. Records the full policy body it enforced (policy_identity), so the release can be replayed later without the original policy file; a run under a packaged --policy-profile preset also records the exact preset bytes it ran under (policy_model). Both are replay provenance only. evidence_pack_sha256 is the decision digest of the evidence-pack.json that shipped beside it — the evidence the gate decided over, and the value an approval binds to. Every evidence warning is added to the pack before the gate runs, so the two are the same pack; validate fails a run whose gate names any other. | Observation of your configured policy | Release engineering and CI |
release-record.json | This release's identity, counts, artifact hashes, and SBOM references — the unit that release comparison consumes. A release that states support promises also records release.support_promises ({kind, ends_at} per kind: security updates, software maintenance, hardware availability); one that states none writes no such key. A release that declares release.applies_to_variants records the declared variant ids (sorted) as release.applies_to_variants, and release.batch_id when stated; with --previous-output it also carries variant_applicability_changes (the previous and current sets, added, removed, or the named state of a side that could not be read). Declared, never inferred: product_id is unchanged and none of it is written into evidence-pack.json. A release that declares none writes no such key. A run whose scan root lies below its repository's root also records scan_scope: the subtree it covered (relative to the repository root, never a host path) and any lockfile above the scan root, of an ecosystem the tree declares without locking, that the run did not read (lockfiles_above_scan_root, beside the lockfile_above_scan_root warning); a run at the repository root writes no such key, and it is never written into evidence-pack.json — see monorepos and workspaces. A run given a build-provenance or support-policy file (--build-provenance, --support-policy, or build.provenance / support_policy) keeps its exact bytes under external/build-provenance.input / external/support-policy.input: build_provenance_ref / support_policy_evidence_ref then name that copy as path with its sha256, so store verify checks it like any member, and carry supplied_path (the display form of the file given, never a host path) and custody — in_release, or not_in_release with an empty path and a reason (not_read, unreadable, not_a_regular_file, over_size_cap, changed_since_read, destination_refused, source_not_given, link_outside_scan_root — named inside the scan root while a symlink takes it outside, so no copy is made). SBOMFlow did not produce those bytes and asserts nothing about them; evidence-bundle.zip and store export carry them verbatim (both refuse a member carrying private key material, naming the member and never its content), and no sharing pack, passport, auditor pack or support bundle does. | Observation | Release history workflows |
issues.json / issues.md | Actionable items from gaps and findings, with stable keys, lifecycle states, and suggested owners. Known-exploited findings sort first. | Observation | Whoever fixes things; trackers |
audit-log.jsonl | Append-only, hash-chained run and decision events. Later modification breaks the chain and fails validation. | Record of events, including human decisions when they happen | Auditors and reviewers |
assessment-report.html | A single-file, human-readable report of all of the above. Opens in any browser, no server. | Observation | Everyone — start here |
vuln-source-health.json | Which vulnerability sources ran, their freshness, and whether any produced no usable intelligence. | Observation | Anyone asking "why were no CVEs found?" |
advisory-reconciliation.json | How multiple advisory sources describing the same finding were reconciled: per-source severity candidates, the precedence rule applied (a source is selected, never averaged), and an explicit flag when sources conflict. | Observation | Reviewers asking "which source's severity am I looking at, and did the sources disagree?" |
decision-migration.json | Provenance of review-decision identity migrations: which recorded decisions map onto the current finding identities. An ambiguous or stale legacy decision is reported and left unapplied — migration never broadens a decision. | Observation — bookkeeping, never a review outcome | Reviewers upgrading across releases who need to know which past decisions still bind |
bsi-tr03183-2-presence.json | Which BSI TR-03183-2 SBOM data fields your SBOM carries, per field: present, partial, missing, unknown (SBOMFlow cannot observe it), or invalid (recorded in a different form than the guideline states). | Observation — field presence, never a conformance or adequacy assessment | EU manufacturers asked "does your SBOM carry the BSI fields?" |
observation-coverage.json | What the scan did and did not inspect: every discovered file's single terminal disposition (parsed, recognized-but-unsupported, hashed-only, refused, binary, not-composition-relevant, …), the composition signals seen, and why a zero-component run may be incomplete. Every ratio names its numerator and denominator — there is deliberately no aggregate coverage score. | Observation — never a completeness claim, and never a decision | Anyone asking "did it actually look at my project?" |
unknown-input-candidates.json | Files no parser recognised that may still carry composition evidence: an unparsed manifest, lockfile, build descriptor, on-device package database, or firmware blob. Each row names the rules that fired, the corroborating signals, and a fixed confidence label (never a probability). | Observation — a proposal for a human, never a component and never a finding | Anyone asking "what did it see but not understand?" |
ai-evidence.json (experimental) | The AI model artifacts observed in the release tree, how each was identified (a documented magic number, a structural invariant, or the filename alone), its size and SHA-256, plus the entity graph and the observed-versus-declared reconciliation. Nothing is loaded, deserialised or executed. traversal records how much of the tree the walk actually examined, so an inventory a resource budget cut short is reported as PARTIAL rather than as a complete one. | Observation — an inventory, never a score, a verdict, or a safety or conformity claim | Anyone asking "what intelligence shipped in this release?" |
ai-evidence-map.json (experimental) | Versioned maps from published AI documentation sources onto the evidence this pack contains. Every mapped reference carries the source name, its official identifier and URL, its version or effective date, the exact article/annex/control reference, and the SHA-256 of the pinned extract it was derived from. Each expectation names who supplies it — an SBOMFlow observation, an operator declaration, a test result, or a human review. Authority is explicit: one source is law (the EU AI Act's Annex IV documentation elements), the other four are voluntary guidance. References the map does not map are listed as unmapped, so the denominator stays visible. | A map, never an assessment — no state means met or adequate, no number reads as a level, and a not-applicable row exists only where a human recorded the rationale | Anyone asking "which of my evidence relates to which documentation duty, and what is still on me?" |
preserved-artifacts.json (experimental) | The custody records an operator declared under preserved_artifacts: in sbomflow.yaml (written by sbomflow ingest-preserved-artifact, or held in a custody set declared as - set: DIR, which expands to one declaration per record the set holds and adds a set: {path, set_id} block to each), one row per artifact SHA-256 and ordered by it. A record's artifact.declared_format is a hint named from the filename extension only (declared_by: filename_extension) and is shown verbatim, so a record written before the hint rename still shows its original value. Each row carries the record's stated associations with their origins — including artifact_kind, the closed-vocabulary statement of what the evidence IS (--artifact-kind on ingest-preserved-artifact and custody add), never inferred from a filename, an extension or the bytes, and counted one bucket per row in summary.artifact_kind (not_stated for a row that states none, conflicting when two records disagree; absent from a run in which nobody stated a kind, so an earlier run's bytes are unchanged) — and the five optional test-record statements (test_performer, performer_independence, test_environment_statement, test_configuration_sha256 with test_configuration_file_name, procedure_ref; --performer / --independence / --test-environment / --configuration-file / --procedure-ref on ingest-preserved-artifact and custody add), rendered per row under test_record_context with not_stated where nobody stated one. Two more statements describe the original the record never copies: original_location (--original-location URI: an s3:, gs:, az:, https:, file: or urn: location or an opaque id, never fetched, resolved or opened) and retain_until (--retain-until DATE, checked by the --as-of validator and kept as typed), rendered per row under retention_statement: retain_until when stated, state (stated / not_stated), relative_to_as_of (before / on_or_after / not_evaluated — placed against this run's --as-of and never the clock) and the meaning "a retention date a person stated; SBOMFlow does not compute or check any legal retention period and does not keep the original". A reached date warns preserved_artifact_retention_ended (info). A row whose records come from a joined custody set that records a preservation hold (custody hold) also carries retention_statement.preservation_hold — state (held when any such set is held, else lifted), each set's set_id, placed_by, placed_at, reason and, once lifted, lifted_by, lifted_at and lifted_reason, as the set records them — and the warning states the hold; a row with no such set carries no key. A location that fails the location rules can only be in a record written by hand; it is shown as withheld and warned preserved_artifact_location_withheld, and no rendering repeats the value. The configuration file is hashed and never parsed, the independence set is closed and carries no value meaning certified or accredited, and SBOMFlow verified none of the five — the block says so beside them — a verbatim copy of the record under preserved-artifacts/, and four checks that each state what they prove (the firmware check also lists, under declared_by_file, the GNU build-id and UAPI.8 package metadata each declared image file declares about itself — read, not_elf, unreadable with a reason, or not_supplied; a build-id is not a checksum and nothing there corroborates the capture): the supplied original re-hashed (verified / mismatch / missing / unreadable / not_supplied), a supplied firmware image looked up among the files this run scanned (in_release — which does not show the artifact was captured from it — then among the subjects of the build provenance supplied to the run (named_by_build_provenance — a statement by the build system whose meaning quotes the provenance signature state verbatim, corroborated only when that state is verified; in_release wins for a digest that is both), or not_in_scanned_tree, naming the generated directories the scan prunes; a declared MCUboot image's declaration also shows image_inner_hash — the image hash it states in its own hash TLV and whether the keyless recompute over its signed region agrees, matched / mismatch (warning preserved_artifact_image_hash_mismatch) / no_digest / truncated (a file shorter than its declared layout; no hash read), never a signature verdict), the stated release compared with the run's stated identity (two statements, never a measurement), and capture_firmware — what the CAPTURE RECORD itself states about the image it ran on (--firmware-image / --firmware-sha256 / --firmware-build-id / --firmware-version / --firmware-inner-hash ALG:HEX on ingest-preserved-artifact and custody add), which is a different statement by a different person from the image sbomflow.yaml declares. Its state is stated_image_in_release (the stated digest is byte-identical to a file this run scanned — which still does not show the capture ran on it), stated_image_named_by_build_provenance (not scanned, but a subject of the supplied build provenance; corroborated only under a verified signature state), stated_image_not_in_scanned_tree (naming the pruned directories), stated_without_a_digest (an identity was stated with no SHA-256, so there was nothing to look up — never read as a failed lookup) or not_stated; comparisons[] compares the stated digest with the declared image's, the stated GNU build id with the one that image declares, the stated version with this run's release identity, and a stated MCUboot image hash with the one the declared image states in its hash TLV (never with the file SHA-256, and never with a version string), each agrees / differs / not_comparable with the reason that blocked it (run_declares_no_firmware_image, declared_image_missing, declared_image_unreadable, declared_image_declares_no_gnu_build_id, declared_image_identity_unreadable, run_declares_no_release_identity, declared_image_not_mcuboot, declared_image_declares_no_inner_hash, declared_image_declares_no_inner_hash_of_that_algorithm and the four capture_states_no_* — thirteen reasons, because each sends a reader somewhere different). A differs warns preserved_artifact_capture_firmware_differs: two statements disagree, which is NOT evidence of tampering, suspends no decision and reaches no gate. summary.capture_firmware counts one bucket per row. A row whose records state a scheme record kind (certification_or_approval_record or conformance_test_report) also carries run_checks.scheme_record_scope: stated (the version and sha256 its records state), compared_with (release_version — the run's declared product version, or not_stated — and declared_image_sha256 when the entry declares an image this run hashed), a state — names_this_release_image (every stated digest is a file this run's scan hashed; a digest only the build provenance names never is), names_this_release_version (string-equal, never parsed), names_other_version, names_no_version, or not_evaluable with a reason (run_declares_no_release_version / stated_image_not_in_scanned_tree) — its meaning, and valid_until_statement (valid_until as stated with --valid-until DATE on ingest-preserved-artifact and custody add, state, relative_to_as_of against the run's --as-of only, and a meaning that adds "the stated validity ended" once that date is reached). It compares statements and decides nothing about whether a record still applies; summary.scheme_record_scope counts those rows by state (absent when there are none), START-HERE.md restates the counts, a names_other_version row is a scheme_record_names_other_version item in next-actions, and a state that moved since --previous-output is listed under comparison_with_previous.run_check_changed. When sbomflow.yaml declares an evidence plan (preserved_artifact_expectations:, each entry an id, an artifact_kind, an optional minimum, capture_session and description), the index also carries expectations — one entry per plan entry with the matching_row_keys whose records state that kind and a state: met_and_accepted (every matching row carries an applied accepted decision), present_unreviewed, present_not_accepted, absent (fewer matching rows than the minimum; info warning preserved_artifact_expectation_unmet) or not_evaluable (fewer, and unplaced_row_keys names rows that state no kind) — with expectations_meaning and expectations_refused (each skipped entry and its reason); summary.expectations counts them, START-HERE.md restates the counts, and an absent or not_evaluable entry is a preserved_artifact_expectation_unmet item in next-actions. A plan compared with the rows declared; it says nothing about whether the plan is sufficient, and it reaches the gate only through the default-off policy key fail_on_preserved_artifact_expectation_unmet (true or unreviewed; see Holding a release on the plan) — the one place a preserved artifact's plan can reach the gate, and only when a team opts in. A record that is forged, oversize, a symlink or outside the config file's directory is refused with a named reason; a declared set: that is a symlink, escapes the config directory, carries no .sbomflow-custody-set marker, has no readable custody-set.json, or holds no record at all is refused as ONE row (set_not_a_custody_set / set_index_unreadable / set_holds_no_records) and the run continues — so state: none_declared never reads back over a set the config did declare. state: none_declared means nothing was declared, which says nothing about whether an artifact exists. The original bytes are never kept; the custody records themselves are copied verbatim, so evidence-bundle.zip and store export carry the operator-typed capture_notes and preserved_by as recorded. A record kept inside the run's own preserved-artifacts/ is refused unread and never overwritten or removed; a copy it would collide with takes the <artifact sha256>.<record sha256>.json name instead, so the plain copy path of unchanged bytes can move between releases — compare copies by record_sha256, never by path. | Custody and stated context — never a test result, a coverage claim or a finding, and never part of the evidence pack or the approval digest; the release gate reads only the expectations block, and only under the default-off fail_on_preserved_artifact_expectation_unmet. The review block is unreviewed until a named human's decision applies: status (effective), recorded_status, reviewer, reviewed_at, note, applied, not_applied_reason, decision_source (kind: run_reviews plus the reviews file's base name, or custody_set plus the set: path as declared) and agreeing_sources. A decision is keyed preserved-artifact:sha256:<hex>, requires a readable --reviewer and --note, and is shown but not applied when the original mismatches, when its reviewer or note is not readable text, or when two decision sources disagree (conflicting_decisions). A decision recorded in a custody set is applied only when the set can vouch for it (sbomflow custody verify DIR verifies the set itself (its audit chain, its index against the admission events, its header, its seal and its entry count), the record copy agrees with the set index, and the set's own chain records that exact entry), when the set declares this run's release (a set declaring none reads "decision made outside any declared release"; a row whose own stated release disagrees with the run is refused), and when the original does not mismatch — set integrity is checked first and the release last. custody_set_sources[] (written only when a set was declared) is the snapshot of each joined set at this run — ref, set_id, seal, audit_chain_head, the decisions verbatim and the blocked keys with their reasons — so a reviews.json entry recorded later contests a disagreeing set decision instead of silently replacing it. seal and audit_chain_head are written only when this run verified that set, so an empty value means it did not, and validate refuses an applied set decision whose snapshot carries neither; it also refuses a snapshot whose blocked map no longer holds a reason a row was refused for, which is how an edit made after the run is caught. analyze/audit apply the reviews input they are given (--reviews or reviews:); review, next-actions and the reviewer console read the output directory's reviews.json live, per key, and say when a live entry replaced an analyze-time decision. Decisions are never carried forward from a previous output; an output directory reused for a different release keeps its own reviews.json, whose decisions still apply to byte-identical artifacts, as evidence decisions do. A run given --previous-output additionally SHOWS what that release recorded: each row whose artifact SHA-256 the previous index already held carries prior_decision_context — that release's status (effective), recorded_status, reviewer, reviewed_at, note, applied (whether it applied THERE) and not_applied_reason when it did not, plus the release it came from (previous_release.state read/absent/unreadable, with release_id/release_version when read) and a named state of its own: decision_recorded, conflicting_decisions_recorded (that release read disagreeing decisions and applied none) or no_decision_recorded (it held the same bytes and decided nothing). Rows are matched on the digest ALONE — a changed statement about the bytes must not hide a real prior decision — and where the two releases described them differently, identity.differing_statements names each field with both statements. prior_decision_context_meaning, written beside rows when any row carries the block, states its six limits once: authority (not applied, decides nothing, no automatic carry-forward), scope (exactly ONE previous release, not a history and not the last word), identity, source (the previous index as it was written, so a decision recorded against that release afterwards is not reflected until it is re-run), attribution (the reviewer string, never resolved to a person or checked for independence) and surfaces. A row whose bytes are new carries no key at all and is listed under comparison_with_previous.added; a run that compared nothing carries no key on any row, and validate refuses one that does. undeclared_decisions[] lists decisions for keys no declared row carries — never applied. A row whose bytes a --test-results file of the same run also has (exact SHA-256 equality, nothing else) carries test_result_totals: that file's totals as the parsed lane counted them, its source_file, result_format, suite_name and sha256, a rule and a meaning; the row's own decision is not copied into it, the evidence pack's test-result rows never name the row (they are approval-digest material), and validate recomputes a present block from the pack. A row whose original is a file in a format with a published, citable specification also carries file_declared_identity: what that file declares about itself, read under the cited specification, each value with origin extracted, alongside what was withheld and why and any comparison against the release's declared source revision. SBOMFlow did not verify those values and did not read the file's content — except that a microcontroller load image's reader (Intel HEX, S-record, UF2) walks every record's framing and records no data byte; the key is absent when no reader claims the format, which is most rows. A load-image row also carries elf_load_image_match, its bytes compared with the paired ELF's (same name, .elf/.axf/.out, same directory). Beside it, build_id_match holds a GNU build id the file declares (today perf.data's) against the GNU build ids of the firmware images the run declares: matched naming each image, not_matched with both totals, or not_established with the reason; a declared version or other string is listed as declared and never matched or verified, and the block reaches no gate or approval digest. A load image declares no build id; only when it is identical to its paired ELF is that ELF's id carried across — as a kept original's derived_build_ids, or for a firmware_image declared as a load image, as a match with image_build_id_origin: derived — always labelled derived with its basis, never declared. A row whose original is an ESP-IDF application image read by esp-app-image also carries app_elf_sha256_comparison: the application ELF SHA-256 its descriptor states beside the SHA-256 of each ELF image the run declares, equal, differs or not_comparable with its reason; an equal digest names exactly that ELF file and does not show the image was built from it. When a run keeps both such an image and an ESP-IDF core dump stating an ELF digest prefix, the dump's row carries app_descriptor_prefix_context and each consistent image's row core_dump_prefix_links; When the run declares no firmware image it read as an ELF but declares an ESP-IDF image whose descriptor states an ELF digest, both ESP comparisons use that statement as the release side: each release entry carries digest_source: image_descriptor_statement and the declared image's image_sha256, and the block's rule and meaning say no ELF was hashed. validate recomputes all three. A row whose reader names a declared version field (a DFM alert's firmware version, a Memfault-format dump's software version) also carries declared_version_context: that value beside the product version the run states (product.version or --product-version), agree, differ or not_comparable with its reason, by exact equality after trimming, both values verbatim — context only, never a match, a finding, a warning or a gate input. A row whose original declares source paths (today an LCOV tracefile's SF: records) holds them against observed_source_paths, written beside rows only when such a row exists, so every other run keeps its bytes: the repository-relative paths of the files this run's own scan walked and hashed — read off the run, never a second walk, so a directory or file the walk skipped or refused is counted under walk (entries_reached, directories_pruned, files_not_hashed) and never listed — sorted, capped at 100000, with paths_examined = paths_recorded + paths_truncated + paths_refused_outside_scan_root and paths_sha256 over the listed paths, one newline after each (the same for two runs of one tree and for a copy of it at another absolute path). A path that is not repository-relative, or that resolves outside the scan root, is refused and counted, never recorded. Its state is complete, incomplete (cap_reached, walk_stopped_at_entry_budget) or not_recorded with its reason. The row's source_paths comparison then reads counted — declared_paths_in_observed_set, observed_path_count and declared_path_count: two counts with both totals, declared_paths_not_repository_relative beside them, the declared paths not in the set listed up to 256 with declared_paths_not_observed_listed stating how many of how many, and the set's observed_paths_sha256 — or not_established with its reason when the set is not complete or the read was not part of a release run, never a partial count. It is correspondence only: no percentage, no figure, no bar to meet and no gate, and it says nothing about what was exercised or how well; validate recomputes the set's counts, order and digest and refuses a count against a set the index does not hold. | Anyone asking "which traces, dumps and captures belong to this release, and are they the bytes the record describes?" |
A run that assessed no requirement regime (requirement_regime: none)#
The CRA view is one thing SBOMFlow can do, not the frame every run has to sit in. The task-oriented guide is Use SBOMFlow without CRA assessment; this section is the artifact-by-artifact reference. Set requirement_regime: none in sbomflow.yaml, or pass --requirement-regime none to analyze, audit, init or quickstart, and the run derives no requirement graph, builds no gaps and assesses no requirement.
What changes, and nothing else:
cra-coverage.jsonandcra-guidance-crosswalk.jsonare not written. Every other default artifact above is produced exactly as usual.auditno longer composes the technical documentation pack, the EU declaration draft or the CRA Article 14 drafts — all of them are derived from a requirement assessment that did not happen. It still writes the reviewer bundle.evidence-pack.jsoncarriesevidence_gaps: [],requirement_assessments: [],route_info: {}, and a declaration inrequirement_graph:{"regime": "none", "selection": "operator_declared", "source": "sbomflow.yaml" | "--requirement-regime", "statement": "No requirement regime was applied by operator choice. Empty evidence_gaps and requirement_assessments mean NOT ASSESSED, never 'no gaps'."}. Read that statement literally. An empty gap list here is not a clean result.run-completion.jsonlists each omitted artifact indeclared_not_applicable[]with the reasonrequirement_regime_none. The key is always present — empty on acrarun — because an absent key could only mean "written by an older build".validateexits 0: it subtracts a declared artifact only when the seal and the pack agree. A seal that declares an artifact away over a pack that was assessed, or a declared-absent artifact that is nonetheless present, is reported astorn_overlay.EXPECTED_ARTIFACTSstays 14 for acrarun; anonerun's classification reports its own smaller total and the 14 beside it.standards-viewandemit-user-inforefuse such a directory by name. They are views over a CRA requirement assessment and have nothing to read.--fail-on-evidence-regression(a flag, or a policy file) is dropped to itsreview_recheck:part and the drop is recorded inrelease-gate.json'sregime_excluded_policies[], rather than refused: only thenew_gap:andstatus_regression:parts are requirement-derived.- A gap delta is reported only when both releases carried requirement data.
release-drift.json'ssummary.requirement_regime_comparableisfalsewhenever either side was run undernoneor could not be read — sonone→none, which is every release after your first, is not reported as zero movement — with a named reason (requirement_regime_changed,requirement_regime_none,requirement_regime_not_read) and a note, and both the evidence-gap and requirement-coverage change sets are emptied. These surfaces are routed through one shared predicate and print NOT COMPARED / not assessed instead of a zero: the drift report, the Release Drift table inassessment-report.html, the release notes (Markdown and CycloneDX),START-HERE.md,compare-releaseson stdout, the portfolio rollup (its gap rows, and its gap totals when no release contributed) and the portfolio dashboard, which shows those as its not-recorded glyph and says how many releases carry no requirement-gap delta.audit-log.jsonlrecordsnew_gaps: nullwith the reason beside it. Materiality never scores such a comparison. A release that could not be read is recorded asnot_read, never answered for. evidence-aging.jsonskips with the statenot_applicable_by_operator_choice; its counts arenull, never0(0is this vocabulary's value for "a human accepted it in this very run").
A cra run — the default — is unaffected: byte-identical, apart from the one always-present declared_not_applicable key in run-completion.json.
What every reader shows for such a run
Nothing presents an unassessed run as zero. Wherever a surface would show a gap count, a coverage figure or a gaps empty state, it shows "Not assessed — no requirement regime selected by operator choice" instead, or a distinct not-assessed value beside the integer count:
- the
analyze/audittranscript keeps itsassessing evidencephase and reportsnot assessed, and the run summary readsevidence gaps: not assessedin both the observed and the review rows; assessment-report.htmlstates the absence where the CRA route block would be, shows the statement in place of the requirement and gap tables, readsnot assessedon every requirement-derived tile, and its footer drops the CRA sentence for a regime-neutral one;START-HERE.mdreportsEvidence gaps recorded: Not assessed …, the same in its outstanding-decision line, and lists the CRA and CRA-draft groups with a not-applicable note instead of omitting them;issues.mdsays no issue could be derived, and a known-exploited issue's determination sentence names no framework: it asks for a human determination under whatever regime governs the product, instead of stating a CRA Article 14 candidacy;evidence-bundle.json,sharing-pack.jsonandpassport.jsoncarrycoverage_summary: {"state": "not_applicable_by_operator_choice", …}— on the sharing pack that block replaces the numeric one, never annotates it — plusrequirement_regime: "none"andcounts.evidence_gaps_state: "not_assessed"beside the integer. Their HTML renderings read the manifest, so a pack opened far from its run directory says the same thing;reviewandreviewer.htmlreadunresolved_gaps=not assessed, andreview-queue.jsoncarriessummary.unresolved_gaps_state: "not_assessed";notifyreportsgaps: not assessedin every channel body;store listprintsgaps=not assessed, andstore query --min-gaps Nexcludes such a release with a note naming how many were excluded — including--min-gaps 0, which would otherwise match them as satisfying the threshold;- the SQLite evidence index carries an additive
releases.gaps_statecolumn (assessed|not_assessed,NOT NULL), soCOUNT(gaps)can be told apart from "never assessed"; the index is a rebuildable cache and needs no migration; - the portfolio dashboard shows
not assessedin the release's gap cell, leaves the open-gap total unrecorded when no latest release contributed one, and says how many releases applied no requirement regime; metrics.jsoncarriescounts.evidence_gaps_stateandrequirement_regime;next-actionscarries arequirement_regimeblock (always,craornone) stating that anonerun's list holds no gap action and offers no--fail-on-gapssuggestion, and its terminal rendering prints that statement for anonerun. This block is the one key carried on acrarun too — see the note below;- the
quickstart/setupsummaries and the setup gap walkthrough say not assessed rather than "a clean result", andexplain --gaprefuses by naming the declaration instead of an unknown id; sync-issues(whose dry run is the default;--applyis the opt-in) says why no missing-evidence issue could be planned;sync-issuesandsync-jiradefault their tracker labels tosbomflow,release-evidenceinstead ofsbomflow,cra-evidence, read from the run's own pack rather than the current config; an explicit--labelsstill wins. This changes the labels ingithub-issue-plan.jsonandjira-issue-plan.json, and what--applypushes, for anonerun only.
Every one of these keys is written only for a none run — with exactly one stated exception — which is what keeps a cra run's artifacts byte-identical: the evidence pack and every artifact analyze/audit writes, the reviewer bundle, the sharing pack, the passport, review-queue.json and review stdout, the reviewer console, the notification payload, the release and SQLite indexes, and the portfolio dashboard. Measured file by file against the previous build.
The exception: next-actions --json carries the requirement_regime block on a cra run as well, deliberately — a field that appears only when it has something to say is a field no reader can rely on — so that one document is not byte-identical to a build before this change. next-actions stdout is: the terminal rendering stays silent for cra.
Switching a product to none also refuses the inputs the regime removes, each naming what to take out: a declared product.class other than default, the technical_documentation, annex_i_evidence and due_diligence config keys, --emit-techdoc, --emit-incident-report, the --incident-* options, an explicit --fail-on-gaps, and a policy file that enforces fail_on_gaps. sbomflow init --requirement-regime none writes a config with none of them.
Unknown-input candidates (unknown-input-candidates.json)#
The third state of an input, and the one that used to be invisible.
SBOMFlow already models the two known states: an input it parses, and an input it recognises but cannot parse yet (which it warns about). The state it could not express was a file it recognises as nothing at all — hashed into the manifest, then silently contributing nothing. This artifact is that state, made visible.
A row appears when a file nothing else classified looks like it might carry composition. It is a worklist for a reviewer, and the boundaries are hard:
- Never a component. The Sentinel creates no component, emits no purl, and never guesses an ecosystem from a filename. Nothing here reaches the SBOM. A heuristic that invents inventory is worse than the blindness it replaces, because the invention is invisible.
- A label, never a number. Confidence is one of a fixed vocabulary —
likely_manifest,likely_lockfile,likely_build_descriptor,likely_package_database,likely_firmware_or_binary,structured_unknown,low_signal. There is no calibrated model behind a "0.82", and printing one would dress a filename heuristic up as a measurement. - Two independent signals, or nothing. Rising above
low_signalrequires at least two corroborating signals that observe genuinely different properties of the file; a rule that promotes on a single property is rejected outright. A Sentinel that flags every JSON file is noise, and noise trains reviewers to ignore the warnings that matter. - Content is sampled, never recorded. Classification reads a bounded head+tail sample (64 KiB total, however large the file) taken only from files nothing else classified — a parsed or recognised file costs no extra byte. The artifact carries
sampled_bytes(an integer) and derived signal names. The sampled bytes themselves are never written anywhere. - Bounded and honest. The row list is capped; when the cap bites, the document says
truncated: trueand still reports the true total. Warnings are capped too — a 1,000-candidate tree produces a handful of warnings, not 1,000. - No gate. Informational (exit 0).
--strictcan surface it in CI; nothing else blocks on it. - SBOMFlow's own records are not unknown. A JSON document whose
schema_versionis one of the schema identifiers this build declares for its own documents — a preserved-artifact recordingest-preserved-artifactwrote into the project, for one — is never a row. Only an exact identifier counts; a lookalikesbomflow-…string this build does not declare stays in the worklist (ruleset version 3).
Nothing decompresses, executes, or reaches the network.
Observation coverage (observation-coverage.json)#
The artifact that makes zero components readable.
Every other SBOM tool can hand you an empty inventory that looks exactly like a clean one. SBOMFlow refuses to: a file it hashes but does not understand is counted, and a run that parses nothing while build descriptors, source, binaries, unsupported inputs or parse failures were present says so out loud (possible_missed_composition, with a machine-readable cause).
What it does not do matters just as much:
- No aggregate score. Each ratio keeps its numerator and denominator apart. A single "87% covered" would hide which dimension was weak and would imply a completeness claim SBOMFlow does not make — no static scanner can prove it found every component.
- No false alarms. The warning comes from a decision table, not from
if no components: warn. A genuinely empty tree, a docs-only tree, and a deliberately component-free target whose manifest SBOMFlow did parse all stay silent. A tool that cries wolf gets ignored. - No gate. Coverage is informational (exit 0).
--strictcan surface it in CI; nothing else blocks on it.
The signals block also records what the inputs said should have existed. A lockfile implies an installed dependency tree, a Yocto conf/local.conf implies tmp/deploy/, a Buildroot .config implies output/build/; where the implied artifact was not found beside its input, the run counts it under missing_resolved_build rather than staying silent. That is not a claim that the build is wrong — scanning a clean checkout legitimately produces it. It is the fact that whatever would have resolved there could not have been observed by this run, which otherwise reads exactly like a run that looked and found nothing. Where the implied artifact could not be inspected at all rather than found absent, it is counted separately under unreadable_resolved_build: unreadable is not absent, and neither of them is clean. The expectations are a closed table, so what this can say is enumerable rather than inferred from filenames.
sbomflow validate recomputes every total from the per-file rows, so a coverage report whose numbers do not add up is rejected rather than believed.
License evidence (a section of evidence-pack.json)#
Every run records observed license evidence over the scanned tree: LICENSE / LICENCE / COPYING files (by presence and SHA-256 — the full text is not parsed) and SPDX-License-Identifier: header tags in source files. A tag counts only where the SPDX convention puts it — in a comment — so code that merely mentions the tag name is not reported as declaring a license. Each declared tag expression is checked for SPDX syntax validity against a pinned SPDX License List snapshot; a malformed expression becomes an invalid_license_expression warning and an unrecognised identifier an unknown_license_id note — the raw string is always preserved. The assessment-report.html renders the rollup.
This is observed metadata for a reviewer, never a conclusion: it does not state the licence a component or the product is "really" under, and never infers a component's licence from a nearby LICENSE file. Turning a declared licence into a concluded one is a human-reviewed step.
A qualifier the purl cannot carry. A component row states its build configuration in its purl — ?arch=amd64, ?triplet=x64-linux. Several purl types carry no qualifiers at all: pkg:pypi/… never does, and an OS package whose vendor was not observed falls back to pkg:generic/deb/…. Where the rendered purl omits a qualifier the component states, the CycloneDX writer emits it beside the purl instead, one property per key:
| Property | Says |
|---|---|
sbomflow:purl_qualifier:<key> | One purl qualifier the component states that its rendered purl could not carry — the value verbatim, the key lower-cased. Emitted only on that rendering loss, so a row whose purl already shows the qualifier carries no such property. |
It exists so two rows that differ only in a configuration the purl cannot express are still tellable apart. The purl is deliberately not widened for those types: for pypi, npm, golang, cargo, gem and composer the purl string is the osv.dev query payload, so adding a qualifier to it would change advisory matching. See docs/FINDING_IDENTITY.md.
Declared vs concluded in the SBOMs. A component's declared licence reaches SPDX licenseDeclared (and CycloneDX expression) only when it is a single, syntactically valid SPDX expression; its canonical form is emitted with the source recorded (SPDX licenseComments / a sbomflow:license_provenance property). An invalid or multi-licence declaration whose AND/OR relationship the source never states becomes NOASSERTION with every raw string preserved verbatim — never guessed. SPDX licenseConcluded stays NOASSERTION unless a reviewer supplies a license_conclusions file (config license_conclusions:) mapping a purl to a concluded expression; the engine never concludes a licence.
Both halves of that mapping must be text the reviewer wrote. A purl or a conclusion supplied as a JSON/YAML null, number, boolean, list or mapping is reported as license_conclusion_invalid and applied to nothing — the value is never converted to text, because a conversion would put a determination nobody made into the field SPDX reserves for a human's. The warning names the type, so it reads differently from the one for a field left absent or blank: the repair is to quote an expression, not to supply a missing key.
Per-component parser provenance (observed_by, a field of evidence-pack.json components)#
Each component row records which deterministic parser routine produced it in an additive observed_by field — a stable parser id such as cargo-lock, npm-package-lock, container-deb, yocto-manifest, or imported-cyclonedx for a supplier SBOM import. It exists for replay and debugging: "which code path observed this row" is a recorded fact, not a reconstruction. A row merged from several sources keeps the id of the parser that produced its primary source_file; every contributing file remains listed in source_files.
Boundaries: observed_by never affects component identity, merging, advisory matching, origin/claim labels, or any gate. The engine version is recorded once per pack (tool_version), never per component. An empty value means the producing parser was not recorded — hash-identified binary inventory rows stay empty, and packs written by older SBOMFlow versions load with the field absent. Absent provenance is reported as absent; it is never backfilled or guessed.
Supplier-declared external components (supplier_external_declarations, a field of evidence-pack.json components)#
CycloneDX 1.7 lets a supplier state that a component is external — expected to be provided by the environment, not part of the assembly — with isExternal, and give the accepted range with versionRange (vers syntax) instead of a version. When an imported document states either key, the component row carries one entry per stating document:
| Key | Meaning |
|---|---|
source | the imported document that made the statement |
supplier_declared_external | true or false exactly as stated; absent when not stated |
supplier_version_range | the range verbatim, bounded at the 1.7 schema's 4096 characters (supplier_version_range_truncated: true when cut) |
version_range_state | stated_for_external when the same document states isExternal: true; stated_without_external otherwise — the 1.7 schema allows a range only on an external component, so the range is recorded, never corrected and never refused |
unreadable | the keys present with a value of the wrong JSON type; nothing is coerced |
note | the fixed sentence that this is the supplier's statement |
It is the supplier's statement and nothing more. SBOMFlow does not decide what ships: the component stays in the inventory and is still counted among the supplier-imported components; the range is never parsed, never copied into version and never used to match advisories, so a finding on such a component is still a finding. The import quality row counts declared_external_components (components whose supplier states isExternal: true) against components on the same row. The emitted CycloneDX SBOM (and the VDR, which embeds its component rows) carries sbomflow:supplier_declared_external, sbomflow:supplier_version_range and sbomflow:supplier_version_range_state; the SPDX 2.3 package carries the statement as a fixed-prefix comment line. A row with nothing stated carries no key, so every other run is byte-identical.
Supplier-stated supplier and digests (supplier_statements and declared_hashes, fields of evidence-pack.json components)#
When an imported SBOM states a component's supplier (CycloneDX supplier.name, SPDX 2.x supplier, SPDX 3.0.1 suppliedBy) or its digests (CycloneDX hashes, SPDX 2.x checksums, SPDX 3.0.1 verifiedUsing), the component row carries them, one row per statement, each naming the document that made it:
| Key | Meaning |
|---|---|
supplier_statements[].supplier | the supplier name as stated. For SPDX 2.x the Organization: / Person: prefix and an optional (email) are grammar, and the whole declared text is kept in declared. NOASSERTION and NONE are not statements. Bounded at 1024 characters (supplier_truncated: true when cut) |
supplier_statements[].kind | organization, person, or agent (an SPDX 2.x value in neither form, kept whole; an SPDX 3 SoftwareAgent/Agent) |
declared_hashes[].algorithm / value | the algorithm in CycloneDX spelling where CycloneDX has one, and the lower-case hex value |
declared_hashes[].stated_by | supplier_sbom |
source | the imported document that made the statement |
A lockfile's stated digest takes the same path with no new field: the SRI integrity npm, yarn, pnpm and bun record for the registry tarball (decoded to hex) and a stated Cargo, NuGet or other checksum of a known algorithm and length are read from the provenance the lockfile reader already records, and reach the same SBOM fields labelled "stated by a lockfile (<file>)". A component whose sources state more than one value for an algorithm — a Python lockfile lists a digest per platform wheel — carries none of them in the standard field, because none is the component's hash.
These are the document's statements, carried verbatim: nothing is inferred from a purl namespace, a registry or a URL, and SBOMFlow read none of the bytes a digest describes, so a carried digest is never content-hash identity and never "verified". An algorithm the format does not define, or a value that is not hex of the length its algorithm produces, is named in a malformed_external_sbom_component warning and not carried. The emitted SBOMs carry them in their own fields — CycloneDX supplier and hashes, SPDX 2.3 supplier and checksums, SPDX 3.0.1 suppliedBy (an Organization/Person element) and verifiedUsing — with who stated each in sbomflow:supplier_stated:<n> / sbomflow:declared_hash:<n> (CycloneDX), the package comment (SPDX 2.3) and each Hash's comment (SPDX 3.0.1). A standard field carries a value only when the statements agree: two documents naming different suppliers, or stating different digests for one algorithm, leave the field empty and every statement in the properties. A declared-withheld hash element (product.sbom_withheld_fields) omits the digests as it omits every hash. The NTIA supplier_name element and the BSI TR-03183-2 hash field count these statements, and their notes say who stated them. A row with nothing stated carries neither key, so every other run is byte-identical.
Nested components and a stated scope (CycloneDX import)#
A CycloneDX component nested in another (components[].components) is part of the delivered assembly and is imported as a component, with a dependency edge from the component it sits in — CycloneDX says nesting "is not a dependency tree", and the edge is the containment BSI TR-03183-2 counts as the dependency element. A stated scope is carried as the component's kind: required as runtime, optional as optional, excluded as dev, and the emitted CycloneDX SBOM writes the same scope back. An excluded component stays in the inventory and is still matched; the release gate exempts its findings only under the explicit exclude_dev_dependency_findings policy, as for a lockfile dev dependency. An unknown scope value is named in a warning and not guessed.
A lifecycle scope on an SPDX 3 relationship (SPDX 3.0.1 import)#
SPDX 3.0.1's LifecycleScopedRelationship is a Relationship with a scope (design, development, build, test, runtime, other). OE-core's create-spdx-3.0 class, which Yocto 6.0 uses, writes every binary package's runtime dependencies as one runtime-scoped dependsOn. An imported one is read as the relationship it is — a dependsOn is a dependency edge whatever its scope — and its scope is carried as the kind the same fact takes elsewhere: runtime gives each dependency the kind runtime (as CycloneDX scope: required does); development and test make a package named only that way not shipped (dependency_kind: dev), the SPDX 2.3 DEV_DEPENDENCY_OF and TEST_DEPENDENCY_OF rule; build, design and other state no kind, because a build-time dependency (a static library) can ship — the BUILD_DEPENDENCY_OF rule. runtime anywhere outranks a development or test scope, and any unscoped mention makes the package shipped. A scope outside the vocabulary is named in spdx3_document_not_fully_understood and the edge is still read. A dependsOn whose from is a build or another non-package element is named there too and is not turned into a dependency of the product: the image build's build-scoped dependency on its host tools is not the product's.
Non-purl identifiers (external_identifiers, a field of evidence-pack.json components)#
Each component row carries an additive external_identifiers list holding the CPE 2.3, CPE 2.2, SWID tag id, SWHID and OmniBOR/gitoid values a document stated or an operator declared. Each entry has a type, a canonical value, a source and, when the document's spelling differed from the canonical one, the declared_value it used.
This release produces three source values: imported for a supplier SBOM's own statement, operator_map for identity.cpe_map in your config, and declared_by_build for a build system's own declaration. The one producer of declared_by_build is the default scan's reader for the Yocto create-spdx-3.0 image SBOM: the CPEs a Yocto 5.1+ build writes for the recipes in its own image. The build stated them; SBOMFlow derived none of them.
source is the load-bearing field. "The supplier's SBOM states this CPE" and "our operator mapped this purl to this CPE" are different claims with different owners, and a reviewer deciding whether to trust an NVD cross-check needs to know which one they are reading. Recorded identifiers are re-emitted into the fields each format defines for them: CycloneDX cpe, swid, omniborId and swhid; SPDX 2.3 externalRefs (cpe23Type, cpe22Type, swid, swh, gitoid); SPDX 3.0.1 externalIdentifier. Where a format has room for one value and several are recorded, the rest still travel in the SBOMFlow property or comment namespace, so nothing is dropped.
Merged across sources. When a lockfile row and an imported SBOM describe the same purl, the identifiers from both are kept — deduplicated on (type, value, source) and sorted by it, so the result does not depend on which source was read first. Two sources stating the same CPE collapse to one record; a supplier's CPE and an operator-mapped one stay two.
A package with no purl keeps its identifiers. An SPDX 2.x or 3.0.1 package that declares no purl — the CPE-only shape Buildroot's generator, OE-core and some BSP vendors emit — imports with generic identity and its CPE, SWID, SWHID and gitoid identifiers, exactly as a CycloneDX component with no purl does, with the same external_sbom_component_missing_purl note at info. Two kinds of SPDX package stay out, each because the document says so: the document's subject — the one package it describes, which a CycloneDX document keeps in metadata.component — and a package the document names only as a build, development or test tool or dependency (SPDX 2.3 BUILD_TOOL_OF, DEV_TOOL_OF, TEST_TOOL_OF, TEST_OF, TEST_CASE_OF, DEV_DEPENDENCY_OF, TEST_DEPENDENCY_OF; SPDX 3.0.1 usesTool, hasTest, hasTestCase). Without a purl such a tool is not imported, and the warning names the relationship and the identifiers that went with it; with a purl it imports as before, carried as not shipped (dependency_kind: dev, CycloneDX scope: excluded). BUILD_DEPENDENCY_OF is not a tool role: SPDX 2.3's own example is Maven compile scope, which ships. An SPDX CONTAINS (and CONTAINED_BY, and SPDX 3 contains) between two imported packages, or from the subject, is a dependency edge — BSI TR-03183-2 counts the components a component contains as its dependencies — and a containment of a file is not.
Boundaries. This is identity, not an advisory route: SBOMFlow never creates a finding from a CPE, match_scope is unchanged by anything recorded here, and no gate reads it. The opt-in scan.cpe_candidates report reads it to list candidates for a human in cpe-candidates.json (see below), never findings. Nothing is derived — a CPE is recorded only when a document states it or an operator declares it, never built from a component name, because a guessed vendor is a false-positive source. Three input states stay apart: a well-formed canonical value is recorded; a well-formed non-canonical one is recorded canonicalised with the declared spelling kept beside it and imported_identifier_not_canonical raised; a malformed one records no identity and raises imported_cpe_malformed, because a string that is not a CPE sitting in a CPE field will be believed by the next reader. An empty list means none was stated — not that none exists.
NTIA 2021 minimum-elements presence (a section of evidence-pack.json)#
Every run records an ntia_minimum_elements block. For each of the seven fields in NTIA's minimum elements list — component name, version, a unique identifier (package URL), supplier, dependency relationships, the SBOM author, and a timestamp — it reports whether the field is present, partial (some components carry it), or absent, with counts and missing-component examples.
The 2021 list is the baseline this block measures, not the current authority. CISA's 2026 Minimum Elements for a Software Bill of Materials (SBOM) supersedes it (see the next section), and the block records that lineage in its own reference line so a pack read years from now still says which list its counts were taken against. The 2021 report is kept rather than replaced because packs emitted against it stay readable, and because a great deal of downstream tooling is still built around those seven fields.
This is honest field presence, never a conformance assessment: the block carries no "compliant" / "conforms" / "certified" language, and a field SBOMFlow does not record — a per-component supplier organisation, since SBOMFlow records source/build provenance rather than who produced each component — is reported absent rather than inferred. It answers "which NTIA fields are populated?" in one command; a human judges sufficiency.
CISA 2026 SBOM minimum elements (a validate presence check)#
sbomflow validate runs a presence check for the five data fields CISA's 2026 Minimum Elements for a Software Bill of Materials (SBOM) adds to the NTIA 2021 baseline — SBOM Tool Name, SBOM Generation Context, Component License, Component Hash Value, and Component Hash Algorithm — over SBOMFlow's own emitted CycloneDX 1.6 and SPDX 2.3 documents. For each element it prints whether the SBOM carries it, and names any absent one with the cataloged sbom_minimum_element_missing warning.
This is a presence check — never a conformance certification. CISA, with NSA, FBI and international partners, published *2026 Minimum Elements for a Software Bill of Materials (SBOM) on 2026-07-29, which "updates and replaces"* the NTIA 2021 minimum elements (status checked at the source 2026-08-27 and recorded, with that date, in the guidance-status register SBOMFlow ships). It is **voluntary guidance, not a rule**. SBOMFlow observes which fields its output carries; it never claims the SBOM "conforms to" the guidance, and it never fabricates a value it did not observe. The elements below are named with the 2026 document's own vocabulary, read out of the document after it was retrieved and its digest recorded in the guidance-status register. They are a **strict subset — 5 of the guidance's 17 data fields** — and partial coverage is stated as partial, never as conformity.
Where each element lives, and the honest scope of each check:
| Element | CycloneDX 1.6 | SPDX 2.3 | Scope |
|---|---|---|---|
| SBOM Tool Name | metadata.tools name | creationInfo.creators Tool: | always emitted |
| SBOM Generation Context | metadata.lifecycles (discovery) | creationInfo.comment | records only that SBOMFlow generated the SBOM by automated discovery/analysis — never a claim about your build stage |
| Component License | component licenses, or the sbomflow:license_declared_status: none statement | package licenseDeclared | per-component in both formats: the declared licence where a source declares one; for the rest, SPDX's own NOASSERTION, and in CycloneDX — which has no no-assertion value, so a NOASSERTION entry would be a licence of that name — no licenses and the explicit none statement. A disclosed unknown, never an invented licence |
| Component Hash Value | component hashes | package checksums | required of components SBOMFlow identifies by content (firmware blobs, binaries); a component resolved to a Package URL from a lockfile carries that purl, and SBOMFlow never invents a hash for bytes it did not fetch |
| Component Hash Algorithm | component hashes[].alg | package checksums[].algorithm | a separate element in the guidance, checked over the digests that exist: a digest recorded under no named algorithm is a hex string nobody can reproduce. A document carrying no digest at all has not been asked this question, and is reported NOT ESTABLISHED rather than passing it |
| Withheld declaration (an SBOMFlow row, not one of the guidance's data fields) | metadata.properties: one sbomflow:withheld_element per element, then sbomflow:redaction_contact | creationInfo.comment lines Withheld element (declared by the producer): … and Redaction contact (declared by the producer): … | written only when product.sbom_withheld_fields names elements (with the product.sbom_redaction_contact it requires): component_license, component_hash_value, and component_hash_algorithm beside the hash value — the two generation elements are SBOMFlow's own statements and are refused. The producer's declaration, never an inference: each element above then states declared_withheld beside its claim state, and an absent element nobody declared stays an unknown. A declared element's values are omitted from the emitted SBOMs (below) |
| SBOM access pointer (an SBOMFlow row, not one of the guidance's data fields) | metadata.component.externalReferences[], type bom | creationInfo.comment line SBOM access (stated by the producer, not fetched by SBOMFlow): … (SPDX 3: an externalRef of type other on the SpdxDocument) | written only when product.sbom_access: {uri, note} is set: where the SBOM for this version can be obtained, as the producer stated it. Recorded text — SBOMFlow never fetches, resolves or checks it — and a credential in it (a presigned link's signature, a userinfo token) is withheld as ***. START-HERE.md restates it on the SBOM line |
Because this observes presence rather than judging conformance, a missing element is surfaced as a validate note (never an error, and never a gate). See the warning reference for sbom_minimum_element_missing.
The two SBOMFlow rows state presence too, never conformance. A withheld declaration adds one sentence to that element's note — the producer declared this element withheld (redaction contact: …); the declaration says nothing about its value — and one summary line per document; the access pointer adds one line that says SBOMFlow did not fetch it.
A declaration omits the element's values from the documents you hand to a recipient, and from nothing else. Declare component_license withheld and cyclonedx-sbom.json carries no component licenses and none of the SBOMFlow licence properties, and spdx-sbom.json carries no component licenseDeclared, licenseConcluded or licenseComments (the described product package keeps its NOASSERTION; SPDX 3 has no licence field to omit). Declare component_hash_value (with or without component_hash_algorithm; the algorithm alone is refused, because each hash entry carries both) and the documents carry no component hashes, lockfile checksum/integrity properties, checksums or verifiedUsing. The CycloneDX VDR (--emit-vdr) embeds the SBOM's component rows and goes to the same recipients, so its components omit the same values and its metadata.properties carry the same withheld markers. The sharing pack's shared SBOMs are cut from the emitted documents and omit them too, and each carries the marker for every declared element — sbomflow:withheld_element in metadata.properties, the Withheld element (declared by the producer): … line in the SPDX comments — so a recipient of a shared SBOM can tell withheld from unknown. The redaction contact does not travel into a sharing pack: it is contact data, which the redaction contract redacts from every pack, so the recipient asks whoever handed them the pack. The two AI-BOMs (cyclonedx-mlbom.json, spdx3-aibom.json) omit the same elements from every AI model, dataset and other AI entity — for component_license the ML-BOM licenses and every licence extension property (the declared licence, its openness class, its restrictions and any licence conflict); for the hash elements the ML-BOM hashes, the SPDX verifiedUsing and every hash or digest extension property (a declared expectedHash, an unverified digest, a chat-template digest, a governing-prompt hash) — and carry the markers and the redaction contact the software SBOM carries (ML-BOM metadata.properties, after the run-level properties; SPDX AI-BOM comment lines). An AI entity's bom-ref, element id and the document serial and namespace are identity and are kept, and so is the sha256:<first 16 hex> NAME an entity receives when nothing else names it; the SPDX AI-BOM's import digest is SBOMFlow's integrity method for its own software document, not a component hash. ai-evidence.json is the AI record of the observation, as the evidence pack is the record of the scan, and keeps every value. The VEX (cyclonedx-vex.json, csaf-vex.json) embeds no component row, so a declaration does not change it. The declaration stays in each SBOM and in the VDR, and validate reads each declared element as declared withheld and not present; no finding — no warning code, because the absence is the producer's statement, not a gap. The same absence without a declaration is still a finding. What is not omitted: the evidence pack, the approval digest and every internal artifact keep what the scan observed (they are the record of the observation; the SBOM is the document you publish), and the content identity a component is referenced by — its purl, or the binary:<sha256> reference of a content-identified file — is identity, not an emitted hash, so the serial number and the SPDX namespaces do not move. The reviewer bundle (evidence-bundle.zip) carries the evidence pack itself, so it carries the observed values; the sharing pack, the auditor pack and the passport do not. Neither row adds a warning code, and a document that states neither prints exactly the notes it printed before. SBOMFlow states which fields a document carries and what its producer declared about them; it never says that an SBOM meets any guidance.
Every per-population element states the population it was checked against, and refuses to make its claim over an empty one. The three per-population elements are universal claims — "every component carries a licence" — and a universal claim over an empty set is vacuously true, so an SBOM inventorying zero components would otherwise report them present, in wording that reads as an achievement. Instead:
- a satisfied element names its denominator, e.g.
Component License [22 of 22 components], so you can always see how many members the claim covers; - an empty population is reported NOT ESTABLISHED on its own line with its own code,
sbom_minimum_element_not_established— nothing was examined, which is neither a finding that the element is present nor a finding that it is missing. This includes the ordinary case where a document identifies every component by Package URL: no component is then required to carry a hash, so no hash claim is made either way — and, with no digest recorded, the Component Hash Algorithm question is never asked either.
The three populations are deliberately different — the whole inventory, its content-identified members, and the recorded digests — so an element is never answered out of another element's denominator.
Zero examined is never reported as zero problems.
Supplier intake (a section of evidence-pack.json)#
Every run records a supplier_intake block. When the scanned tree carries the conventional suppliers/<name>/ directories (each with a declared intake.json manifest), the block records every supplier (contact-free id, received date, scope note, declared files with SHA-256s), which merged components and supplier-VEX statements trace back to which supplier (component_attribution / vex_attribution), and any version_conflicts where two suppliers claim one component at different versions — both claims recorded, a warning raised, and a human decides. A manifest whose supplier or scope is not text — a JSON null, a number, a boolean, a list or an object — is reported and the supplier is skipped, rather than read as though somebody had typed the value. Without a suppliers/ directory the block is empty: discovery ran and zero suppliers were intaken.
Attribution is factual origin, never judgment: supplier-declared components remain first-class for advisory matching and gating, and supplier VEX remains context that never suppresses a gate.
A supplier may also declare an AI-BOM (ai_boms in their manifest): a CycloneDX ML-BOM or an SPDX 3.0.1 AI-profile document. Each is read, digested and reported in ai-evidence.json → supplier_ai_boms on every run, and folds nothing into this release's AI evidence until a human records an acceptance for its exact bytes through the ordinary reviews channel. A short fixed list of fields this build has no home for (model-card metrics and considerations, external references, energy figures, a supplier's safety assessment and decision thresholds) is listed as loss when present; most other fields — licences, purls, dataset descriptions, and the AI-profile properties such as ai_domain and ai_typeOfModel — are dropped without being listed, as the BOM family census measures. An accepted entity is DECLARED by that supplier — never an observation of this release. supplier_ai_boms carries each document's acceptance_key, so the artifact itself says what to record to accept it.
Due-diligence records (a section of evidence-pack.json)#
Every run records a due_diligence block. When the due_diligence config key points at a reviewer-owned records file, the block carries the operator's Article 13(6) third-party due-diligence records (which checks were performed per component, when, by which role, with what outcome), a per-supplier rollup, and counts; each latest record also becomes a hashed evidence item attached to its named components. Without a records file the block is empty. The block carries a fixed banner — recording is not adequacy: SBOMFlow hashes and surfaces the records for review and never judges the diligence sufficient.
Component ownership labels (a section of evidence-pack.json)#
Every run records an owner_labels block. When the owners config key points at a local rules file ({"rules": [{"match": "pkg:pypi/*", "owner": "platform-python"}]}), each rule's glob is tried against a component's purl, then its name, then its recorded source file — first matching rule in file order wins — and the block maps each matched component to its team label. The label rides the review queue, issues.json, and tracker sync plans as owner_label so findings route to the right team (Jira and GitHub plans also gain a per-issue owner-<team> label for tracker-side filters). It is strictly separate from the engine's built-in suggested_owner functional-area hint, and it is presentation and routing metadata only: never an assignee, never a decision, never a gate input. An unmatched component simply has no label (no "unknown" noise), a later rule an earlier rule shadows draws a warning rather than a silent skip, and a label that looks like a person instead of a team draws a privacy warning. A rule whose match or owner is not text — a JSON null, a number, a boolean, a list or an object — is reported and skipped rather than converted into a rule nobody wrote; it never reaches the first-match-wins ordering, so it cannot shadow a real rule. Without an owners file the block is empty.
Imported-SBOM quality (a section of evidence-pack.json)#
When you import a supplier's SBOM with --import-sbom / --import-sboms, every run records an imported_sbom_quality list — one entry per imported document — with the same honest field-presence idea as the NTIA check above, but pointed at what your supplier handed you instead of SBOMFlow's own output:
| Field | Meaning |
|---|---|
source | the imported file's resolved path |
components | components SBOMFlow actually imported from this document (entries lacking both a name and a valid purl were never imported and are not counted) |
with_version | of those, how many declare a version |
with_purl | how many declare a resolvable package URL |
with_license | how many declare a license |
with_supplier | how many declare a supplier/organization in the document itself (CycloneDX supplier, SPDX 2.x supplier, SPDX 3.0.1 suppliedBy) |
unknown_purl_types | purl types present but outside SBOMFlow's own ecosystem support (e.g. swift, cocoapods) — still imported, just without ecosystem-specific handling such as version-range matching |
declared_crypto_components | how many declare a CycloneDX cryptoProperties object (see the next section). A count of DECLARATIONS in this document, against the components count on the same row — never a count of algorithms in your product, which this document is not evidence of |
declared_external_components | how many the supplier states are CycloneDX 1.7 isExternal: true (see Supplier-declared external components); written only when non-zero, and those components are still counted in components |
supplier_compositions | each CycloneDX compositions entry the document states: aggregate verbatim, and each assemblies / dependencies bom-ref with the imported component's purl where it resolves (resolves: false where it does not). Bounded (64 entries, 256 refs per list; the overflow is counted). It is the supplier's statement about its own document — a supplier's complete is recorded, never adopted — and it changes no component, match or gate. Written only when the document states one |
supplier_citations | a CycloneDX 1.7 document's own citations — who it says asserted which field. Each is recorded verbatim and bounded (256 citations, 64 pointers or expressions each; the overflow is counted): every JSON Pointer with state: resolves (and the imported component_purl when it lands in a component) or does_not_resolve; every path expression expression_not_evaluated (SBOMFlow has no JSONPath engine); attributed_to / process with the named object's name where the document gives one; timestamp and the supplier's supplier_note. A resolved pointer is a fact about the pointer, not a check of the cited value; nothing is re-derived from a citation and it changes no claim origin, match or gate. In an older document citations is not read and stays named as not interpreted |
merged_duplicate_entries / merged_occurrences | a CycloneDX document that lists the same package URL in more than one entry (an npm tree records one entry per node_modules path): SBOMFlow imports one component per purl, keeps every dependency edge, and records here how many entries were merged into another entry's identity and, per purl, how many entries carried it with their bom-ref values (20 per purl and 1,000 purls listed; the rest counted). The merge made visible, not a statement that the entries are one installed copy. Written only when a purl repeats |
This is counts only, never a grade: no percentage-as-score, no letter grade, no pass/fail verdict — "your supplier's SBOM lacks versions on 6 of 22 components" is a fact you can act on, not a rating. Counts are computed from each document before any merge with your local scan or another imported document, so a component your own build also observed can never inflate what the supplier's document actually declared. For an SPDX 3.0.1 document with_supplier counts packages whose suppliedBy names an Agent the document defines with a name; a reference to an Agent it does not define states no name.
Supplier-declared cryptographic properties (a section of evidence-pack.json)#
A CycloneDX document may attach a cryptoProperties object to a component: the asset type, an object identifier, and sub-objects describing a declared algorithm, certificate, related material or protocol. SBOMFlow used to read none of it — the import named cryptoProperties as a field that had not travelled and discarded every value inside it. Now every value is kept, in a supplier_declared_crypto list with one entry per component that declared one:
| Field | Meaning |
|---|---|
source, source_sha256 | the imported document's resolved path and digest, so the assertion is always traceable to the bytes your supplier sent |
bom_ref, component_name, component_version, component_purl, component_type | which component the declaration was attached to. A component with no purl still carries its declaration |
asset_type, oid | the two declared scalars most people look for first, copied from the recorded values below so they cannot disagree with them |
values | every scalar the declared object held, each as field (the dotted path the document spelled, e.g. algorithmProperties.nistQuantumSecurityLevel), value, truncated and declared_chars |
declared_values, recorded_values, truncated_values | what was found against what was examined. They differ only when a bound was reached, and reaching a bound always writes a resource_limit_exceeded warning too |
origin, boundary_note | supplier_declared, plus the sentence below in full, so a row that travels out of the file still says what it is |
This is the supplier's assertion and nothing else. SBOMFlow opened no binary, identified no algorithm, derived no security level, compared no date or key length against any published table, and emits no cryptoProperties of its own. There is no readiness figure and no quantum-safe verdict, because the set examined is one supplier's document and not your firmware. The values are recorded so a person can cross-check them: they never enter component identity, never reach the vulnerability matcher, never open or close a gate, and are never a review status.
The paths in values are derived by walking whatever the document declared, so a field a later CycloneDX revision introduces is preserved by the same code rather than dropped for being unrecognised. A value longer than 512 characters is recorded as its leading prefix with truncated set and declared_chars holding the length before the cut — a shortened value can never be mistaken for a complete one.
BSI TR-03183-2 field presence (bsi-tr03183-2-presence.json)#
The EU-facing counterpart. Every run also writes bsi-tr03183-2-presence.json, reporting each data field of BSI TR-03183-2 (Cyber Resilience Requirements for Manufacturers and Products, Part 2: Software Bill of Materials, v2.1.0) as one of five states:
| Status | Meaning |
|---|---|
present | every component (or the document) carries the field |
partial | some components carry it; the report names missing examples |
missing | none carry it, although SBOMFlow's model can record it |
unknown | SBOMFlow cannot observe this field from build artifacts at all, so its absence says nothing about your product — every unknown field explains why |
invalid | a value is recorded, but not in the form the guideline states |
Two invalid results are expected on a default run. TR-03183-2 states SHA-512 for the deployable component's hash; by default SBOMFlow records a SHA-256 content digest, so a recorded digest is reported invalid rather than quietly accepted. Set scan.file_hash_algorithms: [sha256, sha512] and the scan records a SHA-512 beside the SHA-256 for every file it reads, taken in the same read: CycloneDX hashes[] gains a SHA-512 entry after the SHA-256 one, SPDX 2.3 checksums[] a SHA512 after the SHA256, SPDX 3.0.1 verifiedUsing carries both, and the row then reads present when every component carries a SHA-512, or partial naming the rest. A component identified by coordinates (a purl) carries no digest at all, because SBOMFlow never records a hash of bytes it did not read, so a run mixing lockfile components and inventoried binaries reads partial — which is what it is. sha256 stays required: it is the identity every other artifact keys on.
The creator field is no longer permanent. The guideline states an email address or URL for the SBOM creator, and the producing tool and version is neither — so creator_of_the_sbom reads invalid until you declare product.sbom_author with an email or a url in sbomflow.yaml, after which it reads present. The contact is emitted in CycloneDX metadata.authors and metadata.manufacturer.contact, SPDX 2.3 creationInfo.creators and SPDX 3.0.1 createdBy.
SBOMFlow records only what you declared there. It never reads a contact from git config, from $EMAIL or from the OS user: an inferred contact would publish an address nobody chose to publish, and would make the emitted documents depend on the machine that ran the scan. SBOMFLOW_SBOM_AUTHOR_* is recognised and ignored for the same reason, and you are warned that it was. A declared contact is redacted from the sharing pack (shared-spdx-sbom.json keeps the Tool: creator only), so publishing it is a decision about the full SBOM, not about every artifact a run produces.
Which SBOM format the guideline accepts
TR-03183-2 also states which SBOM formats it accepts, and at which minimum version. The report renders those floors against the documents the same run emitted, in a sbom_format_floors block:
| Emitted document | Its version | Floor the guideline states | Row |
|---|---|---|---|
cyclonedx-sbom.json | 1.6 | CycloneDX 1.6 or higher | present |
spdx-sbom.json | SPDX-2.3 | SPDX 3.0.1 or higher | missing |
The SPDX row is expected. SBOMFlow emits SPDX 2.3 deliberately — it is the interoperable target and 3.0.1's ecosystem tooling is immature — so that document sits below the version this guideline states, and the report says so plainly rather than leaving you to compare two files by hand.
The same four states are used as for fields, and for the same reason: a format this run did not emit (unknown) and a format emitted at a version that could not be read (invalid) are different facts, and a yes/no would collapse them into one indistinguishable "not met".
Each row is a version comparison against a transcribed number, not a judgement that a document is adequate or acceptable. Which format a given reviewer or customer requires is theirs to decide.
This is field presence, never conformance: the artifact carries no "compliant" / "conforms" / "certified" language, no score and no grade, and presence here is not evidence of CRA conformity. Fields TR-03183-2 requires but SBOMFlow cannot see — the executable/archive/structured properties of a shipped file, a per-component creator's email — are reported unknown, never guessed.
Opt-in artifacts#
Each appears only when you ask for it; all remain offline unless stated:
| Artifact | Flag / command | Observation or decision |
|---|---|---|
vex.json (OpenVEX) and cyclonedx-vex.json | --emit-vex | Decision-bearing: statuses come from your recorded vulnerability reviews; not_affected without a valid justification is downgraded, never emitted. cyclonedx-vex.json carries the components its statements affect (so every affects[].ref resolves in the same document, as CycloneDX defines it) and names the paired cyclonedx-sbom.json by a BOM-Link (urn:cdx:<serial>/<version>) in its externalReferences; an affects ref is never a BOM-Link, because Dependency-Track applies an unresolved BOM-Link ref to every vulnerable component of a project |
csaf-vex.json (CSAF 2.0 VEX) | --emit-vex | Decision-bearing, from the same reviews. A not_affected product carries its CISA justification as a CSAF flag; a reviewer's statement on an affected product is its impact statement (a threat, verbatim). CSAF 2.0 mandatory test 6.1.27.10 also requires an action statement for every known_affected product — a remediations entry naming it, with a category from the CSAF vocabulary (read from the vendored OASIS schema). It comes only from the review's own action and remediation_category keys, recorded together (reviewer runbook § 5); SBOMFlow never authors a remediation or chooses its category. Without them validate reports the document as not customer-ready, naming the product and the test, and case export-advisory still writes its DRAFT and prints the same finding (a case triage records no action). These are checks of a subset of the CSAF 2.0 section 6.1 mandatory tests (beside the vendored OASIS schema, which validate runs when the jsonschema extra is installed); a section 6.1 test the check does not apply is not run, and not run is never a pass. Test 6.1.27.11 is applied: every CSAF VEX document must carry /vulnerabilities, and the schema requires at least one entry, so a run with no vulnerability to state (a clean scan, or every finding disputed) writes no csaf-vex.json and records vex_export_has_no_statements with path csaf-vex.json in scan-warnings.json, saying why; SBOMFlow never invents a vulnerability to fill the array, and validate refuses a CSAF VEX document without /vulnerabilities. The draft's document.publisher is csaf.publisher from sbomflow.yaml when it is stated; otherwise a placeholder that says so (category other, name "Publisher not stated (draft placeholder)", a .invalid namespace), which validate notes on a draft and refuses on any other status. SBOMFlow is named only as tracking.generator, never as the product's vendor |
cyclonedx-vdr.json (Vulnerability Disclosure Report) | --emit-vdr | Observation — discloses every known finding, including untriaged ones. An untriaged finding carries no analysis block, because nobody has judged it. Where a review exists the analysis mirrors the VEX exactly. A VDR answers "what is known?"; a VEX answers "what does it mean?" |
ssvc.json | --emit-ssvc | Observation — a suggested priority from the CISA decision table; never sets status or blocks a gate |
Technical-documentation pack + eu-declaration-of-conformity.DRAFT.* | --emit-techdoc | Observation assembled for humans; the declaration is an UNSIGNED DRAFT and the watermark is validator-enforced |
cra-article14-*.DRAFT.json/.md | --emit-incident-report | Drafts with submitted: false; SBOMFlow never files or transmits them. Each (schema 1.1) names the Article 14 model it was built from — model_version, model_content_digest and the model's pinned_sources with their retrieval dates and sha256. Each carries a deadline_clock for its stage — elapsed-time arithmetic over instants you record with --incident-awareness-at and friends, never a legal deadline and never a decision that anything is reportable. A window whose anchor you did not record is UNKNOWN by name, never a computed date |
cra-article14-*.SRP-ORDER.DRAFT.json/.md | --emit-incident-report | The same drafts in ENISA's own Single Reporting Platform field order, for a human to transcribe from. SBOMFlow models no SRP API — ENISA's FAQ says none is provided at the platform's initial release; nothing is invented, dropped, or reordered, and the counts are not a readiness score |
release-drift.json | --previous-output or compare-releases | Observation: added/removed/changed components, findings, evidence, gates, and support periods between two releases. support_period_changes also says which way a declared support end date moved — support_period_extended and support_period_shortened, decided by parsed instant, so one instant written two ways has no direction and an unparseable value has none either (it is reported as invalid_support_period_release_metadata); an end date that moved earlier is material in summary.materiality, and the default-off policy key fail_on_support_period_shortened makes it block (an end whose direction could not be read, or that was removed, blocks it as policy_data_unavailable instead of passing). Declared support promises (release.support_promises, --support-promise KIND=DATE) are compared kind by kind through the same keys, each row carrying kind and the id support_promise:<kind>. ai_changes carries the AI evidence delta — a model whose bytes differ, a declared supplier/licence/base model/dataset provenance/pinned digest that moved, an external provider, an agent tool or permission, lineage edges, and observed-versus-declared disagreements that appeared or cleared. An empty ai_changes is never agreement by itself: state, reason and no_change_meaning distinguish "compared and identical" from "nothing was comparable" (no AI record on the previous release, an unreadable one, or a run with no scan). summary.comparable says the same thing for the release as a whole: it is false when either side was unavailable, and every cross-release count is then empty rather than a number — a delta against a release nobody read is not an observation, so gaps are not reported as resolved and components are not reported as removed, and an enforced drift gate reports release_drift_unavailable rather than passing over the empty sets. No record says a change is harmful or disallowed |
oci-layout-evidence.json (experimental) and release-record.json container_images | release.container_images in sbomflow.yaml (the images this release declares it ships) | Observation, context only: each declared image's reference and digest, where the digest came from (declared_in_config), the local layout or image tarball named for it (display form, never a host path), and whether the declared digest is one the local bytes carry — match (the reader recomputed that blob and it hashed to the digest), mismatch, not_established (bytes named, nothing decided, with the reason) or not_supplied (no local bytes named; never match). The referrer manifests those bytes hold for the image (SBOM, attestation, signature) are listed as recorded facts: SBOMFlow checks no signature and states nothing about who produced them. With --previous-output, comparison_with_previous compares the declared set with the previous release record by reference and digest and names a digest changed under an unchanged release version. Local files only: no registry, referrers API or container daemon. The readers' warnings are recorded here and on stderr, never in the evidence pack, the release gate or the approval digest; a run without the key writes neither the block nor the file |
covered-software.json (experimental) | covered_software: in sbomflow.yaml (files whose SHA-256 you recorded from the record that identifies them: an id, a path relative to the release tree, a reference_sha256, optionally a scheme_record_sha256) | Observation, a fact about bytes: one entry per declared file, sorted by id, with the recorded reference_sha256, the observed_sha256 when the file was read and a state — identical, differs (info warning covered_software_digest_differs), path_absent, path_not_regular_file (a symbolic link anywhere on the path, which is never followed, or anything that is not a regular file) or path_unreadable (a read that failed, never reported as absence) — with a detail when no digest was taken and a meaning per state. The comparison is the SHA-256 of the file's bytes and nothing else, never its size. identical means the file has the digest you recorded; SBOMFlow reads no scheme rule and decides nothing about re-certification. validate recomputes every state from the two digests and from artifact-manifest.json, so an edited state, digest or count is named. The warning is recorded here and on stderr, never in the evidence pack, the release gate or the approval digest; a run without the key writes no file |
assessment-findings-ledger.json (experimental) | --assessment-findings PATH on analyze or audit (a sbomflow-assessment-findings-v1 file you write from an assessment report — a penetration test, a lab evaluation, a process audit, a bug-bounty programme: source {id, report_sha256} and findings [{id, title, severity_as_supplied, component_hint?, first_reported_version?}]; see inputs) | Observation the operator declares, never a finding SBOMFlow made: one row per declared finding, sorted by id, with source_id, id, the title and severity_as_supplied exactly as supplied (never re-derived), its review_key assessment:<source id>:<finding id>, a status and its basis. status is open_no_decision until a named reviewer's fix_evidenced, risk_accepted or not_reproducible is recorded under the key in the reviews input the run reads (--reviews or reviews:), and then decision_recorded with the decision quoted as recorded; a state outside those three, or an entry with no readable reviewer or note, is shown as not applied (decision_problem) and the finding stays open. source.report_state is among_preserved_artifacts (with the matching preserved-artifact row's review key) or not_among_preserved_artifacts (warning assessment_finding_source_report_missing): the report is named by digest and never read. An entry that is not a well-formed finding is listed under skipped_entries with the reason, and two entries stating one id are both kept, open, with no decision bound to either (warning assessment_findings_malformed). undeclared_decisions names decisions under an assessment: key no finding carries. With --store, an open finding whose key carries a decision in an earlier stored release has a carry_forward_candidate (needs_confirmation, with review_again_reasons prior_releases_disagreed / source_report_changed; never applied), and carry_forward.prior_findings_not_declared names findings left open in an earlier release that this file no longer lists. validate recomputes each status from its basis and the summary from the rows. Nothing here sets a VEX status, enters the case workflow, or reaches the evidence pack, the requirement assessment, the release gate or the approval digest; a run without the option writes no file |
component-support.json (experimental) | component_support.declarations and/or component_support.snapshots in sbomflow.yaml (local files, each relative to that file: declaration files listing {identifier, support_level, support_ends, note} with schema_version: sbomflow-component-support-declaration-v1; lifecycle snapshots, either an operator snapshot {schema_version: sbomflow-component-support-snapshot-v1, entries: [{identifier, cycle, support_ends, source_url, retrieved}]} or a public lifecycle API's product document saved locally, schema 1.x; stale_after_days, default 90) | Recorded, never inferred, never fetched: one row per component of the release, sorted by purl, with two blocks that answer two questions and are never read from each other. declared_support — actively_maintained, no_longer_maintained or abandoned with the declared end date, the declaring file's path as written and its SHA-256; not_declared when no declaration names the component (the row is always present, so nobody-said is visible rather than missing); undetermined with the declared value kept verbatim when a level or date cannot be read or two declarations disagree. lifecycle_snapshot — maintained or eol (the end date a snapshot states for the component's cycle, measured at --as-of, or the snapshot's own end-of-life statement), not_mapped when no snapshot names the component, unknown with its reasons, and every source (snapshot file and SHA-256, source URL, retrieval instant). A cycle applies to a version that is the cycle or starts with it and a dot. support_gap lists components whose snapshot end is before the release's security_updates promise end, else its kind-less support end, never a hardware date; support_gap_basis says which, or — when the release declares neither — that nothing was compared, which is never a statement that there is no gap. component_support_changes lists components that became eol since a --previous-output release. Warnings component_support_snapshot_stale (a record retrieved more than stale_after_days before --as-of) and component_support_not_mapped (a count) go to stderr and this file, never the pack. Matching is identifier equality only (a Package URL, or a CPE's vendor and product); a declaration or record naming no component is listed as unmatched. Nothing reaches the evidence pack or the approval digest; the gate reads support_gap only through the default-off fail_on_component_support_gap, which, enforced, blocks as policy_data_unavailable — never passes — when a configured snapshot was not read, the snapshots read hold no record or no component end it can compare, a stated end cannot be compared (an end of life without a date, an unreadable date, disagreeing statements, no component version), or the release declares no readable support end. validate recomputes every declared state, the gap rows and the summary |
deployed-environment.json (experimental) and release-record.json deployed_environment | release.deployed_environment in sbomflow.yaml (the path of a declaration file the operator produced) | Operator declaration, context only: provenance operator_declared, is_monitoring: false. The environment label, the instant the operator's inventory was taken, the source they ran, and each entry (image, service or host package) compared with this release only by exact digest or exact version equality — matches_release, differs_from_release or not_comparable with its reason — with every count beside its denominator. Never a gate decision or a VEX status; a run without the key writes neither the block nor the file. See Deployed environment |
update-payload-binding.json (experimental) and release-record.json update_payloads | release.update_payloads in sbomflow.yaml (a list of update payload files) | Bytes and digests, context only: each payload's SHA-256 (and SHA-512 when opted in), the format its file name selects, the inner image digests its parser reports held against the recomputed ones, and this release's id and evidence-pack.json digest. A payload outside the scan root is kept under external/. Never a gate decision or a VEX status; a run without the key writes neither. See Update payload binding |
reqif-export-receipt.json (experimental) beside a ReqIF document | sbomflow export-reqif EVIDENCE_DIR --output FILE | What the export read and wrote: the document's SHA-256, the verified release and requirement regime it came from, object counts by source, and the exploitation-delta state (not_available in this version). The ReqIF document itself is an external format; see Handing a release to a requirements-management tool |
sbom-concordance.json (experimental, only with --output; --json prints the same document) | sbomflow sbom-set compare SBOM SBOM [SBOM ...] | Observation, comparison only: whether the supplied SBOMs are shown to describe the same bytes (the subject binding and its reasons), each input's custody record (original SHA-256, format, subject, generator, import state), and, only when the binding holds, nine dimensions N sources wide with each fact's document coordinate. Elects no winner, merges nothing, changes no inventory or gate |
requirement-catalog-view.json (experimental) | requirement_catalogs: in sbomflow.yaml (your own requirement lists, each a local profile file regime-profile init writes) | Your requirement list compared with what this run recorded, context only: per requirement, what its selector found in this run's own records — evidence_observed, evidence_not_observed, manual_review or not_evaluable — with your ids, titles, citations, notes and legal_review copied verbatim and provenance user_supplied. SBOMFlow did not write, interpret or review the requirements and states nothing about conformity. A catalog that cannot be used is requirement_catalog_unreadable and reads not_evaluable, never an absent view. Never a gate input; a run without the key writes no file. See Your own requirement lists |
device-advisory-context.json (experimental) | identity.devices in sbomflow.yaml (devices declared by vendor, product name and/or model number and firmware version), read against the CSAF documents among the run's supplier VEX inputs (supplier_vex / --supplier-vex) | Context, never findings: one observation per advisory product whose vendor branch EQUALS a declared vendor spelling and whose model-number pattern matches a declared model or whose product-name branch EQUALS a declared name, with the branch path, the identity basis (model_number, model_number_abbreviated, product_name), what the advisory's version statement says about the declared firmware version (in_range, not_in_range, all_versions, no_versions, equal, not_equal, not_stated, or not_evaluated with its reason) and the advisory's own product-status bucket quoted with its CSAF name. Near misses are listed, never matched; coverage counts every document, product and result with its denominator. Never a finding, a VEX status, a release-gate input or an approval-digest input; a run without the key writes no file. See Declared devices and CSAF advisories |
cpe-candidates.json (experimental) | scan.cpe_candidates: {enabled: true} in sbomflow.yaml, with a local NVD snapshot (--nvd-file / nvd.file: an NVD CVE API 2.0 JSON file, a .json.gz or .zip feed, or a vulndb store) | Observation, never a finding: each component whose DECLARED CPE (an imported SBOM, a build, identity.cpe_map, or the curated vendored-header table, which names both Mbed TLS and both FreeRTOS NVD names) shares a vendor:product with a vulnerable: true cpeMatch in an NVD CVE configuration. Each row carries component_ref, cpe, cve, nvd_status, range_evaluated (the four version bounds under a dotted-numeric ordering; an unorderable version is listed with its reason and cpe_candidate_version_unorderable, never dropped), platform_condition (platform_condition_not_evaluated for an AND with a platform), match_basis: cpe_configuration, match_confidence: candidate, the disposition a human recorded this run and any prior_disposition surfaced from an earlier stored release or a declared family member (analyze --store, family.json beside the config). coverage counts components with and without a declared CPE and the snapshot's Deferred (nvd_configuration_deferred), Rejected and unanalysed records, each with its denominator; cpe_snapshot_missing says when no snapshot was read. A human disposes of a candidate with review --dispute-match <candidate_key> (disputed), or a match_disputes entry with "disposition": "confirmed" (a confirmed candidate stays blocking under the knob and is not turned into a finding); the key is (vendor:product, CVE, NVD range digest), so a disposition decays when NVD edits the range. No name guessing, no fuzzy match, no network. Nothing reaches the evidence pack, the release gate or the approval digest unless the operator sets the default-off fail_on_unreviewed_cpe_candidates; a run without the key writes no file |
supplier-sbom-field-presence.json (experimental) | An SBOM declared in suppliers/<dir>/intake.json, or imported with --import-sbom / external_sboms | Observation, presence only: one table per supplier or imported document, read by the same check validate runs over SBOMFlow's own SBOMs. Each of the five fields (SBOM Tool Name, SBOM Generation Context, Component License, Component Hash Value, Component Hash Algorithm) reads present, missing, withheld (only when the document itself declares it) or not_established with a reason (empty_population, fields_not_interpreted, unread_carrier, unexamined_members), with its examined count; the declared format version is compared with the BSI TR-03183-2 floor for that format (at_or_above_floor, below_floor, version_unreadable). A field the check did not read is never reported missing; a document the importer did not parse carries no table. Either gets supplier_sbom_field_presence_unassessable with the importer's own warning counts, on stderr and in the file. No score, grade or verdict; nothing reaches the evidence pack, the release gate or the approval digest, and a run without such an SBOM writes no file |
supplier-sbom-correspondence.json (experimental) | scope_paths in a supplier's suppliers/<dir>/intake.json (tree paths its deliverable occupies) | Comparison, context only: each SBOM that supplier declared against the components this run observed under those paths (identified by package URL where a lockfile's registry ecosystem or a catalog rule gives one, never by name) — packages in both, supplier-only, observed-only and version disagreements, or the reason no comparison was made (counts null, never zero). The binding is the intake's declaration; nothing is merged and nothing changes the pack, the gate or any acceptance |
preserved-artifact-changes.json (experimental) and preserved-artifacts.json comparison_with_previous | compare-releases (the file) or --previous-output (the block) | Observation, context only: the preserved external artifacts compared between two releases by artifact sha256 — added, removed, review_status_changed (effective status and whether a decision applied, or the recorded decision, as each index recorded it; each row says which), association_changed (field by field, verbatim with origins), record_changed (custody records by record sha256) and run_check_changed; beside them, stated_scenario_pairs sets each added capture beside every previous-release capture whose record states the same scenario text (exact strings; never the file name or content), with the previous decision as context and decision_state saying whether a decision is recorded for the new bytes (decided), not carried (decision_not_carried) or on neither side — two records stating the same text, never the same capture, and not counted in changes_total. Never inside release-drift.json, because release drift is part of the approval digest and preserved artifacts are not. A named state says whether a comparison happened: against an output written before preserved-artifact indexes existed it is previous_index_absent and reads "not compared", never "no change"; an unreadable index is previous_index_unreadable/current_index_unreadable, never empty. When the comparison DID happen, the index's own rows also carry prior_decision_context (above); the changes file carries no row, so it carries no context. No artifact content is compared or carried |
recheck/recheck.json and recheck.md | recheck EVIDENCE_DIR --osv-snapshot FILE [--vulndb-dir DIR] | Observation, advisory only: a shipped release's recorded components re-matched against newer local advisory data, each finding classified newly_affected / no_longer_affected / unchanged. Never a gate input and never a review decision; the shipped pack is never mutated. Every row also carries release_snapshot_knowability, one of exactly three values, read from the advisory snapshot the RELEASE recorded, looked up strictly by the digest it recorded and never by what is active today: absent_from_release_snapshot (neither the advisory id nor any alias is in that snapshot), present_in_release_snapshot (it is — on a newly_affected row, it was there and did not match then), or release_snapshot_unavailable (those bytes were not read, so membership is not established either way; never a synonym for absent, and no other snapshot is read instead). release_snapshot_sha256 is the digest of the bytes actually read, null when none were, and the top-level release_snapshot block names why they were or were not read. The store is the one --vulndb-dir DIR names — local only, read and never fetched; the option has no default and no store is guessed from the working directory, so without it every row reads release_snapshot_unavailable (resolution no_store_examined). The field records snapshot membership only; what it means for a release is for the reader to decide |
evidence-aging.json | analyze --store DIR | Observation, context only: review-age per in-scope requirement — how many stored releases since a human last accepted its evidence (0 = accepted this run, "never" = no acceptance on record). A stored release whose coverage report could not be read still counts as a release, and is named under prior_releases_skipped with why (absent, unreadable, tombstoned by retention, with the trash path the index records, or by_reference for a release added with store add --no-copy, which the store never held). An unread release newer than the acceptance makes that age an upper bound and says so. never_accepted is a claim about every stored release, so a run that could not read them all reports acceptance_undetermined instead. Never changes review state or a gate; the store is read-only |
finding-regressions.json | analyze --store DIR | Observation, context only: findings that were present in an earlier stored release, resolved in a later one, and are present again now (a depth-one drift report cannot see this). Matched by finding_key, so a re-identified advisory on the same component stays one finding. Sets no VEX and blocks no gate by itself |
carry-forward.json | analyze --store DIR | Observation, context only: findings this release re-observes that a human already determined in an earlier stored release — so re-review does not start from an empty page. Read from the stored releases' vulnerability_reviews.json (the human decision), never from the pack's engine-observed status. Every candidate's disposition is needs_confirmation and stays that way: a prior decision never applies itself, sets no VEX status here, and blocks no gate. Each row also states why the prior answer may not still hold — the component version moved since the decision, or earlier releases disagreed — so confirming is a deliberate act rather than a reflex. Rendered as a worklist in reviewer.html |
freshness-horizon.json | report freshness <output-dir> | Planning context only: one dated horizon — accepted-evidence validity windows, advisory-snapshot staleness, waiver expiries, the support-period end, and each component end date a lifecycle snapshot stated in component-support.json (component_support_end; a declared support level is never a row) — sorted soonest-first with expired / expiring_soon / fresh / undetermined status. Read-only; changes no review state and blocks no gate. An advisory snapshot whose age vuln-source-health.json could not determine — no date recorded, an unreadable one, or a date later than the run — is undetermined: stale_at and days_remaining are null, the recorded input is echoed verbatim, and the row repeats that artifact's own age_undetermined_reason. It is never given a horizon derived from a date that produced no age, so the two artifacts cannot say different things about one snapshot; a source the run never consulted has no snapshot to age and appears here not at all. Every day-count is relative to the --as-of pin, so the pin is strict: a malformed --as-of is refused (exit 2) rather than silently replaced by the wall clock, and a pin more than a day in the future is reported in the artifact's warnings list and on stdout — the horizon is still produced, the breadcrumb is never dropped. It also carries an overdue view of what is ALREADY past its date — CISA KEV remediation due dates that have passed, and expired exceptions re-queued as context. Read-only and never an SLA: it blocks nothing, changes no gate, reopens no decision, and asserts nothing about being on time or compliant. A KEV due date is CISA's date for federal agencies, not one SBOMFlow sets |
analysis-control-receipt.json | doctor --analysis-control PATH | Observation about your installation, not about your product — the one entry here that is not written into an output directory. It executes a reserved synthetic corpus shipped inside the package, fully offline, in a throwaway directory, and records which named controls this installed copy could perform: SBOM readers on valid, malformed and future-spec documents, declared and zero dependency edges, one exact advisory identity plus its near-name and near-version refusals, and the optional JSON-Schema capability. Outcomes are passed / failed / not_run / unsupported / degraded, kept distinct so an absent optional extra never reads as a pass; control_health is healthy / degraded / failed / indeterminate, and an absent, unreadable or empty corpus is indeterminate, never healthy. A packaged fixture that did not ship is failed, never skipped. It also records entry-point identity as an axis of its own: whether the installed distribution's declared sbomflow console script resolves to the code this receipt actually ran. A stale console script from an earlier environment resolves a different package, and the receipt would otherwise describe a distribution the operator is not running. Six named states, compared by resolved location rather than by dotted name, and no filesystem path is recorded — it never moves control_health. It reads no customer project, blocks no gate, and states nothing about a product being safe, secure, compliant, certified, conformant or approved. Exit 0 only when every declared control passed; a written non-healthy receipt exits 6 |
evidence-bundle.json/html/zip | audit (ZIP via --zip) | Portable reviewer handoff copied from existing artifacts; byte-reproducible ZIP; adds no new claims |
| Sharing pack | sharing flags on audit | A redacted, documented subset for importers/distributors |
issues.csv, SARIF, PDF summary | export flags | Observations reformatted for spreadsheets, code-scanning UIs, and print |
reviews.json, vulnerability_reviews.json, waivers, approvals | review / approve commands | Decisions — recorded with reviewer identity, timestamps, and audit events |
review-queue.json preserved_artifacts section | review | Queue view, not a decision: the declared preserved artifacts from preserved-artifacts.json with the run's current reviews.json applied (decision_basis says whether it was), split into awaiting_decision, decided, decisions_not_applied and undeclared_decisions. Counted in its own unit, preserved artifact decisions, and never inside summary.awaiting_total, observed rows or distinct evidence decisions; counts are null when the index could not be read, never zero |
| Importer evidence (OCI layout, CMake, linker map, Zephyr, MCUboot, update manifests, provenance checks) | dedicated commands | Observations from explicitly supplied local inputs |
cyclonedx-mlbom.json (CycloneDX 1.6 ML-BOM) | --emit-mlbom | Observation — the AI inventory in CycloneDX's ML-BOM shape. BOM-Linked to cyclonedx-sbom.json, never copied into it. Adds no claim the always-written ai-evidence.json does not already make |
spdx3-aibom.json (SPDX 3.0.1 AI + Dataset profiles) | --emit-aibom | Observation — the same AI inventory in SPDX. Add --emit-spdx3 and it imports that document's product element by IRI instead of restating it |
Case advisories and the local CSAF directory#
Written by the case commands into a case directory or a directory you name, never by analyze. Generated locally for the manufacturer to review and publish; SBOMFlow does not publish, host, sign or upload.
| File | Written by | What it is |
|---|---|---|
case-advisory.DRAFT.json | case export-advisory (default, or any --document-status draft) | An unsigned CSAF 2.0 draft (csaf_vex by default, or csaf_security_advisory). Nothing is recorded in the journal. |
case-advisory.interim.json, case-advisory.final.json | case export-advisory --document-status interim|final --reviewer --note --tlp | The same document at a status a named reviewer recorded, naming the publisher from csaf.publisher and the TLP label they chose. The journal's advisory_status_recorded event carries the file's sha256. |
<dir>/.well-known/csaf/… | case advisory-tree | A directory laid out as CSAF 2.0 section 7.1 describes: provider metadata, white/<yyyy>/ documents with .sha256/.sha512 files, index.txt, changes.csv, a TLP:WHITE ROLIE feed. Only documents recorded final for exactly their bytes, TLP:WHITE, naming the stated publisher. |
<dir>/csaf-provider-tree-receipt.json | case advisory-tree | Family sbomflow-csaf-provider-tree-v1: every file and its sha256, each document's final record, its empty .asc slot, its section 6.1 test outcomes (tests not run listed as not_run) and the state of each section 7.1 requirement (carried, or outside SBOMFlow because it depends on hosting). |
Reproducible output#
--as-of is what makes a run's artifacts byte-reproducible: it pins the instant every timestamp, age, expiry and window is measured from, so two runs over the same inputs with the same --as-of write the same bytes. Without it, those fields take the run's wall-clock time.
The SOURCE_DATE_EPOCH environment variable (the Reproducible Builds convention, <https://reproducible-builds.org/specs/source-date-epoch/>) sets one thing when no --as-of is given: the embedded creation timestamp of the three SBOM documents — CycloneDX metadata.timestamp, SPDX 2.3 creationInfo.created and SPDX 3.0.1 created — so two analyze/audit runs of the same inputs with the same value write byte-identical SBOMs. It is not the instant anything is evaluated at: the evidence pack's generated_at, and every age, expiry and staleness window, stay the run's own time, because SOURCE_DATE_EPOCH is the date of the source (often the last commit) and measuring a waiver's or an evidence item's validity at it would read an expired item as current. run-receipt.jsonl records the value used (event sbom_timestamp_basis). An explicit --as-of sets every timestamp, the SBOMs' included. A value that is not the decimal seconds date +%s prints is refused (exit 2, E019) before anything is written; an empty value is ignored. For a byte-reproducible pack, set --as-of, for example from the commit time in CI.
Update payload binding#
update-payload-binding.json (experimental, family update-payload-binding) is written by a run given release.update_payloads in sbomflow.yaml, and only then. For each named payload file it records its SHA-256 (and SHA-512 when scan.file_hash_algorithms includes sha512), the update format its file name selects, the inner image digests that format's parser reports held against the digests recomputed over their bytes (matches, differs, not_compared), and this release's release_id with the SHA-256 of its evidence-pack.json. release-record.json carries an update_payloads summary row per payload and, with --previous-output, update_payload_changes. A payload outside the scan root is kept under external/ and checked by store verify. A payload named inside the scan root that a symlink takes outside it is refused unread (unreadable, reason link_outside_scan_root, symlink_outside_root on stderr) and nothing is copied; name a file kept elsewhere by its real path instead. The record says this payload's bytes were recorded beside this release's evidence, and nothing about any device or rollout; it is never a gate decision or a VEX status. See docs/FIRMWARE_EVIDENCE.md.
Deployed environment (operator-declared)#
The operator produces the file; SBOMFlow never looks at a live system. A release record describes a build. A producer that also operates what it builds — a device maker with a cloud back end, a platform team, a SaaS team, an integrator — is asked which release is deployed, and where. SBOMFlow answers only from the producer's own records: name a declaration file under release in sbomflow.yaml,
release:
deployed_environment: ops/eu-west-deployed.json # relative to sbomflow.yamland write the file with whatever tooling you already use to take an inventory:
{
"schema_version": "sbomflow-deployed-environment-declaration-v1",
"environment": "eu-west production",
"taken_at": "2026-06-15T09:00:00Z",
"source": "inventory export written by the platform team's release tooling",
"entries": [
{"kind": "image", "reference": "registry.example/gateway", "digest": "sha256:<64 hex>", "count": 12},
{"kind": "service", "reference": "gateway-api", "version": "1.4.2", "count": 3},
{"kind": "host_package", "reference": "openssl", "version": "3.0.13", "count": 40}
]
}Each analyze and audit run given the key reads that one local file and contacts nothing: no cluster, registry, orchestrator or host. It writes deployed-environment.json (provenance operator_declared, is_monitoring: false) and a compact deployed_environment block in release-record.json, and prints one summary line with every count beside its denominator, for example 3 of 6 declared entries match this release, 2 of 6 differ from it, 1 of 6 are not comparable.
Each entry is compared with this release only by exact equality:
| Entry | Compared by | With |
|---|---|---|
image, service with a digest | digest | the digests the release declares under release.container_images |
image, service with a version | version | the release version |
host_package with a version | version | the versions of the release's own components with exactly that name |
The result is matches_release, differs_from_release, or not_comparable with the reason (the entry states nothing the release can be compared with, or the release states nothing to compare it with). No range, prefix or name similarity is ever used. A declaration none of whose entries match lists every entry as differs_from_release; it is never shown as an empty or clean view.
taken_at is recorded exactly as written. When it is later than the run's own instant (--as-of, or the run time) it is flagged taken_at_later_than_run and still recorded unchanged. A declaration that cannot be read is the unreadable_file warning, and the view says not_compared; a declaration that is read and is not valid — not JSON, a repeated JSON key, an unknown key, a malformed entry or timestamp, a repeated entry, or text over its bound — refuses the run with E016 before anything is written.
The view says what two records contain. It is context for a reviewer: it never sets a VEX status, never moves the release gate or the approval digest, and says nothing about any instant other than taken_at. The declaration's format is published as the sbomflow-deployed-environment-declaration input schema (sbomflow schema list).
Supplier VEX documents, each accounted for#
Every supplier VEX document a run is given (supplier_vex: / --supplier-vex, and a supplier intake's VEX) gets a row in evidence-pack.json under vulnerability_scan.supplier_vex_documents, and START-HERE.md restates each row: the document's position among the documents given, its display path and its sha256 (two files outside the product tree both show as outside-scan-root/vex.json; the position and digest keep them two rows), its format (openvex, csaf, cyclonedx, or not_read when it could not be read), and how many statements it carried, how many are naming_a_component (a component purl), naming_an_inventoried_component (a purl this run's inventory holds — the only statements a finding can carry), applied_to_a_finding, and naming_no_component (also warned as supplier_vex_statement_unscoped). A statement bound to a component with no finding for its advisory used to leave no trace anywhere; it is now counted. A statement is context for a reviewer and never a VEX status; nothing here changes a status, a gate or a warning, and a run with no supplier VEX writes no key.
Declared devices and CSAF advisories#
Industrial, energy and building-automation advisories — from a vendor PSIRT or from a national CERT that republishes them — describe the products they are about by a CSAF branch path: vendor, then product_name, then product_version_range or product_version, sometimes with product_identification_helper.model_numbers, and almost never with a purl. SBOMFlow binds a supplier statement to a component by purl only, so such a statement stays supplier_vex_statement_unscoped. Declaring the device under identity.devices (see inputs) makes a run write device-advisory-context.json beside it, from the CSAF documents it already reads as supplier VEX. Nothing is fetched.
Identity is exact. An advisory product yields an observation for a declared device when its vendor branch equals one declared vendor spelling, letter for letter, and either a declared model number matches one of its model-number patterns or a declared product name equals one of its product_name branches. The pattern language follows each document's own CSAF version: under CSAF 2.0, ? is one character and zero or more, and a pattern with no that matches only the start of the declared model is recorded as the separate, weaker basis model_number_abbreviated (CSAF 2.0 lets a model number "stop at any point"); under CSAF 2.1 the pattern must cover the whole model number and \?, \* and \\ are literals. A product that lists model numbers yields nothing for a device whose declared models all miss them, even when the name is equal (model_list_excludes_declared). A vendor, product name or model number that differs only by letter case or outer whitespace, or a CSAF 2.1 model prefix, is listed under near_misses and never matched; the distinct product names under a declared vendor that did not match are listed too, and coverage.vendor_names_seen lists the vendor spellings the documents use, so a declaration can be corrected without SBOMFlow guessing.
Versions are evaluated only where a rule exists. A product_version is compared by exact string equality (a leading v is never stripped; it is noted when it is the only difference). A vers: range is validated strictly against the pinned vers-spec text (commit 599b53de29e0271314e82347516845f18928237a, pre-standard) and evaluated for vers:semver (SemVer 2.0.0 grammar and precedence) and for vers:intdot when every version is a whole dotted number (4.2.1, never 4.2.1-HF1 or V4.2.1); states all versions and vers:none/ none. A range with a single comparator (vers:intdot/<4.2.3) is read as vers Clause 5.3.3.1 defines that comparator. Every other statement — a vls such as <=3.08.02, free text, a malformed vers: string, generic, lexicographic, datetime, a package-ecosystem type, a type the text does not list — is recorded verbatim as not_evaluated with its reason, for a person to compare with the declared firmware version.
Each observation quotes the advisory: which advisory, which product, its model list and version statement, and the product-status bucket the advisory lists it under (known_affected, fixed, …) with the vulnerability ids, including a status the advisory states only through a relationship such as installed_on. coverage counts documents, products, identity outcomes and version results, each beside its denominator, and all_versions is never added to in_range; devices_without_observation lists every declared device that found nothing, with how many documents and products were examined. START-HERE.md restates the artifact's own summary line.
The observations are context for a reviewer. None is a finding, a VEX status, an entry in the evidence pack's vulnerabilities, a release-gate input or an approval-digest input: the pack, release-gate.json and the approval digest are the same with or without identity.devices. The artifact's schema is the experimental device-advisory-context family (sbomflow schema show device-advisory-context).
Your own requirement lists (requirement-catalog-view.json)#
requirement-catalog-view.json)#Your requirement list compared with what this run recorded; SBOMFlow did not write, interpret or review these requirements and states nothing about conformity. A customer questionnaire, an internal release policy or a checklist your own staff wrote from a standard you hold is a list of questions only you can answer. SBOMFlow can say, for each question you tie to one of its selectors, what this run's own records hold. The walk-through is in Run a release against your own requirement list.
Name the lists in sbomflow.yaml, relative to that file:
requirement_catalogs:
- catalogs/release-policy.json
- catalogs/customer-questionnaire.jsonEach analyze and audit run validates every list with the same validator as regime-profile validate, registers it for that run as a view_profile that is packaged: false and customer_authored (it can never take an id SBOMFlow ships), and writes requirement-catalog-view.json. Per list it records the path the config wrote, your profile_id, title and version, a content digest, who authored it and its legal_review exactly as you recorded it. Per requirement it records your id, title, citation (not_stated when you gave none) and note verbatim, and one of four states with its meaning:
| State | What it says |
|---|---|
evidence_observed | the selector found, in this run's own records, the fact it names; what that means for your requirement is a person's decision |
evidence_not_observed | the selector ran and the run's records do not hold that fact |
manual_review | a person answers it: the selector is manual, or the fact is held but no human has reviewed or accepted it yet |
not_evaluable | the selector could not be evaluated (a record could not be read, or a file is written after the view); never the same as not observed |
Each selector row also carries SBOMFlow's one-line detail, the counts it read (for preserved_artifact_kind, present rows and accepted rows apart) and, under recorded, any value quoted from the run's records as it was recorded.
Four selectors answer release-process questions from records the run already holds, and quote what they read under recorded: approval_present (the approval count, and the required, satisfied_roles and missing_roles lists and every role the run recorded — a role recorded nowhere reads evidence_not_observed, never an invented one), gate_recorded (the gate's own sentence, enforcement mode and exit code verbatim, and with policy_requirement:NAME that policy's recorded outcome — an informational run keeps "No release-gate policy enforced"), artifact_digest_recorded (the SHA-256 release-record.json carries for a produced file; a file the run did not produce reads evidence_not_observed) and release_comparison_present (the comparison id and previous release of release-drift.json). They read the gate after it decided and never feed it.
A list that is missing, cannot be read, is not JSON, or is refused by the validator — an unsupported selector, an id SBOMFlow ships or another list in the run already uses, or text that states a conformity outcome — raises the requirement_catalog_unreadable warning on stderr, and the view names it not_evaluable with every problem. The run continues. The view is never a gate input: release-gate.json, evidence-pack.json, release-record.json and the approval digest are the same with or without it, under requirement_regime: cra or none.
Closing a gap between releases#
When you pass --previous-output, issues.json gains rows marked lifecycle_status: "resolved" — one per evidence gap or advisory finding that was present in the previous release and is absent now. The row names the gap_id or vulnerability_id that closed, so you can close the matching tracker item against something specific.
Those ids are, by definition, not in the current evidence-pack.json: that is what resolved means. validate therefore checks them against the resolved set recorded in release-drift.json — the same artifact the rows were written from — rather than against the current pack. Open rows are unchanged: their ids must still resolve against the current pack, and an id that appears in neither place is still reported.
The check is not skipped for resolved rows, only pointed at the right ID space. If no drift artifact is present, the strict current-pack check applies, so a run that lost its drift record reports the contradiction instead of accepting it.
Resolving a gap is an observation about the artifacts, never a conclusion: the row says a human should confirm the evidence change before closing anything.
The two open-standard AI-BOMs#
ai-evidence.json is written on every run and is the record. The two AI-BOMs are opt-in projections of it into third-party standards, for a consumer whose tooling reads CycloneDX or SPDX. They add no authority, resolve no conflict, and reach no verdict; both are validated against the publisher's own schema, vendored in the package.
They link, they do not duplicate. The ML-BOM carries a CycloneDX BOM-Link (urn:cdx:<serial>/<version>) to cyclonedx-sbom.json rather than folding model weights into the component list a vulnerability scanner walks. The SPDX AI-BOM declares spdx3-sbom.json's product element in SpdxDocument.import and hangs its contains relationships off that IRI — two SPDX documents naming one IRI are naming one element. Without --emit-spdx3 there is no external IRI to import, so the product is declared locally and the document's own comment says which happened.
A declared entity names where it was declared, never where that file sat on your machine. Every entity folded in from --ai-manifest carries a locator — the manifest's location plus a JSON pointer to the entry, such as #/models/detector — in ai-evidence.json and in both AI-BOMs (sbomflow:locator:N). The location follows the rule every other supplied input follows: relative to the product root when the manifest sits inside it, and outside-scan-root/<file name> when it does not. The same manifest kept in two different directories outside the product gives byte-identical artifacts.
That link can be checked — but only when SBOMFlow held the bytes. When the run also emitted spdx3-sbom.json, the reference carries verifiedUsing: a sha256 computed over the exact bytes that run wrote for that file. It is a digest of the document's content, never of its path — hashing a path would prove only that a string had not been edited.
Three states, and they are not two. The AI-BOM's comment says which one applies, because SPDX 3.0.1 allows only four properties on an ExternalMap and the reason cannot sit beside the reference:
| State | What happened | What is emitted |
|---|---|---|
| recorded | non-empty referenced bytes were held and hashed | verifiedUsing with the sha256 |
| unavailable | no bytes reached the writer — it does not separate "never emitted" from "could not be read", because it cannot tell them apart | no verifiedUsing; the reason is stated |
| refused (empty) | the bytes were obtained and are zero-length | no verifiedUsing; the refusal is stated |
The last two are deliberately kept apart. Empty is something SBOMFlow observed, not something it failed to get, and sha256("") is a perfectly real digest of nothing that would read as a verified reference. In a pack written before this field existed, and in any pack where the field is absent, absence means not recorded — never "verified".
What that digest claims, and what it does not. Exactly one sentence: the content digest of the exact referenced bytes was recorded. It records what those bytes were when the document was written — it says nothing about what is on disk now. It asserts nothing about the referenced document being schema-valid, correct, complete, of known provenance, authentic, signed, signature-verified, trusted, reviewed, accepted, in conformity with anything, safe, or independently corroborated. For the same reason a declared digest — a supplier's expectedHash for a file SBOMFlow never opened — never appears in a verifiedUsing anywhere in this document; it travels as sbomflow:unverified_digest.
The digest is self-attested. Read verifiedUsing with that in mind. The same run computed the sha256, over a file that same run had just written. Nothing external checked anything. A faulty or tampered run emits a document and a digest that agree with each other, and comparing the pair against itself cannot see that. What the digest does detect — provided this AI-BOM itself is unaltered — is alteration of the referenced file after the pack was written: a reviewer who resolves spdx3-sbom.json later can tell whether it is still the file this AI-BOM referenced. The proviso is not decoration. Neither document is indexed in artifact-manifest.json (which lists the inputs a run scanned, not the artifacts it emitted), so anyone who alters the target can alter the digest beside it consistently and the pack will show nothing. Read without it, this is tamper-evidence; read with it, it is drift-and-corruption evidence, which is what it actually is. The AI-BOM's own comment says all of this in those words rather than leaving a reader to infer it from a property named verifiedUsing.
The unknown is stated, never guessed. SPDX 3.0.1 gives three properties SBOMFlow cannot observe an explicit noAssertion member — ai_autonomyType, ai_useSensitivePersonalInformation and dataset_datasetType — and those are emitted as noAssertion rather than omitted, because an absent property reads to many consumers as "no". ai_safetyRiskAssessment is never emitted: its enumeration has no "no assertion" member and SBOMFlow performs no safety assessment, so any value would be an invented risk rating. A CycloneDX modelCard is omitted entirely when nothing was declared, because an empty card asserts a model card exists.
Run-level properties both AI-BOMs carry#
These describe the DOCUMENT and the run that produced it, not any one component, so they are not information loss — there is nothing in the internal graph they fail to carry. They are listed here because every sbomflow: name a reader may encounter is documented somewhere on this page, and a test asserts exactly that.
| Property | Says |
|---|---|
sbomflow:release_id | The release identity this inventory belongs to — the same one the rest of the pack uses. |
sbomflow:ai_entity_count | How many AI entities the document describes. |
sbomflow:ai_entities_in_release | How many of them the graph claims actually ship in this release. An entity can be recorded without being claimed. |
sbomflow:boundary | The no-overclaim sentence: engineering evidence only, no compliance, conformity, certification or safety claim, no conflict resolved, no human review replaced. |
sbomflow:software_bom_link | The CycloneDX BOM-Link to cyclonedx-sbom.json. |
sbomflow:software_bom_link_absent | Present INSTEAD when no usable serial number was supplied, so the absence of a link is stated rather than inferred from a missing key. |
sbomflow:software_product_link | The element-level BOM-Link to the software SBOM's product component. |
sbomflow:software_product_link_absent | Present INSTEAD when the product's bom-ref contains whitespace or a control character, which an IRI fragment cannot carry. Percent-encoding it would produce a link that no longer equals the bom-ref it resolves to, so no element link is emitted and this says why. |
Information loss: CycloneDX 1.6 ML-BOM#
Facts SBOMFlow holds that the standard has no field for. Each one is carried through CycloneDX's own documented extension mechanism — namespaced properties — so it is visible without ever being dressed up as a standard field.
| Fact | How it is carried | Why |
|---|---|---|
Identification strength (signature / structure / extension_only) | property sbomflow:observed:identified_by | CycloneDX records a hash but has no field for how a file's FORMAT was established. A model matched by a documented magic number and one matched only by its filename are different claims and stay distinguishable here. |
| Model container format and size in bytes | properties sbomflow:observed:format, sbomflow:observed:size_bytes | No native CycloneDX component field carries either for a model artifact. |
| SBOMFlow entity id and entity kind | properties sbomflow:entity_id, sbomflow:entity_kind | The bom-ref is derived from the entity id but is not the id. The kind is kept because prompt, agent and evaluation entities have no CycloneDX component type of their own. |
| Whether the name is a supplier's name or just a filename | property sbomflow:name_basis | CycloneDX has one name field and no way to qualify where it came from. |
| Observed file locations (one entity can have several) | properties sbomflow:locator:N | Sameness for an observed model is its digest, so the same weights shipped twice are ONE component with two locations. CycloneDX has no per-component file-location list. |
| Observed-versus-declared conflicts | properties sbomflow:conflict:<attribute> | CycloneDX has no representation for two sources disagreeing about one attribute. The disagreeing values are emitted together, unresolved. |
| Evidence provenance (observed, declared, or both) | property sbomflow:evidence_provenance | CycloneDX evidence describes licence/copyright analysis, not this distinction. |
| Declared base model (model lineage) | property sbomflow:declared:baseModel | pedigree.ancestors would require synthesising a component for a model SBOMFlow never observed, which would put an unobserved artifact into the inventory. The declaration is carried as a string instead. |
| Declared base model of an adapter | property sbomflow:declared:base_model | The same lineage gap as a model's declared base: the 1.6 modelCard has no base-model field and pedigree.ancestors takes a whole component, which SBOMFlow would have to mint for a model it never observed. |
| Typed graph relationships other than 'ships in release' | properties sbomflow:relationship:<predicate> | dependencies[] carries only 'ships in this release'. CycloneDX has no general typed-relationship vocabulary, so trained_on, derived_from, uses_model, invokes_service, governed_by_prompt and evaluates are named properties pointing at the other end's bom-ref. |
| A DECLARED digest, which is an expectation and not a verification | property sbomflow:unverified_digest | hashes[] is emitted ONLY for a digest SBOMFlow computed from bytes it read. A manifest's expectedHash describes a file that was never opened, and publishing it as a hash would republish 'the supplier says it should be X' as 'we checked and it is X'. |
| Why the inventory holds what it holds (no scan ran / the scan recognised nothing / the record was unreadable) | properties sbomflow:inventory_state, sbomflow:inventory_state_note | CycloneDX has no field for the provenance of an EMPTY list, and those three causes are different facts of which only one is reassuring. |
| Whether the tree walk that produced the inventory finished | properties sbomflow:inventory_partial, sbomflow:inventory_completeness_unknown | A scan a resource budget cut short must not project into a BOM that looks complete; CycloneDX models no such completeness signal. |
| Scan warning codes raised while walking the tree, and whether a declared manifest was supplied | properties sbomflow:scan_warning:N, sbomflow:manifest_supplied | Both are properties of the RUN rather than of a component. ai-evidence.json remains the full record. |
| Reconciliation summary (how many facts were comparable at all) | not carried — read ai-evidence.json | The denominator behind 'zero conflicts' is a property of the run, not of a component, and no BOM field describes it. The ML-BOM links back to the release; ai-evidence.json remains the record of what could be compared. |
Any declared field this writer does not place in a native CycloneDX field is still emitted, as sbomflow:declared:<field>. Nothing is dropped.
Information loss: SPDX 3.0.1 AI + Dataset profiles#
Carried through SPDX's own extension_CdxPropertiesExtension, which is why the document declares the extension profile alongside ai, core, dataset and software.
| Fact | How it is carried | Why |
|---|---|---|
Identification strength (signature / structure / extension_only) | extension property sbomflow:observed:identified_by | SPDX records a hash through verifiedUsing but has no property for how a file's FORMAT was established, so a magic-number match and a filename guess would otherwise look identical. |
| Model container format and size in bytes | extension properties sbomflow:observed:format, sbomflow:observed:size_bytes | dataset_datasetSize counts dataset entries, not bytes, and no AIPackage property carries a container format. |
| SBOMFlow entity id and entity kind | extension properties sbomflow:entity_id, sbomflow:entity_kind | The spdxId is derived from the entity id but is not the id. The kind is kept because prompt, service, agent and evaluation entities have no AI or Dataset profile class and are emitted as software_Package. |
| Whether the name is a supplier's name or just a filename | extension property sbomflow:name_basis | SPDX has one name property and no way to qualify its origin. |
| Observed file locations (one entity can have several) | extension properties sbomflow:locator:N | An observed model's identity is its digest, so the same weights shipped twice are ONE package with two locations; SPDX models no such list. |
| Observed-versus-declared conflicts | extension properties sbomflow:conflict:<attribute> | SPDX has no representation for two sources disagreeing about one property. The disagreeing values are emitted together and left unresolved. |
| Evidence provenance (observed, declared, or both) | extension property sbomflow:evidence_provenance | SPDX 3.0.1 has no per-property provenance in the AI or Dataset profile. |
| Declared base model (model lineage) | extension property sbomflow:declared:baseModel | A descendantOf relationship would need an SPDX element for a model SBOMFlow never observed. Minting one would put an unobserved artifact into the document, so the declaration stays a string. |
| Declared base model of an adapter | extension property sbomflow:declared:base_model | The same lineage gap as a model's declared base: descendantOf needs an SPDX element at both ends, and SBOMFlow never observed the base model, so the adapter's declaration stays a string. |
| Declared licence text (models, datasets and adapters) | extension property sbomflow:declared:licence | SPDX 3.0.1 carries a licence only as a hasDeclaredLicense relationship to a simplelicensing_LicenseExpression, whose value must be an SPDX licence expression. A declared licence is free text; parsing it into an expression would publish a string as a validated licence claim. |
governed_by_prompt and evaluates graph relationships | extension properties sbomflow:relationship:<predicate> | SPDX 3.0.1's relationship vocabulary has no equivalent for either. trained_on, derived_from, uses_model and invokes_service DO map (to trainedOn, descendantOf, dependsOn, dependsOn) and are emitted as real relationships. |
| Declared dataset provenance and sensitivity prose | extension properties sbomflow:declared:provenance, sbomflow:declared:sensitivity | dataset_dataCollectionProcess describes HOW data was collected and dataset_confidentialityLevel is a closed enum; neither is what these free-text declarations say, and mapping them would restate a declaration as something else. |
| Reconciliation summary (how many facts were comparable at all) | not carried — read ai-evidence.json | The denominator behind 'zero conflicts' is a property of the run, not of a package, and SPDX has no field for it. |
| Safety risk rating | deliberately never emitted | ai_safetyRiskAssessment has no 'no assertion' member and SBOMFlow performs no safety assessment, so the property is omitted rather than filled. |
| A DECLARED digest, which is an expectation and not a verification | extension property sbomflow:unverified_digest | SPDX names the property verifiedUsing — the element WAS verified by that method — so only a digest SBOMFlow computed from bytes it read may appear there. A manifest's expectedHash describes a file that was never opened. |
| Why the inventory holds what it holds (no scan ran / the scan recognised nothing / the record was unreadable) | SpdxDocument.comment | SPDX has no field for the provenance of an EMPTY collection, and those three causes are different facts of which only one is reassuring. |
| Whether the tree walk that produced the inventory finished | SpdxDocument.comment | A scan a resource budget cut short must not project into a document that looks complete; SPDX models no such completeness signal. |
| Scan warning codes raised while walking the tree | SpdxDocument.comment | A property of the RUN rather than of a package; SPDX has no field for it. |
| Whether a cross-document reference could be given an integrity method, and why not | SpdxDocument.comment | SPDX 3.0.1 closes ExternalMap to four properties, so the reason a verifiedUsing is absent cannot be recorded beside the reference it belongs to. Left unsaid, a missing digest reads as a reference nobody needed to check rather than one this run could not compute. |
As in the ML-BOM, any declared field this writer does not place in a native SPDX property is still emitted, as sbomflow:declared:<field>. Nothing is dropped — and a test asserts both directions, so an extension name with no row above fails the build just as a row with no emitted name does.
Provenance chips: where every row came from#
Every component, advisory finding, and evidence row — in the assessment report and in the reviewer console — carries exactly one small provenance chip. The chip's text names the source family the row's fact came from; its title carries the full provenance string.
| Chip | Appears on | Means |
|---|---|---|
observed | component, finding | SBOMFlow saw this in the scanned tree. |
imported-sbom | component, finding | A supplier document claimed it; SBOMFlow did not observe it. |
corroborated | component, finding | Seen locally and claimed by a supplier document. |
local-sample-feed, osv, nvd, … | finding | The advisory source the finding came from. |
file, config, test, … | evidence | The kind of source the evidence item was read from. |
N sources | evidence | Several sources back this row; the title lists them all. |
unrecorded | any row | No provenance was captured for this row. |
unrecorded is highlighted deliberately. A row whose provenance was never captured must say so: a blank cell would read as "observed", which is a claim SBOMFlow has not earned. Chips are real text rather than icons, so screen readers announce them and they survive printing.
Empty sections: what an absence means#
A section with no rows is a reported state, not a blank table. Every generated HTML surface — the assessment report, reviewer console, evidence bundle, drift report, and dashboard — renders the same empty-state block naming what is absent, what that absence does and does not establish, and the next command. Empty sections are never hidden: the section and its column headers stay visible, because the absence is itself part of the evidence.
The wording is deliberate where a reader is most likely to misread it:
- No advisory findings means no advisory matched the observed components. It is not proof that the release is free of vulnerabilities. It reflects the advisory sources this run used and the components it observed, so check
usable_intelligenceinvuln-source-health.jsonbefore reading it as an all-clear. That flag counts real sources only: a run whose findings came from the bundled synthetic sample feed reportsusable_intelligence: falsewithsynthetic_intelligence_used: true, and a run that consulted no source at all reportsno_source_configured: true. Zero findings means something different in each case. - No missing-evidence gaps means every requirement SBOMFlow can assess automatically has an observed signal. Manual-only requirements never create gaps, so this is not a statement of completeness or conformity.
- No approvals recorded means no human has signed the release off. An empty ledger blocks a release only when a policy enforces required roles or a quorum.
A count that cannot be read shows as —, never as a number#
The same rule applies one level down, to the numbers themselves. sbomflow dashboard can be pointed at a release-index.json it did not write, and the portfolio rollup reads each release's release-drift.json off disk. When a field that should hold a count holds something else — a string, a list, null, a fractional number, true, or a negative number — that value is not converted into a count:
- a dashboard cell renders
—, the same mark the page uses for a timestamp it does not have, and the two headline counts add a line to the page's Index warnings block naming the field and what was found there; - a
release-drift.jsonwhose summary counts cannot be read joinsproducts_without_driftasunreadable, which is listed and, as that block already states, never counted as zero changes.
true is rejected explicitly rather than read as the number one, and a negative count is rejected by value with the number quoted back, because the type is right and the number is still impossible. A genuine 0 is untouched: zero is a real observation and still renders as 0.
Release identity: what release_id can and cannot separate#
release_id can and cannot separate#product_id and release_id in release-record.json are derived, not assigned: they are stable hashes of what you declared — product.name, product.version, plus the product line, variant, channel and build id. Declare the same inputs and you get the same ids on every machine, which is what lets a store, a release index and a drift comparison line one release up against another.
The consequence runs the other way too. If you declare nothing, SBOMFlow derives Unnamed Product / 0.0.0 — the honest answer for a product nobody named — and hashes those. Every undeclared run therefore derives the same ids, so two unrelated code trees analysed that way are one release by identity, and nothing keyed on release_id can separate them. The same is true of a scaffolded sbomflow.yaml whose REPLACE-ME values have not been edited yet.
That situation is reported rather than left silent. A run whose identity rests on a placeholder records the product_identity_from_placeholder warning in scan-warnings.json, and a release-drift.json whose two sides carry one release_id records that fact in its own warnings array.
Read both as observations about what was declared, not as findings about the product:
- The warning does not mean a collision has happened — only that the ids carry nothing that would prevent one.
- The drift line does not mean the two outputs are the same build, and does not mean they differ. It records that
release_idcannot tell them apart. The deltas in that file are unaffected and remain exactly what the two outputs contain. - Neither is a failure. The run completes and exits as it otherwise would, which is deliberate: analysing a directory before writing any config is the first thing
quickstartdoes.
Declaring product.name and product.version gives the release an identity of its own. Declaring them never renumbers releases you already recorded — ids already written to a store or an evidence pack are read as recorded and are never recomputed.
Re-running into an existing output directory#
Re-running analyze or audit into a directory that already holds a run does not silently mix the two. Choose what should happen:
sbomflow analyze . --output ./evidence # overlay (default)
sbomflow analyze . --output ./evidence --on-existing-output clean # one run, nothing stale
sbomflow analyze . --output ./evidence --on-existing-output refuse # write nothing, exit 2| Policy | What it does |
|---|---|
overlay | Writes over what is there. The historical behaviour, and the default, so existing workflows are unchanged. |
clean | Removes the artifacts SBOMFlow itself generates, then writes. The directory ends up holding exactly one run. |
refuse | If a previous run is detected, writes nothing and exits 2. |
Two guarantees are worth stating plainly:
cleanonly ever removes names SBOMFlow generates. A file you put in the directory — a supplier SBOM, your notes — is never touched, so pointing--outputat the wrong directory cannot destroy your data.refusetriggers on a previous run, not on a non-empty directory. A directory holding unrelated files is not an SBOMFlow output, so refusing there would be noise.
Every artifact a run writes only conditionally is swept by a later run that does not write it, whichever policy you pick. That covers an opt-in flag that is off, a config key that is absent, and an artifact written only when there is something to state: a clean re-run with --emit-vex has no statement to make, so it writes no vex.json or csaf-vex.json, and removes the earlier run's copies rather than leaving two vulnerabilities the new release does not contain beside its pack. The same holds for advisory-reconciliation.json (written only when there are findings), decision-migration.json (only when decisions keyed by advisory were supplied), vuln-source-health.json, audit-log.jsonl and release-drift.html. The VEX family matters most: a stale vex.json left beside a fresh evidence pack would assert not_affected about findings it never saw, and — before this sweep covered it — was even sealed into the run and listed in START-HERE.md.
Only names this engine's own artifacts use are removed. A file of yours under any other name (my-vex.json, vex.json.orig, your notes) is left where it is; README.txt, the one sharing-pack name an operator may own, is never swept. The run says what it removed where it records what it wrote: the artifacts_written event in audit-log.jsonl carries stale_artifacts_removed, the sorted names of the earlier run's artifacts this run removed and did not write again. The key is absent when nothing was removed, so a first run, or a re-run with the same inputs, logs exactly what it always did. SBOMFlow's own tests derive the set of conditional artifacts from the writer itself, so a new one added without a sweep fails there by name.
evidence-bundle.zip is swept the same way. It archives the directory as it stood when it was composed, so a copy left by an earlier run describes that run's pack, not this one. Every analyze or audit removes it before writing, and audit --zip then composes a fresh one from this run's files. An overlay run without --zip therefore ends with no ZIP rather than a stale one, and START-HERE.md never names a ZIP that does not hold the pack beside it. Only that name is removed: an archive of your own under another name is left alone.
sbomflow bundle applies the same rule after it re-seals a directory. Recording a decision (review, approve) rewrites members the ZIP archived, so after audit --zip, a decision and bundle without --zip, the old evidence-bundle.zip no longer matches the re-sealed bundle. bundle removes it and prints what it removed, how many names it disagreed on, and the bundle <dir> --zip <dir>/evidence-bundle.zip command that composes a current one. A ZIP the re-seal left accurate is kept, and an archive under any other name is never touched.
compare-releases follows the same rule for the optional files it writes into its own --output: release-drift.html (--html) and engineering-release-notes.cdx.json (--release-notes-format cyclonedx or both). A re-run into the same directory that does not ask for one removes the earlier run's copy before it writes anything, so it never sits beside a release-drift.json it does not describe, and prints one line per file it removed, beside the lines naming what it wrote:
Removed release-drift.html left by an earlier run (this run does not write it): OUT/release-drift.htmlNothing is printed when nothing was removed. Only those two names are ever removed. The file you name with --release-notes FILE is yours and is never removed — not on a later run that omits the flag, and not on a run where you point it at one of those two names. Your own files under any other name stay where they are, and so does .sbomflow-output, which marks the directory as SBOMFlow output. A file you created yourself under one of those two exact names is treated as the earlier run's, as every sweep here treats a name SBOMFlow generates. If the removal is refused (a read-only directory), the run stops with E004 before writing anything. If the --release-notes path cannot be resolved, nothing is removed — it cannot be shown to differ from those names — and a note: on stderr names each earlier optional output left in place.
compare-releases never writes into an analyze or audit output directory. An --output that holds a run-completion.json is another run's sealed record: writing there would rewrite that run's release-drift.json and remove the release-drift.html its seal lists, and validate would then fail an honest pack. The command refuses with E031, names the seal, and reads, writes and removes nothing; give it a directory of its own. A seal it cannot examine (a dangling link, a permission it does not hold) refuses the same way, because only proof of absence shows the directory is unsealed. The analyze writer's own sweep is held to the same line from the other side: every name it removes is checked against what the run has already written before anything is unlinked, so a sweep can never remove a file the run's seal lists.
What the run seal says was written after it#
run-completion.json is written at the end of the write phase, and a run still writes a few files after it. The seal cannot bind their bytes (the bundle ZIP archives the seal itself), so it names them in run_completeness.written_after_seal and lists them in requested_artifacts:
START-HERE.mdon every run;evidence-bundle.ziponaudit --zip;release-drift.htmlonanalyze --drift-htmlwhen release drift is produced.
validate checks that each named file is present when it reads the directory. A run killed after the seal and before one of them is therefore reported as requested_missing with the file's name, not as complete. A seal with no written_after_seal key predates the declaration, and validate treats it as unknown rather than as "nothing owed". Two files are neither bound nor listed: the .sbomflow-output marker, written before the scan starts, and run-receipt.jsonl, which exists from the start of the run and records its own run_complete event last. On audit --zip that event is written after the ZIP.
When validate compares evidence-bundle.zip with the directory, each missing-entries or extra-entries line states how many names it covers, for example (13 missing, first 5 listed), and lists up to five of them.
When the run seal itself is missing#
Every run from SBOMFlow 0.5.0 on writes run-completion.json, so its absence from a directory holding every required artifact means one of two things: the run stopped after its required artifacts were written, or the file was removed. validate decides which reading applies from the pack's own recorded producer, evidence-pack.json's tool_version:
Recorded tool_version | Classification | validate | doctor |
|---|---|---|---|
0.5.0 or later (compared as numbers) | token_missing_for_producer | exit 4, naming the missing file, the recorded version, both explanations and the remedy | a recovery row with the same sentence |
| older, or none it can read | complete, flagged legacy | exit 0, with a note that the seal was not checked and why | nothing |
The remedy is to re-run analyze or audit; never recreate the token by hand. A present run-completion.json is always verified, whatever version the pack records. store add admits a token_missing_for_producer directory tagged rejected_by_validate, and supplier-package emit refuses it as unsealed.
How to verify an output directory#
sbomflow validate ./evidenceValidation checks structure, cross-artifact consistency (counts, references, finding keys), hashes, the audit-log hash chain, and draft watermarks. A tampered or partially copied directory fails with exit code 4. Optional extras add official JSON-Schema validation when installed; without them the structural baseline still runs and says exactly what was skipped.
Note what that means in practice: the official-schema check is the one that only runs with an extra installed, so a default install cannot tell you whether an emitted SBOM would satisfy a consumer's own SPDX or CycloneDX tooling. Install the extra's package into the same environment (python -m pip install jsonschema) before shipping an SBOM to a customer. There is no package-index entry for v0.5.5, so pip install 'sbomflow[validate]' is not a route to it.
The two SBOM exports are also round-trip stable: re-importing an exported cyclonedx-sbom.json or spdx-sbom.json yields the identical component set (name, version, and purl), so downstream tooling never sees an invented or dropped component. This is regression-tested offline, alongside the official CycloneDX 1.6 / SPDX 2.3 schema-conformance checks above.
Report theme (opt-in dark)#
Every generated HTML surface is light by default. Reviewers who live in dark editors can generate the dark palette instead:
sbomflow analyze . --output evidence --report-theme dark--report-theme {light,dark} is accepted by analyze, audit, compare-releases, dashboard, and review-view, and it reaches every surface that command writes — the assessment report, reviewer console, evidence bundle, sharing pack, drift report, and dashboard.
Three deliberate properties:
- Chosen when the file is written, not when it is read. There is no
prefers-color-schemeswitch and no JavaScript, so a portable evidence file looks the same on every machine that opens it, and the palette is reproducible from the command that produced it. - The default is unchanged. A light document emits no
data-themeattribute, so the dark rules in the stylesheet stay inert. - Paper is always light. Printing a dark document forces the light palette, whichever theme was generated — a dark page on white paper burns ink and reads badly.
Contrast#
Every foreground/background pair the stylesheet renders together meets WCAG 2.1 AA (4.5:1) in the dark palette. The ratios below are computed from the stylesheet itself by an automated test, so they cannot drift from what ships.
| Pair | Dark | Light |
|---|---|---|
body text on page (ink/paper) | 15.4:1 | 18.4:1 |
body text on panel (ink/panel) | 14.1:1 | 17.2:1 |
secondary text (slate/paper) | 7.7:1 | 7.0:1 |
de-emphasised text (faint/paper) | 5.5:1 | 3.3:1 |
pass badge (ok-fg/ok-bg) | 9.1:1 | 6.9:1 |
warning badge (warn-fg/warn-bg) | 8.9:1 | 6.6:1 |
danger badge (danger-fg/danger-bg) | 8.5:1 | 7.4:1 |
Note
The light palette's de-emphasised text (--faint, used by .muted and.none) sits at 3.3:1, below AA for text that small. That is pre-existing and
is deliberately not changed here: repainting a light token would alter every
report already generated, and this change's contract is that the default is
untouched. It is pinned as a known exception by the same automated test, so it
can neither grow nor be silently forgotten. The dark palette meets AA for the
same pair.
The SBOM's own completeness statement (compositions in cyclonedx-sbom.json)#
compositions in cyclonedx-sbom.json)#cyclonedx-sbom.json carries one CycloneDX compositions entry over the product component, so a recipient can tell whether "not listed" may mean "not affected":
unknownwhen this run recorded anything that bounds what it saw — a scan warning the run seal counts as partial, an observation coverage report markedindeterminate, or an input recognised and not parsed, failed to parse, or not read (therecognised_unsupported,parse_failed,refused_resource_limit,refused_path_safetyandrefused_symlinkdispositions). Each is listed inscan-warnings.json/observation-coverage.json.not_specifiedotherwise.
It is never complete: a file scan cannot know about statically linked or vendored code it did not recognise, so SBOMFlow does not state that its own list is complete. Nor incomplete, which asserts that more components exist. A supplier package cut with supplier-package emit states the same value (unknown for a partial run), decided by the same rule. SPDX 2.3 and SPDX 3.0.1 have no completeness construct, so those documents are unchanged.
SBOM revision identity (serialNumber and version in cyclonedx-sbom.json)#
serialNumber and version in cyclonedx-sbom.json)#The serialNumber is deterministic: a UUIDv5 over the product name and version and the component set, so the same inputs give the same SBOM. Every CycloneDX document SBOMFlow writes (the SBOM, VEX, VDR, ML-BOM and release notes) derives its own serial the same way, and a VEX names the SBOM beside it by a BOM-Link (urn:cdx:<serial>/<version>) that carries the SBOM's serial.
The UUID version is 5, on purpose. The CycloneDX 1.4 to 1.7 schemas accept any RFC 4122 UUID in serialNumber, and version 5 is one. Two published texts say otherwise:
- CycloneDX's description says every BOM SHOULD have a unique serial "even if the contents of the BOM have not changed".
- The IANA registration of the
urn:cdxnamespace says in its assignment text that BOM serial numbers are version 4 UUIDs. Its syntax admits any RFC 9562 UUID.
SBOMFlow departs from both deliberately: a random serial would make two runs over the same inputs produce different bytes, and byte-identical output is what lets a reviewer diff and reproduce an SBOM. A tool that insists on version 4 will reject these serials; nothing in SBOMFlow presents them as version 4. A corrected SBOM for a release you already shipped would therefore get a new serial and version: 1, and a recipient holding both could not tell a correction from an unrelated BOM. CycloneDX's rule for a modified BOM is to keep the serial and increment version, and SBOMFlow follows it when — and only when — you say which output it revises:
analyze --previous-output <dir>(orprevious_output_path) whosecyclonedx-sbom.jsonnames the same product name and version as this run, with differentcomponents/dependencies/compositions: the new SBOM keeps that document'sserialNumberand takes itsversion+ 1, andrelease-record.jsongainssbom_revisionnaming the superseded serial, version and SHA-256 beside the new document's SHA-256.- The same, with identical content: the previous serial and version are kept, so with the same
--as-ofthe SBOM is byte-identical to the one it restates, and nosbom_revisionis written. - A different product version (the ordinary release-to-release comparison), or no previous output: unchanged — a fresh deterministic serial and
version: 1. - A previous SBOM that cannot be read, is not CycloneDX, or has no usable serial or version changes nothing, and a
previous_output_unavailablewarning says why.
sbom_revision says which document this one supersedes; it does not say the revision is correct or approved. The --emit-mlbom BOM-Link names the revised version. SPDX documents are unchanged.
Determinism#
With a fixed --as-of timestamp, repeat runs over the same inputs are byte-identical — including the bundle ZIP. Without --as-of, only documented runtime timestamps (and fields derived from them, such as the audit-log hash chain) differ; components, edges, gaps, warnings, and purls do not. That is what makes the artifacts diff-able and reviewable.
--emit-metrics is the one exception under --as-of: metrics.json records wall-clock phase durations and a peak memory figure, so its bytes differ between two otherwise identical runs, and so do the row that run-completion.json keeps for it and a ZIP that archives both. The run id does not move. run-completion.json lists every produced artifact in produced_artifacts with its size and SHA-256, and run_id is a digest over those rows — except that metrics.json enters it by name only, the same way the bundle manifest leaves metrics.json out by name. Two identical runs with metrics on therefore share a run_id. The metrics.json row still carries its size and digest, and validate still checks them: leaving the file out of the identity does not leave it out of the seal. A run without --emit-metrics computes exactly the run id it always did.
For the field-level schema of every artifact — filename, schema id, and top-level keys — see the artifact schema reference.
Next: read your first bundle · warning codes · exit codes