Keeping runtime and test evidence with a release
Every release is justified in part by evidence SBOMFlow cannot read: a trace from a bench run, a core dump from a unit in the field, a fuzzing campaign's report, a hardware-in-the-loop log, a CI test report, a third-party laboratory's evaluation. Each was produced by a tool or an organisation you chose, and examined by an engineer who drew a conclusion from it. Months or years later someone asks:
Which runtime or test evidence did we examine for this release, what did the engineer conclude, is it still the same file, and where is the original?
That is an engineering question, and this is an engineering record: byte identity and named human decisions about evidence a tool cannot interpret for you. Whoever asks later reads the same record — a customer questionnaire, a supplier handover, an internal handover years after the people have moved on, a safety or conformance file, an integrator, or a requirement regime your product falls under. None of them is the reason the record exists, and it needs none of them: you can keep and verify one with no requirement regime selected at all.
This page walks the workflow end to end, with one worked example and four shorter ones, and is the reference for every option and state. For the short path — a custody set kept standalone and then joined to a run, with evidence from two tools, two files of one name, an image under build/ and an open-format capture — start with Keeping runtime evidence in custody. Every command is offline.
Three rules hold throughout:
- SBOMFlow keeps custody; it does not interpret. It records a file's name, size and SHA-256, what a person stated about it, and a named reviewer's conclusion. It never parses the file, never runs the tool that produced it, and never infers what the file shows.
- Your conclusion is the only conclusion. A preserved file is unreviewed until a named person records a decision, with a note saying what it covers and what it does not.
- None of it moves the release gate. Preserved artifacts never enter the gate, the evidence pack or the digest an approval binds to, and they are never mapped to a regulatory requirement. They are context a reviewer can find and verify, not a verdict.
The worked example: a bench trace and a field event#
The example uses file types from Percepio's tools because they show both shapes of the problem well: Percepio's public demonstration material for Detect shows an alert carrying a cc_coredump.dmp core dump and a dfm_trace.psfs trace (percepio/demos). SBOMFlow does not connect to either tool, and nothing on this page depends on what those files contain: any file from any tool follows the same path.
The files used here are the synthetic stand-ins shipped with SBOMFlow's source in examples/preserved-artifact-demo/, each labelled synthetic in its own first bytes. They are not real captures.
Two pieces of evidence belong to release 2.4.1 of a sensor node:
- a trace an engineer captured on the bench, from boot to idle on a cold start;
- one field event, which produced a trace and a core dump.
1. Preserve each file with its own record#
sbomflow ingest-preserved-artifact captures/boot-to-idle.psf \
--release-id 2.4.1 --scenario "boot-to-idle, 30s, cold start" \
--tool-name "Percepio Tracealyzer" --tool-version 4.11 \
--as-of 2026-09-10 --operator fw-bench \
--output preserved/boot-to-idle.preserved-artifact.json
sbomflow ingest-preserved-artifact captures/field-crash.psfs \
--release-id 2.4.1 --scenario "field event 1, trace" \
--tool-name "Percepio Detect" --as-of 2026-09-12 --operator field-support \
--notes "SYNTHETIC demonstration capture. One alert carried this trace and a core dump; the core dump has its own record." \
--output preserved/field-crash.preserved-artifact.json
sbomflow ingest-preserved-artifact captures/cc_coredump.dmp \
--release-id 2.4.1 --scenario "field event 1, core dump" \
--tool-name "Percepio Detect" --as-of 2026-09-12 --operator field-support \
--artifact-kind core_dump \
--notes "SYNTHETIC demonstration capture. One alert carried this core dump and a trace; the trace has its own record." \
--output preserved/field-crash-coredump.preserved-artifact.jsonEach record holds the file's digest and every value you typed, marked as a statement a person made — user_supplied — never as a measurement. Nothing is defaulted: an unstated tool version is listed as missing. .dmp is recorded as core_dump from the extension alone.
Where a reader is registered for the file's format, the command also prints what the header declares, read exactly as a release run reads it (see What a file declares about itself). That is shown on the terminal only: the record, and its --json document, are byte-identical with and without it, and the release run reads the header again. A closed format says it is closed, and a format no reader knows prints nothing more.
Say which image the capture ran on, at capture time. The strongest statement a capture can carry is "this trace was taken on the image with digest X" — and the person who knows it is the one at the bench, not the one who assembles the release later. Four options record it, on ingest-preserved-artifact and on the artifact form of custody add:
sbomflow ingest-preserved-artifact captures/boot-to-idle.psf \
--release-id 2.4.1 --scenario "boot-to-idle, 30s, cold start" \
--firmware-image firmware/zephyr.elf \
--firmware-build-id a1b2c3d4e5f60718 \
--firmware-version 2.4.1 \
--output preserved/boot-on-image.preserved-artifact.json| Option | What is recorded | What it proves | What it does not prove |
|---|---|---|---|
--firmware-image PATH | the file's SHA-256 and its basename only | a file with those bytes was hashed here | that the capture ran on it |
--firmware-sha256 HEX | the digest you state | that you state that digest | the same |
--firmware-build-id HEX | the GNU build id you state | that you state that build id | a build id is not a checksum — it survives stripping |
--firmware-version TEXT | the version string you state | that a device or a build reported it | it is never a stand-in for a digest |
The image is hashed and never parsed, a symlink or a FIFO in its place is refused unread, and no host path is recorded. The artifact itself follows the same rule: a symlink given as the artifact is refused with E019 and nothing is written, exactly as custody add refuses it — give the file's own path. Giving both --firmware-image and --firmware-sha256 with different values is refused with both values named and nothing written: SBOMFlow does not choose between two things you stated.
The format hint is the file name, and only the file name. The record's declared_format says what the filename ends in, never what the bytes are, which tool wrote them or how the capture was taken; declared_by: filename_extension says so in the record itself. The hints this build writes:
| Extension | declared_format |
|---|---|
.psf | psf_extension_trace |
.psfs | psfs_extension_trace |
.trc, .svdat | trace_recording |
.pcap, .pcapng | network_capture |
.sarif | static_analysis_results |
.mf4, .mdf | measurement_log |
.blf, .asc | bus_log |
.dmp, .core | core_dump |
.gcda, .gcno, .profraw | coverage_data |
.zephyr-coredump, .zephyr-coredump-log, .crashcatcher, .memfault-coredump, .mdmp | core_dump |
.ctf, .ctf-metadata, .ctf2, .ctf2-metadata | trace_metadata |
.zephyr-ctf | trace_recording |
.perf-data, .perf.data | performance_recording |
.fuzzer-stats, .fuzzer_stats | fuzzing_statistics |
.bugreport-zip | archive |
.ihex, .ihx, .hex, .srec, .s19, .s28, .s37, .mot, .uf2 | firmware_load_image |
.dfm-alert, .dfm-alert-log | device_alert |
.ulg, .mcap | measurement_log |
.perfetto-trace, .pftrace | trace_recording |
.csv | tabular_export |
.xml, .json, .jsonl, .yaml, .yml | structured_export |
.txt, .log, .md, .html, .tap | text_export |
.pdf | document |
.zip, .tar, .tar.gz, .tgz, .tar.xz, .gz, .xz, .7z, .zst | archive |
.png, .jpg, .jpeg | image |
Every extension in the second group is one a declared-identity reader selects (see Supported inputs): the hint list is derived from the reader registry, so a reader added later brings its names' hints with it. A few whole file names a reader selects carry no extension that says anything, so the whole name is the hint, and the record says declared_by: filename:
| File name | declared_format |
|---|---|
core, core.<digits>, vmcore, coredump.bin | core_dump |
perf.data | performance_recording |
fuzzer_stats | fuzzing_statistics |
trace.dat | trace_recording |
A whole name is matched as the whole name and only fills a gap: a name whose extension already has a hint keeps that hint and declared_by: filename_extension, so no record written before these names were added disagrees with a new one.
Any other extension — including .elf, .so, .axf and .out, which name a firmware image rather than a capture, .info, which names too many unrelated files, .trx, which names a trace buffer, a test-results report and a router firmware container alike, and a file called metadata, for the same reason — is recorded as unrecognised with an info-level warning, and the bytes are preserved all the same.
An archive hint is about the filename, not about the members. It says the name ends in an archive extension. SBOMFlow never opens, lists or decompresses a container, so the members inside it are not individually preserved and carry no digest of their own — only the container's bytes are covered by the record's sha256. If a reviewer needs to reason about one file inside an archive, record that file as its own artifact. A multi-part extension is read longest first, so bench.tar.gz takes the .tar.gz hint rather than the .gz one; only the extension the name actually ends in is read, which makes x.tar.gz.txt a text_export.
The kind is what you say it is. The format hint above names an extension. What the evidence is — a trace, a core dump, a laboratory report, a record a scheme issued — is a separate field, artifact_kind, and it is only ever recorded because you stated it with --artifact-kind. It is never inferred from the extension, never from the filename, and never from the bytes: a file called scheme_certificate.pdf records no kind at all unless you state one, because the only thing that can tell a laboratory report from a penetration-test report is the person who commissioned it.
The third command above states one: the core dump is recorded as core_dump, and the two traces deliberately state nothing, which is what a record written before this field existed looks like.
The vocabulary is closed, so the counts mean something. A value outside it is refused with E019, which lists the whole vocabulary, and so is an empty --artifact-kind: omit the option to state no kind rather than passing a blank, which would leave no trace of the mistake:
artifact_kind | What it names |
|---|---|
runtime_trace | a recording of execution over time, as the capture tool wrote it |
core_dump | a dump of process or device memory taken at a fault |
crash_report | a report a device, operating system or crash service produced about a fault |
device_log | log output a device emitted |
bench_log | log output a test bench or rig recorded around a device |
network_capture | recorded network or bus traffic |
test_report | a human-readable report about a test run |
test_results | machine-readable results of a test run |
coverage_report | a code-coverage report, or the raw data one is built from |
fuzzing_campaign | the record of a fuzzing run: its corpus, statistics or log |
fuzzing_reproducer | one input that reproduces a fault a fuzzer found |
static_analysis_report | output of a static analysis tool |
penetration_test_report | a report of a security test people performed |
laboratory_test_report | a report a testing laboratory issued about a product |
certification_or_approval_record | a document a scheme or body issued about a product or process; SBOMFlow did not verify it |
conformance_test_report | output of a scheme's conformance or interoperability test tool |
vulnerability_handling_record | a record of how one reported vulnerability was handled |
signing_record | a record produced when an artifact was signed |
provisioning_record | a record produced when a device was provisioned |
device_state_statement | a statement a device or its security module produced about its own state or configuration, such as a measured-boot log or a quote it emitted; SBOMFlow did not parse, verify or appraise it. Not device_log (the device's ordinary log output), provisioning_record (made when the device was provisioned) or crash_report (about a fault) |
timestamp_or_transparency_receipt | a receipt a timestamping or transparency service returned |
other | evidence this vocabulary does not name; say what it is in --notes |
A device's statement about itself is kept, not read. device_state_statement is for a file a device or its security module produced about its own state or configuration, such as a measured-boot log or a quote it emitted. SBOMFlow digests the bytes and records the kind you stated; it does not parse the file, check a signature on it, compare a measurement with any reference value, or say what the statement shows. A record written earlier that filed such a file under other is never rewritten or reclassified. A new record stating the new kind for the same file sits beside the old one, and the row then counts under conflicting, with neither statement preferred.
The two vocabularies overlap by name, and that means nothing. core_dump and network_capture are both a declared_format value and an artifact_kind value, and coverage_data / static_analysis_results are near-twins of coverage_report / static_analysis_report. A stated kind that happens to equal the format hint is a coincidence, not a confirmation: one is an extension, the other is a person's statement, and nothing compares them. The record's own rendering labels both lines and says so when the words match. Join on the field name, never on the value.
A run counts the kinds its declared rows state, in summary.artifact_kind in preserved-artifacts.json, one bucket per row so the counts sum to rows: not_stated for a row whose records state no kind, and conflicting when two records for the same artifact state two different kinds — neither is preferred. A row where one of its records states a kind and another states none is counted under that one stated kind: one statement is not a disagreement. The row's own conflicting_fields is the wider question and still names artifact_kind in that case, so the asymmetry between its records stays visible. START-HERE.md restates those counts and nothing more. The whole block is absent from a run in which nobody stated a kind, which is also why a record with no kind is byte-identical to one written before the field existed: adopting the field changes no earlier record and no earlier run.
Storing a kind is not a claim about what the evidence shows. A certification_or_approval_record is a document whose bytes were digested: SBOMFlow did not verify it, does not know whether it is current, and says nothing about whether it covers the release you are assembling. That is a human's reading, and the review block is where it is recorded.
The field event gets two records on purpose. Two files have two digests, and a reviewer's conclusion about the core dump must not quietly cover a trace nobody opened.
Size limit. One artifact may be up to 64 GiB (68719476736 bytes). The file is hashed as a stream, so memory use does not grow with it; at the limit expect tens of seconds. The limit exists to catch a wrong path — a disk image or a whole archive — and a larger file is refused with E019 and nothing recorded. If the file really is the capture, split it into files under the limit and record each, or report the size so the limit can be reconsidered. The same limit applies when analyze or audit re-hashes a declared original or firmware image, which then reads unreadable with the reason artifact_oversize.
Give every file its own --output. Sending a different file to a destination that already holds a record is refused with E019, and nothing is written, so a second capture can never silently replace the first.
2. Declare the records for the release#
In sbomflow.yaml:
preserved_artifacts:
- record: preserved/boot-to-idle.preserved-artifact.json
original: captures/boot-to-idle.psf
firmware_image: firmware/zephyr.elf
- record: preserved/field-crash.preserved-artifact.json
original: captures/field-crash.psfs
firmware_image: firmware/zephyr.elf
- preserved/field-crash-coredump.preserved-artifact.jsonA record is read only when it is declared; SBOMFlow never searches a directory for records. The full form names the original, so the run can re-hash it, and the firmware image the capture was stated to be taken against. The short form names the record alone — useful when the original lives in a separate device-data store rather than beside the release.
sbomflow init writes this key into a new sbomflow.yaml commented out, with placeholders for the record form and the custody-set form, so a fresh scaffold declares nothing until you uncomment it and name your own files.
Keep the firmware image in a directory the scan hashes. build/ and dist/ are pruned and never hashed, so an image there is reported not_in_scanned_tree: a statement about the scan, not a finding that the image is foreign — unless the run is given build provenance naming it (--build-provenance, below).
3. Run the audit#
sbomflow audit . --output evidenceevidence/preserved-artifacts.json has one row per artifact, with three checks, each saying exactly what it proves:
| Check | Here | What it proves, and what it does not |
|---|---|---|
original_verification | verified for both traces; not_supplied for the core dump | the supplied original has the recorded digest and size — byte identity, not who produced it. mismatch, missing and unreadable are the other outcomes |
firmware_image | in_release for both traces | the stated image is byte-identical to a file this release scanned. It does not show the capture was taken from that image |
release_consistency | consistent for all three | the release you stated matches the run's own release identity: two statements agree, neither is a measurement |
The same rows are the "Preserved external artifacts (custody only)" table in evidence/assessment-report.html and evidence/evidence-bundle.html, for a reader who will not open the JSON. It has six columns. Artifact holds the name and the full SHA-256. Stated by the operator holds the kind, scenario, tool and capture session, marked "not stated" where you gave none; these values were not checked. Declared by the file holds the reader and its basis, and up to four header values in plain words, such as RTOS: Zephyr or Firmware version (declared): 1.4.2; the rest are counted and left in the JSON. Run checks holds the three checks above. Decision holds the status and who recorded it, and Reviewer note holds the note verbatim. A closed format reads "nothing to read", with the reason. Each check state shown is explained once under the table. The table adds no artifact and no key, and none of it reaches a gate.
An image under build/, named by build provenance. When the run is given the build provenance statement for the release (--build-provenance, optionally with --build-provenance-public-key), a declared image that the scan did not hash but whose SHA-256 is a subject of that statement reads named_by_build_provenance. Its meaning quotes the provenance signature state verbatim (also in build_provenance_signature_state) and says what it is: a statement by the build system, not a file this run scanned, and not a sign the artifact was captured from it. It is corroborated — an independently_corroborated association — only when that state is verified, which needs a DSSE envelope whose signature verified against the public key you supplied. A plain statement, a skipped check or a failed signature still shows the state, with no association. When a digest is both a scanned file and a provenance subject, in_release wins: the file this run hashed is the stronger observation. The join only reads the provenance record already in the evidence pack; the pack and the approval digest do not change.
What an MCUboot image states about its own hash. A device manager reports the image hash an MCUboot image carries in its own hash TLV — not the file's SHA-256. When the declared image is an MCUboot image, its declaration carries image_inner_hash: alg (sha256, sha384 or sha512) and value are the hash the image states, and integrity says whether recomputing it over the image's signed region agrees (matched), does not (mismatch, which also warns preserved_artifact_image_hash_mismatch), the image carries no hash TLV (no_digest), or the file is shorter than the layout its header declares, so no hash was read at all (truncated). No key is used and no signature is checked. A record can state the hash a device reported with --firmware-inner-hash ALG:HEX, and capture_firmware compares it with the image's own; a firmware version string is never used in its place.
What the image declares. Beside that check, firmware_image.declared_by_file lists, for each declaration, the identifiers the image file declares about itself, read by the same ELF reader elf-evidence uses:
| Field | What it is |
|---|---|
state | read; not_elf (the file is not an ELF image, as in this example, whose image is a stand-in); unreadable with a reason and its reason_meaning; or not_supplied |
gnu_build_id | the GNU build-id note (ld --build-id), in lowercase hex — absent when a fully read image carries none |
elf_package_metadata | the name, version, type, architecture, osCpe and appCpe of a UAPI.8 package note, verbatim; other keys are only counted, in elf_package_metadata_other_key_count |
machine | the ELF machine the image declares |
A GNU build-id is what crash-reporting services, debuginfod servers and symbol stores key a build by, so you can match an id a device reported against the release by value. It is not a checksum — it survives stripping, and two different files can carry the same one — and nothing in this block says a capture came from the image. When any part of the image could not be read (a note larger than the note cap, a package note that is not valid, NUL-terminated JSON or repeats a key, an image over the reader's byte cap), the state is unreadable and no identifier is shown at all, so a missing build-id is never read as "the image declares none". A package note is also withheld when a value carries a control character (a tab inside a value included), a format character such as a bidirectional control, or a lone surrogate; refused_character names the first one, for example U+202E, a format character (such as a bidirectional control), and a note walk stops after 10,000 notes in one image (repeated note headers over the same bytes are walked once). An image that does not start with the ELF magic number reads not_elf from its first four bytes, however large it is. The block never enters the gate, the approval digest or the record's associations.
Every row is unreviewed. START-HERE counts the preserved artifacts awaiting a decision in their own unit, and the assessment report, the evidence bundle and the reviewer console (sbomflow review-view evidence) show the rows. The release gate is exactly what it would be with nothing declared.
Only the small custody records are copied into the run, under preserved-artifacts/. The original files are never copied into the run, the bundle, the evidence store or any sharing pack: a trace or a core dump can hold memory contents, device identifiers and source paths, and SBOMFlow does not keep it. Keeping the original is yours.
4. Examine the files, then record your conclusion#
Open each file in the tool that produced it and decide what it shows. Then:
sbomflow next-actions evidencelists each unreviewed artifact with the exact command and the file each key names. To see everything the run recorded about one of them before you decide — its record, the original's check, the identity the record states, the firmware checks, any decision already recorded and the files that name it:
sbomflow explain evidence --preserved-artifact <boot-trace-digest-prefix>A full review key or a unique digest prefix of at least 12 hex characters is accepted. Then:
sbomflow review evidence \
--accept preserved-artifact:sha256:<boot-trace-digest> \
--reviewer fw-lead \
--note "No deadline misses in the boot window; covers cold start only"records it. --reject and --needs-more-evidence take the same form. For a preserved artifact, --reviewer and a non-empty --note are required for every status; without either, the decision is refused with E021 and nothing is written. Write the note as the conclusion and its limits — here, that the trace covers a cold start and nothing else.
The key is the file's digest, so the decision is bound to those bytes. A different file, even under the same name, is a different key and is unreviewed.
5. Re-run, validate and verify#
sbomflow audit . --output evidence --reviews evidence/reviews.json
sbomflow validate evidence
sbomflow verify-bundle evidence --layered --require-layers contentanalyze and audit apply the reviews file they are given — --reviews, or reviews: in sbomflow.yaml. A re-run without either reads no decision at all, even though evidence/reviews.json exists, and every row stays unreviewed; nothing was lost, the run was simply not given the file. The boot trace's row now reads accepted, applied: true, with the reviewer and the note beside it. validate checks the index against its copies and the release record, and the bundle's content layer re-hashes every custody copy it lists.
If a supplied original later stops matching its record — one changed byte is enough — the row reads mismatch and the decision is shown but not applied, with the reason. A conclusion about other bytes never carries over silently. A decision whose artifact this run no longer declares is listed under undeclared_decisions and never applied, and a decision from a previous release is never carried into this one.
6. Compare releases, and store the record#
sbomflow compare-releases --previous <earlier-evidence> \
--current evidence --output driftdrift/preserved-artifact-changes.json lists the artifacts added, removed, re-decided or re-checked since the previous release. The command's own output prints one line per artifact added, removed or re-decided — its name, its review status and the first 12 hex digits of its SHA-256, since two releases often hold two different files under one name — and counts the other changes. Compared with an output written before this record existed, it says not compared, with the reason — never "no change".
A new capture whose record states the same scenario text as a capture of the previous release is also listed under stated_scenario_pairs, and printed as a "same stated scenario" line naming both files, their review status and what happened to the decision (decided, decision not carried or no decision on either side). The pairing key is the stated scenario (--scenario) compared as an exact string; the file name and the content are never used. A pair means two records state the same scenario text and nothing more: the bytes differ, so it is not the same capture, and the previous decision is shown as context only, never carried. Pairs are not counted among the changes. The evidence plan's entries are not used as a pairing key: each one matches rows by kind and capture session, so one entry can match many captures of different scenarios.
Two statements about the image, and neither one wins. The run now carries two firmware checks side by side, because they are claims by two different people. run_checks.firmware_image is about the image the configuration declares; run_checks.capture_firmware is about the image the capture record names. The capture check states what it found:
capture_firmware.state | What it means |
|---|---|
stated_image_in_release | the stated digest is byte-identical to a file this run scanned. It still does not show the capture ran on it, and the block says so. |
stated_image_named_by_build_provenance | nothing this run scanned carries that digest, but the build provenance supplied to the run names it. A statement by the build system; corroborated only when the provenance signature state is verified, and quoted verbatim either way. |
stated_image_not_in_scanned_tree | nothing this run hashed carries that digest. Not a finding that the image is wrong: the scan prunes generated directories, and the meaning names them. |
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. |
not_stated | the record says nothing about the image. The configuration's entry was written by somebody else. |
comparisons[] then compares the stated digest with the declared image's, the stated build id with the one that image declares about itself, the stated version with this run's release identity, and a stated MCUboot image hash (--firmware-inner-hash) with the one the declared image states in its hash TLV. Each is agrees, differs or not_comparable with the reason that blocked it — the release declares no image, the declared image is missing, it is there and unreadable, it declares no build id, its identity could not be read, this run states no release identity, it is not an MCUboot image, it carries no image hash, or it states one in another algorithm, or you stated nothing to compare. Thirteen different reasons, because each one sends you somewhere different.
A differs warns preserved_artifact_capture_firmware_differs, and that warning claims exactly one thing: two statements about the same named thing disagree. It is not evidence of tampering — a rebuild, a stale sbomflow.yaml entry, a capture taken on another variant and a mistyped value all produce it. It is not a finding about the preserved artifact's bytes. It suspends no review decision, and it reaches no release gate, evidence item, approval digest, VEX status or CRA mapping. A human reconciles the two statements; SBOMFlow does not pick a winner. A custody set performs no corroboration at all, because a set has no scanned tree: the statements travel in the record and are corroborated only when the set is joined to a run.
A run given --previous-output also shows, on each row whose artifact SHA-256 the previous index already held, what that release decided about those exact bytes: prior_decision_context, beside the row's own review and never inside it. It is context and only context — it is not applied, it changes no review status, no gate, no approval digest, no VEX status and no CRA mapping, and this build has no automatic carry-forward, not even for identical bytes. Its own named state tells a decision (decision_recorded) from disagreeing decisions that release applied none of (conflicting_decisions_recorded) from bytes it held and never decided (no_decision_recorded); rows are matched on the digest ALONE, so a changed statement about the bytes cannot hide a real prior decision, and where the two releases described them differently identity.differing_statements names both statements. prior_decision_context_meaning states the six limits once per index. The reviewer runbook § 10 is the workflow: read it, decide again, and record your own conclusion — next-actions offers the command with a placeholder note and never the previous reviewer's words.
Every row carrying one recorded decision also carries, inside its review block, reviewer_identity_context: the decision's reviewer identifier compared, as exact text, with the operator (--operator) and the test performer (--performer) the capture's records state. Its state is same_identifier (with named_as listing operator, performer or both), different_identifier, or not_comparable with a reason (records_state_no_operator_or_performer, or decision_names_no_reviewer). It answers "did someone other than the person who captured this accept it?" only as far as identifiers can: equal identifiers do not prove one person reviewed their own capture, and different identifiers do not prove two people were involved. It is context only — the decision applies exactly as before, and it reaches no release gate, approval digest or evidence pack. When the identifiers are equal, explain --preserved-artifact and custody show print a line saying so beside the decision; every state is in the JSON.
sbomflow store add evidence keeps the release, custody copies included, and sbomflow store verify re-hashes them.
7. From a field event to a case#
The field event in this example left a trace and a core dump, and both are already in the release run. When the event becomes a product-security case, the case can point at those exact bytes. The reviewer records a conclusion about the dump as in step 4. Then open the case, and record the case's decision with the two review keys as its evidence:
sbomflow review evidence \
--needs-more-evidence preserved-artifact:sha256:74302ad6917b5346b0bf5285c4bf1627333edda7a722d484b06d50e8f4c902f1 \
--reviewer fw-lead \
--note "Fault address is in the sensor driver; a trace that covers the fault is needed"
sbomflow case open cases/field-crash --case-id FIELD-CRASH-2.4.1 \
--actor psirt-lead --title "Hard fault reported from the field on 2.4.1"
sbomflow case accept-risk cases/field-crash \
--reason "Units restart on the fault and keep sampling; the driver fix ships in 2.4.2" \
--expires-at 2999-12-31T23:59:59Z --actor psirt-lead \
--evidence preserved-artifact:sha256:74302ad6917b5346b0bf5285c4bf1627333edda7a722d484b06d50e8f4c902f1 \
--evidence preserved-artifact:sha256:c6ab080870d5604effd3be030bea8a55189deb8d0a9b93f38ec51d2c142fd7b7
sbomflow case show cases/field-crash --custody-set evidenceA value that starts with preserved-artifact:sha256: must be a full review key, or the verb refuses it with E019 and records nothing. case triage takes the same --evidence value once an advisory has been correlated with the case, and case remediate takes it too. case show --custody-set accepts a custody set or a release run's evidence directory. For a release run it reads that run's preserved-artifacts.json, and its reviews.json when there is one, the same way sbomflow review does:
preserved-artifact references (release run preserved-artifacts.json: read; decisions: reviews.json applied, as `sbomflow review` applies it):
preserved-artifact:sha256:74302ad6917b5346b0bf5285c4bf1627333edda7a722d484b06d50e8f4c902f1: present — cc_coredump.dmp; review: needs_more_evidence by fw-lead; the original as the run last checked it: not_supplied
preserved-artifact:sha256:c6ab080870d5604effd3be030bea8a55189deb8d0a9b93f38ec51d2c142fd7b7: present — field-crash.psfs; review: unreviewed (no decision recorded); the original as the run last checked it: verifiedEach reference reads present with the run's review status and the original's state as the run recorded it. absent means the run holds no artifact with that key. set_unusable means the run has no readable preserved-artifacts.json, and the reason is given. No original is opened. A reference is a pointer a human recorded in the case. It is not evidence of exploitation and it decides nothing. The case's own decision stays the case's, and the review of the file stays the reviewer's.
Four shorter examples#
The workflow does not change with the tool, the industry or the organisation that issued the evidence. Only the stated values do.
A fuzzing campaign's report. Preserve the report the fuzzing tool wrote, with the release, the campaign and the tool you ran, and declare it with its original. A reviewer's note might read "no new crashes in the parser target over the stated run; the network stack was not in scope". SBOMFlow records that a person concluded it, about those bytes. It does not read the report, count crashes or judge the campaign.
A hardware-in-the-loop bench log. Preserve the log file a bench run produced, with the release, the rig and the scenario, and declare it with the firmware image the rig was flashed with. in_release then says that image is a file of this release; whether the bench actually ran that image is what your note, and the bench's own records, have to establish.
A CI job's test report. Nothing about this is device-specific. Preserve the report a pipeline wrote — a test summary, a coverage file, a load-test result — with the release, the job and the tool that produced it. The job number and the commit are stated values like any other, and the report's own bytes are what a reviewer's conclusion is bound to, so "which test run did we sign off for this release" has an answer that survives the CI system's retention policy.
A third-party laboratory's report. Here the evidence was issued by another organisation rather than produced by a tool of yours, so state both: --tool-name for whatever produced a file, and custody init --issued-by for the organisation that issued it. SBOMFlow records the issuing party as stated and never verifies it, and it derives nothing about accreditation, independence or authority from the name — the string is an origin label, not a credential, and the report's contents remain whatever your reviewer read in it.
Keeping artifacts in a custody set, without a run#
Sometimes the files come before any release run: a test lead collecting bench logs through a sprint, or an engineer keeping a core dump from a field unit. A custody set keeps them, lists them and proves later that the bytes are unchanged, with no scan, no SBOM, no evidence pack and no requirement-regime assessment. The custody commands are experimental.
Create one set per team, release or campaign, in an empty or absent directory whose parent already exists:
sbomflow custody init custody/field-2.4.1 --set-id field-support --release-id 2.4.1 \
--retention-note "originals kept in the bench archive for 15 years from release"The bench team created custody/bench-2.4.1 the same way. Adding the bench trace to it, listing the set, showing one artifact by its digest (or any unique prefix of at least 12 characters), and verifying the set against the captures directory:
sbomflow custody add custody/bench-2.4.1 captures/boot-to-idle.psf \
--release-id 2.4.1 --scenario boot-to-idle --keep-original
sbomflow custody list custody/bench-2.4.1
sbomflow custody show custody/bench-2.4.1 <boot-trace-digest>
sbomflow custody verify custody/bench-2.4.1 --originals captures/Re-running the same add changes nothing: the set already holds that exact record.
initcreates the set in an empty or absent directory: a marker file (.sbomflow-custody-set), the indexcustody-set.json, and a hash-chainedaudit-log.jsonl. A scan never walks a directory holding that marker, and names each one it skips with thescan_directory_excluded_by_markerwarning.--retention-note TEXTrecords how long you state the originals must be kept, in your own words. It goes into the index header asstated_retention({value, origin: user_supplied}), is copied verbatim into the export manifest and the export's readable index, and is covered by the init audit event — so a statement edited into the index afterwards is named bycustody verify. - SBOMFlow does not interpret the text. It derives no date or period from it, compares it with nothing, and never says a retention requirement is met, unmet or expired. A note reading0 dayschanges nothing a sentence does not: the value is a statement a person made, recorded with the origin it came from. - Unstated, the field is an empty block and readsnot statedeverywhere. There is no default: a period nobody stated would be a period SBOMFlow invented. It never joins the header'smissinglist, because that list is compared as a whole against the audit log and an entry in it would make a set written by this build fail verification under an earlier one. - It is a statement about your own archive, not a policy SBOMFlow applies. Nothing here deletes, expires or prunes anything — unlike the unrelatedvulndb retentionsetting, which computes which cached advisory snapshots to drop. - A control character is refused at the flag, and a statement longer than 512 characters is refused withE019and nothing is written — state the retention in a sentence and keep the policy document where the statement points at it. - Both directions are measured, and both are clean. A set created before this field existed carries neither the header key nor the init event field, andcustody verifyreads the absent key as "unstated" rather than as a disagreement — including after acustody addunder this build, which rewrites the index with the field while the older init event still lacks it (a pinned compatibility fixture covers exactly this case). In the other direction, a build from before this field verifies a set and an export written by this one, with the retention stated and unstated, because it ignores keys it does not know and the comparedmissinglist is unchanged. What a rewritten or deleted retention costs: only a build that knows the field catches it. Measured on a set that stated one — this build fails a rewritten value and a deleted key at exit 4, and a build from before the field returns 0 for both, because it never reads the key. A statement is checked by a build that has a name for it, and by no other.init --issued-by NAMEstates which organisation issued the evidence — a test house, a laboratory, a pen-test vendor, a customer's representative — as opposed to--tool-name, which is the tool that produced a file. It takes the same contact-free identifier rule as--operator(lowercase letters, digits,.,_,-), because it travels to whoever receives an export, and an address, a phone number or a personal name there is a disclosure nobody reviewed. It is recorded as stated: nothing verifies it, and it says nothing about accreditation, independence, authority or what any report contains. Unstated, the set header listsissuing_partyas missing and every surface reads "not stated".addwrites the same recordingest-preserved-artifactwrites, verbatim, underrecords/. Adding the same record again changes nothing. A different record for an artifact already in the set is kept beside the first, with a warning. An artifact stating a release other than the set's is refused.--keep-originalkeeps a local copy underoriginals/of the same set, checked against the record before it is kept. Nothing ever exports it.add --record R.jsonadmits a record you already made, through the same checks a release run applies.--original PATHre-hashes the file it describes.showtakes a review key or a unique sha256 prefix of at least 12 characters.verifyre-hashes every record and kept original, finds originals under--originalsby size and then sha256 (never by file name), checks the audit chain and the set's seal, and exits4on any integrity problem. It never takes the index's word: every row is rebuilt from its verified records and the audit log, and the set id and declared release are checked against the log, so an edited name, association or release, or a removed row, is named.--strictalso requires every original found and no stray files.- Each row's original state is that row's own evidence, never the directory's:
| state | what it means |
|---|---|
verified | the kept copy, or a file under --originals, has the recorded size and sha256 |
mismatch | the kept original does not hash to the recorded sha256 |
unreadable | a file of the recorded size was found and could not be read |
missing | nothing searched under --originals has that size and sha256 |
not_supplied | no --originals directory was given and no copy was kept |
An entry under --originals that the walk refuses — a symlink (never followed), a FIFO or other non-regular file, a directory it cannot list, or everything past its entry bound — is recorded against itself in originals_refused, with its own reason, and counted separately from the row states. One refused entry can never move another row's verdict. What a refusal does mean is that part of the directory was not searched, so custody verify states the refusal count beside the number of files it did search (originals.files_searched in --json) and names every refused entry.
- Someone who edits the index and removes the matching events from the end of the audit log leaves a set that agrees with itself. To detect that, record the seal
custody verifyprints when you hand the set over, and pass it later as--expected-seal. - An
initoraddthat stopped part way is completed by re-running the same command.
custody add takes the same --artifact-kind KIND as ingest-preserved-artifact, from the same closed vocabulary, and the two commands write the same record bytes for the same file and the same statements.
Recording a decision in a custody set#
When you have examined an artifact in a set, record what you concluded, and its limits, against exactly those bytes:
sbomflow custody review custody/bench-2.4.1 \
--accept preserved-artifact:sha256:<boot-trace-digest> \
--reviewer lead@example.org \
--note "boot-to-idle trace examined; covers the boot path only"
sbomflow custody show custody/bench-2.4.1 <boot-trace-digest>
sbomflow decisions verify-transactions --store custody/bench-2.4.1--accept,--rejectand--needs-more-evidenceeach take a full review key (preserved-artifact:sha256:and 64 hex characters, ascustody list --jsonprints it); a prefix is refused, because a decision binds exact bytes.--reviewerand a non-empty--noteare required. The note is the only record of what the decision covers.- The decision goes to
preserved-artifact-reviews.jsonin the set through the same crash-consistent transactionsbomflow reviewuses, with an audit event. If a write is interrupted,sbomflow decisions verify-transactions --store DIRandsbomflow decisions recover --store DIR --operation-id IDfinish it. - A later decision for the same artifact supersedes the earlier one, which is kept in the file.
--idempotency-key Kmakes a retried command converge instead of recording twice. custody list,custody showandcustody verifyshow each artifact's decision.showdoes not re-hash the original, so it says so;verifydoes, and a decision whose original no longer matches is shown and not applied. An entry someone wrote by hand without a reviewer or a note is never applied.
A decision in a set is a named person's conclusion. A release run can include the whole set (below), and the decision then applies only when it genuinely holds for that release. It never reaches a gate, VEX or an approval digest.
Including a whole custody set in a release run#
When the release run comes, declare the set itself rather than listing its records:
# sbomflow.yaml
preserved_artifacts:
- set: custody/bench-2.4.1
firmware_image: firmware/zephyr.elf # applies to every row of the setThen run as usual, and read what applied:
sbomflow audit . --output evidence
sbomflow review evidence --json- The entry expands to one declaration per record the set holds, under the same confinement and hostile-read rules as a single
record:entry, and each row inpreserved-artifacts.jsonsays which set it came from. One set of N records is N declarations, sosummary.declared_entriescounts what was joined. original:besideset:is refused: a set row's original is the one the set itself keeps, re-hashed where it is and never copied into the run.- A set that is a symlink, uses
.., leaves the config file's directory, carries no.sbomflow-custody-setmarker, has no readablecustody-set.json, or holds no record at all is refused as one row with its reason, and the run continues. An index whose rows were stripped reads as "holds no record" too, so a declared set never disappears into "nothing was declared". - A decision recorded in the set is applied only when all of these hold, and is otherwise shown with the reason and not applied: 1. the set can vouch for it —
sbomflow custody verify DIRverifies 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. A run refuses exactly what that command exits 4 for, because it reaches the verdict through that same verifier; 2. the set declares this run's release. A set that declares none reads "decision made outside any declared release"; a set declaring another release, or a row whose own stated release disagrees with the run, is refused for that release; 3. the supplied original does not mismatch the bytes the record describes; 4. the run's own reviews file does not record a different decision for the same artifact. Two sources that disagree contest each other: neither applies, both are listed, and a warning says so. Record a decision in the set or in the run's reviews, not both. - A set that cannot vouch for a key is not shown as agreeing with a decision the run's own reviews file records for it: the run's decision still applies on its own, and the set's copy is left in
custody_set_sourcesrather than presented as a second opinion. - What the set recorded at that run is written into the index as
custody_set_sources, including the seal and audit chain head — but only when the run verified that set, so an empty value there means it did not.review,next-actionsand the reviewer console decide a livereviews.jsonentry against that snapshot, so a decision recorded after the run contests a disagreeing set decision instead of silently replacing it. The set is never re-read after the run: the run's record describes the set as the run read it. - Nothing from the set reaches the evidence pack, the release gate, VEX, a CRA mapping or the approval digest, and the original bytes are never copied out of it.
Handing a custody set over, and comparing two of them#
A customer or an auditor who asks for the evidence should be able to check it without SBOMFlow's help beyond the tool itself, and without you sending them the artifacts. custody export writes that package:
sbomflow custody export custody/bench-2.4.1 --output handover/bench-2.4.1 --zip
sbomflow custody verify <exported-set>
sbomflow custody compare custody/bench-2.4.1 <exported-set>
sbomflow custody compare custody/bench-2.4.1 --run evidence<exported-set> is the export directory from the first command (or its custody-export.zip; both verify the same way).
- The export carries the index, every record the index names, the recorded decisions and the audit log, plus
custody-export.json: each member with its sha256 and size, the set's seal, and the audit chain head and event count the export was taken at. custody-export-index.mdis the page a person reads. It is generated from the documents beside it and lists, per artifact, the name, the sha256, the size, the format hint and how it was declared, the review key, each record copy with its digest, whether the set kept an original, and the review decision with the reviewer, the time it was recorded and the note — with the reason when a recorded decision is not applied. It ends with the boundary and with what the package does not establish, in the same words the manifest uses. It aggregates nothing, scores nothing and reaches no verdict, and it proves nothing on its own: recorded text is escaped rather than rendered, the page's own sha256 is listed in the manifest's members, andcustody verifyre-hashes it like any other member. Two exports of one unchanged set under the same--as-ofproduce the same page byte for byte.- The issuing party the set header states travels verbatim in the manifest, beside
issuing_party_note, which says it was stated and not verified.custody verifyprints it and the recipient label — "not stated" included — so the receiving side reads both without--json. It also cross-checks the issuing party against the carried index and reconciles it with theinitevent in the carried audit log, so a rewritten origin is reported rather than printed beside the word "intact". A signature stays a separate fact: it covers the integrity of the package, never the stated origin. - A set or export written before these fields existed verifies clean. Its header carries no
issuing_partyand itsinitevent states none, so the two agree and there is nothing to report. A header whose audit log DID record a party must still carry it: deleting the field, or adding a party the log never recorded, is an integrity problem at exit4. - It never carries an original.
originals_carriedis alwaysfalseand the manifest says so in words. A recipient who has the artifact bytes checks them against the recorded sha256 withsbomflow custody verify <export> --originals DIR, which looks by content, never by filename. Nor does an export carry the decision journal or the run lock. - That rule is enforced on what a member IS, not only on what it is called: a record member must hash to the
record_sha256the set's index records for it, and a member with a second name (a hard link) is refused. So a set whose record copy has been replaced — the statecustody verify DIRalready reports at exit 4 — is refused bycustody exportrather than published. --recipient NAMEstates who the package was prepared for, so the exporting team can answer "what did we send, and to whom" from the artifacts months later. It is recorded in the manifest asrecipient: {value, origin: user_supplied}({}and "not stated" when omitted) and takes the same contact-free identifier rule as--issued-by, because the manifest leaves the company. It is not an access control and it filters nothing —recipient_notesays so in the manifest itself. An export carries exactly what the set holds, or the rows a selection chose (below), whatever label it bears, and two exports of one unchanged set to two recipients differ in that one field and nothing else. It grants nobody anything and withholds nothing from anybody: what leaves the set is decided by the selection, never by the label.--outputmust be an absent or empty directory, and is never a symlink: an export replaces its whole destination, so it refuses to publish over content it did not write.--dry-runprints the member list and the share-boundary preflight verdict and writes nothing. That preflight refuses the whole export if a member carries secret-shaped content — nothing is written and any earlier export is untouched.--key PEM --key-id ID(both together, with the optionalsignextra) writes a detachedcustody-export.sig.jsonover the manifest bytes. It asserts the integrity of the package and who packaged it, nothing more. Verification reportsverified,failedorpresent_unverified; without--public-key, or without the extra, it ispresent_unverifiedand neververified, while the member and manifest hash checks run either way.- Two exports of one unchanged set under the same
--as-ofare byte-identical, zip included.
custody verify tells a set and an export apart by the schema each carries, never by its name, so a renamed handover still verifies as one. On an export it re-hashes every listed member, recomputes the seal from the carried index and the chain head and count from the carried audit log, reconciles that index's header and its rows with the admission events in the carried log (so a renamed set or a rewritten declared release is named, exactly as it is in a set), cross-checks the manifest's set_id and declared_release against the carried index, parses each record copy and checks it against its row, reports a signature or archive the manifest declares but the package does not carry, reports any member the manifest does not list rather than counting it as verified, and refuses a symlink, hard link, device or FIFO member — and a zip member whose name is absolute or traversing — instead of reading it. Exit 4 on any integrity problem.
What an export verification cannot check, and says on every run:
- The artifact bytes themselves. An export never carries them, so they are compared only against files you offer with
--originals DIR. - Who made the package, when it is unsigned. An unsigned export is self-anchored: every check above is the package against itself, so somebody who rewrites the carried index and re-chains the carried audit log to match can hand you a fully consistent package that says whatever they chose. Internal consistency is not authenticity. A signature is what makes that rewrite fail — verify it with
--public-keyagainst a key you already trust, and a rewritten manifest no longer matches what was signed.
Everything else in the package is checked against the bytes the package carries, including a custody-export.zip the manifest declares: it must be a readable, non-empty archive holding exactly the members the manifest accounts for, with the same bytes, so the zip and the directory cannot tell a recipient two different stories.
Sharing a subset
A supplier with several customers, or a team sharing one incident's evidence with one customer, must not hand over the records, digests or names that belong to somebody else. Select what travels:
sbomflow custody export custody/bench-2.4.1 --output handover/boot-trace-only \
--select-key preserved-artifact:sha256:c640b55149337567f8395db0fd66102ecb98006a9762a67cbf62114a6932d7a2--select-key KEY(repeatable) takes a full review key and never a prefix, because a prefix that later matched a second artifact would change what leaves the set.--capture-session IDselects every row whose records state that session; with both, the export carries the rows either selects. A key or a session that selects nothing is refused (E019) and nothing is written. A selection that names every row is the whole set, and is exported as one.- The export then carries only the selected rows, their record copies, their decisions and the audit events about them (plus the set's
initevent, which the header is checked against). Its index is the set's header with the selected rows, and its seal is recomputed over those rows only and recorded asselection_seal;sealis{}because the whole set's seal could not be recomputed from the package. The manifest'sselectionblock says how many rows, records, decisions and audit events were left out, as counts — never which. Nothing about a row left out is written into any member: not its name, digest, review key, record, statement or decision. - A selection is made only from a set whose audit chain verifies and whose decisions reconcile with it (
E031otherwise, with nothing written), because the recipient cannot re-check what was left out.audit_chainin the manifest states the whole chain's head and event count at the time of the export. custody verifyon such a package reportsaudit_chain: partial_export, nevervalid, with the meaning: events for unselected records were omitted by the exporter; the chain cannot be recomputed end to end from this export. Each carried event is still checked against its own hash and its order, the carried index is reconciled with the carried admission events, the selected rows' decisions with their carried decision events, and the seal withselection_seal. Exit0unless another problem is found. A carried event keeps itsprevious_event_hash, which is the hash of the event before it in the set — possibly one left out: a hash of an event, never a record, a digest or a name.- The selection filters what travels; like the recipient label, it is not an access control, and a recipient who holds the package holds everything in it.
custody compare names what two sides record differently: keys only on one side (added, removed), a different set of record digests, a different recorded decision (the fields that differ, never the note's text), and a different recorded original state. Either side may be a set, an export directory or an export zip, and --run OUTPUT_DIR compares against a release run's preserved-artifacts.json.
- Only a field both sides record is compared: a set records whether a copy of the original is kept, a run records the verification it measured, so those two are never reported as a change to each other.
- A side that is absent, unreadable or not a custody carrier is not compared — the document says
comparable: falsewith the reason and every change list empty, and the human output printsNOT COMPARED, at exit 0. That is never the same statement as "no change". - Compare writes nothing unless
--output FILEis given, re-verifies neither side (custody verifydoes that), never compares artifact bytes, and reaches no verdict about which side is right. The human output states that boundary, because a side nobody verified must not read as a side that agrees. - A row a side holds that the shape check rejects is counted in that side's
malformed_rowsand named in the human output, never dropped: an unreadable row is not an absent one, andcustody verifyreports the same row as malformed.
--as-of on a custody command is tool time: it pins the index's generated_at and the audit timestamps so output is byte-identical. It is never the time a capture was taken. To record when the capture was taken, pass --captured-at T to the artifact form (custody add DIR ARTIFACT --captured-at T): it records captured_as_of exactly as ingest-preserved-artifact ARTIFACT --as-of T does. Without it the record lists captured_as_of as missing. The record form already states its own capture time, and an intake manifest states none, so both refuse --captured-at by name.
Custody proves byte identity and records what humans stated and decided; it does not analyse artifacts, prove coverage or test success, or make any security, safety or conformity claim.
Joining several files from one capture#
One incident rarely produces one file. A field alert arrives as a trace and a dump; a fuzzing campaign leaves statistics, a crash and a corpus snapshot; a hardware-in-the-loop run produces a measurement log, a rig configuration and a set of results. Each file gets its own record, and without something joining them a reviewer meets five unrelated rows where one session happened.
--capture-session ID states that grouping, on both ingest-preserved-artifact and custody add:
sbomflow custody add custody/bench-2.4.1 captures/field-crash.psfs \
--release-id 2.4.1 --artifact-kind runtime_trace \
--capture-session alert-nightshift-a
sbomflow custody add custody/bench-2.4.1 captures/cc_coredump.dmp \
--release-id 2.4.1 --artifact-kind core_dump \
--capture-session alert-nightshift-aThe id follows the same contact-free rule as --operator, because it travels with several records and is exactly the field someone would otherwise type a person's name or a ticket URL into. Nothing groups files on its own — no directory sweep decides that neighbouring files belong to one session. An operator says so, or nothing does.
custody list can then show one session's rows:
sbomflow custody list custody/bench-2.4.1 --capture-session alert-nightshift-aThe filter applies to the JSON form as well as the printed one, and both say how many rows were examined beside how many matched. A filtered listing that looked like a whole set would be worse than no filter, so a capture_session_filter block records the id, both counts, and a note that the set itself is unchanged and its seal still covers every row. An unfiltered listing carries no such block.
Each index also carries a capture_sessions view: one entry per stated session, with the row keys in it, a count of review states and a count of artifact kinds. It is derived from the rows alone and validate recomputes it, so a grouping that claimed another session's rows is refused rather than believed. For a custody set the view is computed when the set is READ and never written into custody-set.json — the set's seal covers its rows, and a grouping stored inside would move bytes a recipient verifies.
A session is a view, and never a decision target. The entries carry no status of their own, and sbomflow next-actions uses the grouping only to suggest an order of work — it reports how many artifacts await a decision in each session, beside how many await one in no session at all, so the breakdown adds up to the worklist. A decision is still recorded against one artifact's bytes, because a session-wide decision would cover files nobody opened.
Adding many files at once: an intake manifest#
Two hundred alert payloads, one nightly CI job's reports, a lab's delivery folder or a supplier's test bundle is two hundred custody add commands, and a shell loop that stops at the 57th file leaves 56 of them in the set and the rest out. An intake manifest names every file to add in one list, and custody add admits all of them or none:
python3 -c 'import json, sys; json.dump({"schema_version": "sbomflow-custody-intake-v1", "defaults": {"release_id": "2.4.1", "capture_session": "alert-nightshift-a"}, "entries": [{"path": path} for path in sys.argv[1:]]}, sys.stdout, indent=2)' captures/field-crash.psfs captures/cc_coredump.dmp > intake.json
sbomflow custody add custody/bench-2.4.1 --intake-manifest intake.jsonThe first line is yours, not SBOMFlow's: it writes the list from the files you name, and your shell would expand captures/* there if you typed it. SBOMFlow never walks a tree or expands a path as a pattern — it reads the list it is given. The format is sbomflow-custody-intake-v1 (experimental), and an entry can state its own fields beside the defaults — here, what each file is:
{
"schema_version": "sbomflow-custody-intake-v1",
"defaults": {"release_id": "2.4.1", "capture_session": "alert-nightshift-a"},
"entries": [
{"path": "captures/field-crash.psfs", "artifact_kind": "runtime_trace"},
{"path": "captures/cc_coredump.dmp", "artifact_kind": "core_dump"}
]
}- Statements.
defaultsand each entry take exactly the artifact form's options, spelled with underscores:release_id,scenario,artifact_kind,capture_session,performer,retain_until,firmware_imageand the rest. An entry's statement replaces the default of the same name, and each value is checked exactly as the option is. With--intake-manifestthose options are refused on the command line, so what an entry states has one source.--keep-originalapplies to every entry. - Paths. Each
path— and afirmware_imageorconfiguration_filestatement — is relative to the manifest's directory and is located the way a declared custody set is: a..component, an absolute path, a symlink or a path through one, a directory, a device and a FIFO are all refused. A path is read literally, so an entry ofcaptures/.psfsnames a file called.psfs, and the refusal says so. - A directory entry. Instead of
path, an entry may name onedirectorywith the exactnamesand/or thepatternsit expects there — a CI job's or a test rig's output folder, for example. The directory is listed once and never recursed, and supplies only the files the entry names; everything else in it is reported as not taken, with its reason (not_named,symlink,directory,not_regular_file,unreadable), and counted. A missing expected name, a pattern matching nothing, or a named or matched symlink, directory or special file refuses the whole intake, listing and counting every such problem; an unnamed one is only counted. Step 8 of Keeping runtime evidence in custody walks one. - All or nothing. Every entry is checked, located and hashed, and its record built, before anything is written; so is every refusal the one-file form would only reach after writing an earlier file (an artifact already in the set without its original when
--keep-originalasks for one, for example). Any refusal names its entry —entry 57 of 60— exits2and writes nothing. A manifest of more than 10 000 entries is refused before any file is hashed. - Then one file at a time. The entries are admitted in order under one set lock, each exactly as a single
custody addwould admit it, with one audit event per record. Two entries naming the same file with the same statements make one record, and two files that share a name in different directories are two rows, because a row is its bytes. Re-running the same manifest reports every entryunchangedand writes nothing. - What it cannot promise. A write that fails partway (a full disk), or a file rewritten between the check and its admission, stops at that entry. The refusal says which entries were admitted in full; they stay in the set with their audit events, and re-running the same command admits the rest without adding any twice.
--jsonprints the usual operation document withrow: nulland anintakeblock:entry_count,outcome_countsand, per file, its entry number, path,outcome, review key and record path. With a directory entry it also carriesdirectories: per directory entry, each pattern's matched count, the files taken and every entry not taken with its reason, each counted, and each admission event records the directory, the named count and the patterns' matched counts.
What a test record says about the test#
A file on its own does not say who ran the test, how independent they were, or what they ran it on. Those are the fields a reviewer of test evidence usually asks for, and without somewhere to put them they end up in a document nobody links to the artifact. Five optional statements go on the record instead, on both ingest-preserved-artifact and custody add:
| Flag | Records | Rule |
|---|---|---|
--performer ID | test_performer | A lab, team or role identifier. The same contact-free rule as --operator, because this value can leave your company inside a record. |
--independence VALUE | performer_independence | One of same_team, independent_internal, third_party. |
--test-environment TEXT | test_environment_statement | The rig or environment tested on. At most 256 characters, no control characters. |
--configuration-file PATH | test_configuration_sha256, test_configuration_file_name | The file is hashed and never parsed. |
--procedure-ref TEXT | procedure_ref | The procedure or test plan followed. At most 128 characters, never dereferenced. |
sbomflow ingest-preserved-artifact captures/field-crash.psfs \
--release-id 2.4.1 --scenario "24h soak, ambient 40C" \
--as-of 2026-09-12 --operator fw-bench --artifact-kind runtime_trace \
--performer lab-nordic --independence third_party \
--test-environment "HIL rig 3, 24h soak, ambient 40C" \
--procedure-ref "TP-114 rev C" \
--output preserved/soak-test-context.preserved-artifact.json--configuration-file is omitted above only because this demonstration tree has no rig configuration to point at; it takes any file and records its name and SHA-256.
Every one of the five is a statement by the person who recorded the artifact, and the record says so beside them rather than in a footnote. SBOMFlow did not watch the test run. It cannot tell an independent laboratory from the team at the next desk, and it does not check that a stated environment existed. The test_record_context block on each row in the release index renders all five, with not_stated where nobody filled one in, next to that sentence.
Three deliberate limits are worth stating plainly:
- There is no value meaning certified, accredited or approved. The
--independenceset is closed and offers none, because those are qualifications somebody grants and can withdraw, and SBOMFlow verifies neither. What a person can honestly state is the relationship between whoever tested the thing and whoever built it. - The configuration file is digested, not read. What a rig configuration, a fuzzing dictionary or a scan profile contains stays with you; a reviewer can still tell that the file they were handed is the file that was used. A file that is not a regular file — a FIFO, a device node, a symlink out of the tree — is refused rather than read.
- Omitting all five costs nothing. A record that states none of them is byte-identical to one written before these fields existed, so re-recording an existing artifact stays unchanged rather than becoming a refused rewrite.
When the same test-results file is also parsed#
A JUnit, TAP or Robot Framework file can be read two ways in one run: by --test-results, which counts its totals into the evidence pack, and as a preserved artifact of kind test_results, which keeps it with a review decision bound to its bytes. When both happen, the row of those bytes in preserved-artifacts.json carries test_result_totals: the totals the parsed lane counted (tests, passed, failures, errors, skipped, todo, and bailed_out for a TAP stream that stopped), the source_file, result_format, suite_name and sha256 of each parsed file with those bytes, a rule and a meaning.
The join is exact SHA-256 equality and nothing else: no file name, path or suite name is compared, so a renamed copy still joins and a re-exported file with one changed byte does not. It tells a reviewer that the totals and the file a named person decided about are one file. It does not say the decision was about the totals, that the tests are adequate, or that a failure does not matter, and SBOMFlow ran no test. explain --preserved-artifact prints a Test totals line next to the row's decision. The decision is not copied into the block, so a decision recorded later is shown with the same totals.
The block is written only into preserved-artifacts.json. The evidence pack's test-result rows do not name the preserved row, because they are part of the approval digest and custody state must never move it: the gate, the pack and the digest are the same bytes whether or not the file is also kept. validate recomputes a present block from the pack's test-result rows and refuses an edited total, a swapped digest or an added result. A row with no parsed counterpart carries no block, and an output written before the block existed still validates.
Where the original is kept, and a retention date you state#
The original file is never copied into a record or an output, so the record is the only place that can say where it lives — and years later, nobody remembers. Two optional statements go on the record, on both ingest-preserved-artifact and custody add:
| Flag | Records | Rule |
|---|---|---|
--original-location URI | original_location | An s3:, gs:, az:, https:, file: or urn: location, or an opaque id such as a document number. At most 512 characters. Never fetched, resolved or opened. |
--retain-until DATE | retain_until | A retention date you state, checked by the same validator as --as-of and recorded exactly as you typed it. |
sbomflow custody add custody/bench-2.4.1 captures/field-crash.psfs \
--release-id 2.4.1 --artifact-kind runtime_trace \
--original-location s3://acme-evidence/bench/2.4.1/field-crash.psfs \
--retain-until 2036-12-31
sbomflow custody list custody/bench-2.4.1 --as-of 2037-01-01 --jsonA location is text that travels inside a record, so one that carries a credential is refused (exit 2, E019) rather than recorded: a user name or password, anything with an @, a query string or a fragment, a parameter a presigned or shared-access link uses to authorise a download (the full list is PRESIGNED_URL_PARAMETERS in the source), an absolute path on this machine (file:///…, file://localhost/…, a bare /… or ~/…), an unfamiliar scheme such as http:, or a control or whitespace character. The refusal names the problem and never repeats the value, so a credential you pasted by mistake is not copied into a terminal or a CI log. State where the original is kept without a credential: a bucket and key, a share on a named host (file://archive-host/share/…), or a document id.
A record written or edited by hand can still carry such a value. It is read — the rest of the record is still custody evidence — but the value is shown as withheld in preserved-artifacts.json, custody list and custody show, and a preserved_artifact_location_withheld warning names the problem and not the value. custody add --record refuses such a record outright, because a set's index is shared. The record file itself is unchanged, and a run's verbatim copy of it under preserved-artifacts/ still carries the value, because a custody copy is never rewritten.
Each row in the release index, and in custody list, carries a retention_statement: the dates stated for those bytes, stated or not_stated, and relative_to_as_of — before or on_or_after the pinned --as-of, or not_evaluated when nothing was stated or no --as-of was given. The clock is never used for it. A date that has been reached raises an info warning, preserved_artifact_retention_ended, and nothing else: the release gate, the evidence pack and the approval digest are the same bytes either way. Despite its name, the warning does not say retention is over. It says the pinned date is on or after a date a person stated, and then what is recorded about a preservation hold: the hold on the custody set that keeps the artifact, who placed it and when (see A preservation hold on a custody set), or that this set records none, or, for a record declared directly in a run, that a record kept in no custody set carries no hold. A hold recorded on a stored release pack with store hold is a different record and is not read here, so without a hold an open incident or dispute may still require the original.
A retention date here is a date a person stated. SBOMFlow does not compute or check any legal retention period, does not keep the original and deletes nothing — the block says so beside the date. Whether an original may be disposed of is your decision, made under your own policy. This is also a different statement from the free-text --retention-note a custody set can carry: that describes the set's archive as a whole, in your own words, while --retain-until is one date on one record.
A preservation hold on a custody set#
An open incident, a dispute or a customer investigation can mean the evidence in a custody set must be kept whatever retention date anyone stated. custody hold records that on the set, the way store hold records it on a stored release pack:
sbomflow custody hold custody/bench-2.4.1 --actor legal-team \
--reason "Kept while field-return case 2026-17 is open."
sbomflow custody list custody/bench-2.4.1 --as-of 2037-01-01The hold records the named person (--actor, the same contact-free identifier rule as --operator, because a set travels in an export), when it was placed (--as-of, or now), and the reason exactly as stated (required, at most 512 characters, no control characters). It is written twice, and each record checks the other: as preservation_hold in custody-set.json, and as a custody_set_hold_placed event in the set's hash-chained audit log that binds the SHA-256 of that exact block. custody verify reconciles the two and names any disagreement as the problem preservation_hold_disagrees_with_audit_chain (exit 4): a reason, time or state edited in the index, a hold written by hand, or one removed. A second hold on a held set is refused and names the first. custody release-hold DIR --actor NAME --reason TEXT lifts it: the record stays, flagged lifted, with who lifted it, when and why, and the event is appended. Every rewrite of the index by a later custody add carries the hold forward unchanged. A custody hold that stopped after its audit event is completed by re-running the same command.
What a hold changes is what the set says. custody list prints a Hold line, and each row's retention_statement carries preservation_hold (the set's hold as recorded). A reached retention date is reported beside the hold instead of beside the words "no hold is recorded", and a release run that joins the set says the same on the joined rows. A hold deletes nothing and keeps nothing, because custody never deletes and never copies an original. It changes no row, record, seal or decision, has no expiry, and SBOMFlow does not assess the stated reason: the record says who placed a hold and when, never what obligation it serves. Full and selection exports carry the hold and its events, and the recipient's custody verify reconciles them the same way.
Scheme records#
A scheme's certificate or approval, or the report a scheme's test tool produced, usually names a firmware version and sometimes an image digest. You re-release often, and whether the record on file still describes the build you are shipping is the question a customer or a market-surveillance reviewer asks. Record it with the kind that says what it is, and the version or digest it names:
| Flag | Records | Rule |
|---|---|---|
--artifact-kind certification_or_approval_record or conformance_test_report | artifact_kind | What the file is, as you state it. |
--firmware-version TEXT / --firmware-sha256 HEX | firmware_version / firmware_image_sha256 | The version, or the image digest, the record names — typed from the record, never read out of it. |
--valid-until DATE | valid_until | The date the record states it applies until, checked by the --as-of validator and recorded exactly as typed. Refused (exit 2, E019) on any other kind, because nothing would read it there, and refused when it is not a date. |
Every run that joins such a record — declared directly, or held in a custody set joined with - set: — adds run_checks.scheme_record_scope to its row in preserved-artifacts.json. It shows what the record states, what it was compared with (compared_with.release_version, your product.version; and compared_with.declared_image_sha256 when the entry also declares a firmware_image:), and one state:
| State | Means |
|---|---|
names_this_release_image | Every image digest the record states equals the SHA-256 of a file this run's scan hashed. |
names_this_release_version | The version the record states is string-equal to your declared release version. |
names_other_version | A version the record states is not string-equal to it. 2.4 and 2.4.0 differ: versions are compared as text, never parsed. |
names_no_version | The record states neither a version nor a digest. |
not_evaluable | The record could not be placed: reason is run_declares_no_release_version (no product.version, or the placeholder) or stated_image_not_in_scanned_tree (it states only a digest, and no scanned file has it). |
A digest that only your build provenance names, or that belongs to an image under a directory the scan prunes, never reads as this release's image: the provenance is a statement by the build system, and run_checks.capture_firmware on the same row already shows that lookup. When a record states both a digest found in the scanned tree and a version, the digest decides; when it states two versions (two records for the same file), one that differs makes the row names_other_version.
"scheme_record_scope": {
"state": "names_other_version",
"stated": {"version": ["2.3.1"]},
"compared_with": {"release_version": "2.4.0"},
"valid_until_statement": {
"valid_until": ["2027-03-31"], "state": "stated", "relative_to_as_of": "before"
}
}The date you state is placed against the run's --as-of only — before, on_or_after, or not_evaluated without one — and never against the clock. When it has been reached, the meaning beside it says the stated validity ended, and nothing more. SBOMFlow never says a record is current, lapsed, or needs a new assessment, and it never reads the certificate itself: whether a version change matters, and what the record's issuer asks for when one happens, is your reviewer's call. A reviewer records that conclusion as the artifact's decision (sbomflow review <output-dir> --accept <review-key> --reviewer … --note "delta not re-tested; functional change only").
The comparison shows up in four more places, and decides nothing in any of them:
summary.scheme_record_scopecounts the rows by state, andSTART-HERE.mdrestates those counts in one line. Both are absent when no row is a scheme record.sbomflow next-actionslists every row that readsnames_other_versionas ascheme_record_names_other_versionitem, with the version the record states and the version this release declares. It stays listed while the two differ, even after a decision is recorded, because a decision about the same file recorded for an earlier release must not hide it.- A run given
--previous-outputlists a record whose state moved — for example fromnames_this_release_versionat 2.3.1 tonames_other_versionat 2.4.0 — undercomparison_with_previous.run_check_changed. - The release gate, the evidence pack and the approval digest are the same bytes with and without the record.
One limit: a record cannot state which product variant it covers, so it is compared with the release it is joined to and nothing else. With two variants, run each variant's release with its own record declared, and each is compared with the version that run declares; release.variant is not read here.
The evidence you planned to keep#
Most teams keep a list of what each release should carry — a cold-start boot trace, a fuzzing campaign, the latest penetration-test report — and check it by hand. Write that list in sbomflow.yaml, and every analyze or audit run compares it with the rows preserved_artifacts: declares:
preserved_artifact_expectations:
- id: boot-trace-cold-start
artifact_kind: runtime_trace
description: "Cold-start boot trace on the release image"
minimum: 1
capture_session: bench-2.4.1
- id: fuzzing
artifact_kind: fuzzing_campaign| Key | Rule |
|---|---|
id | Required. A contact-free identifier (lowercase letters, digits, ., _, -, at most 64 characters), unique in the plan. |
artifact_kind | Required. A kind --artifact-kind accepts. A row counts only for the kind its record states — never for its file name, extension or bytes — so record the kind when you preserve the file. |
minimum | How many rows of that kind; 1 when omitted. |
capture_session | Optional. Count only rows whose record states this session. |
description | Optional text, at most 200 characters, shown beside the entry. |
independence | Optional. A list of one or more of same_team, independent_internal, third_party: count only rows whose recorder stated one of them with --independence. |
firmware_image | Optional. in_release: count only rows whose declared image this run's own check reads in_release (a file the scan hashed) or named_by_build_provenance. It says nothing about whether the capture ran on that image. |
procedure_ref | Optional. Count only rows whose recorder stated exactly this text with --procedure-ref. |
A qualifier compares what a person stated (for the image, what this run's image check read). SBOMFlow verifies none of it: independence: [third_party] means "the recorder said third party", not that an independent party did the work. A row that states no value for a qualifier, or two different values, or whose image could not be checked, is unplaced — it might be the evidence the entry names, so the entry reads not_evaluable and unplaced_row_keys names it, exactly as for a row stating no kind. A row whose statement definitely differs does not count. An entry that states a qualifier carries it, and a qualifier_meaning, in preserved-artifacts.json; an entry that states none writes the bytes it wrote before qualifiers existed. For example:
preserved_artifact_expectations:
- id: pentest-independent
artifact_kind: penetration_test_report
independence:
- third_party
firmware_image: in_release
procedure_ref: TP-7Each entry lands in preserved-artifacts.json under expectations, with the review keys of the rows that match it and one state:
| State | Means |
|---|---|
met_and_accepted | At least minimum rows match, and every one carries an applied accepted decision from a named reviewer. |
present_unreviewed | At least minimum rows match, and at least one has no applied decision. |
present_not_accepted | At least minimum rows match, and at least one carries an applied decision that is not accepted. |
absent | Fewer rows match than minimum, and every row that could match states a kind. Raises the info warning preserved_artifact_expectation_unmet. |
not_evaluable | Fewer rows match than minimum, and a row that could match states no kind (or two different kinds); unplaced_row_keys names it. Stating its kind makes the entry evaluable. |
A plan compared with the rows declared for this run says nothing about whether the plan is sufficient, and SBOMFlow does not know whether evidence it cannot see is kept elsewhere. Rows held in a custody set joined with - set: count exactly like rows declared directly. A requirement_regime: none run evaluates the plan the same way.
An entry that cannot be read — an unknown kind, a repeated id, a minimum of "2" or 0, a key the plan does not know — is skipped rather than repaired, listed under expectations_refused with its reason, and warned preserved_artifact_entry_invalid; the rest of the plan is still evaluated. Use the block form shown above. The key is file-only: SBOMFLOW_PRESERVED_ARTIFACT_EXPECTATIONS is recognised and ignored, with a message saying so.
The comparison shows up in three more places, and decides nothing in any of them:
summary.expectationscounts the entries by state besidedeclared_entries,evaluated_entriesandrefused_entries, andSTART-HERE.mdrestates those counts in one line. Both are absent when no plan is declared.sbomflow next-actionslists every entry that readsabsentornot_evaluableas onepreserved_artifact_expectation_unmetitem, with how many declared rows state the kind against the plan's minimum. A decision recorded withsbomflow reviewafter the run is read there live;preserved-artifacts.jsonitself keeps the states the run wrote until the next run.- The release gate —
--fail-on-gapsincluded — the evidence pack and the approval digest are the same bytes with and without a plan, unless your policy file opts in to the knob below.
Holding a release on the plan (opt-in)#
This is the one place a preserved artifact's plan can reach the release gate, and only when your team opts in. Write this key in a policy file (--policy, or the policy: section of sbomflow.yaml); there is no command-line flag, no SBOMFLOW_* variable, and no shipped preset turns it on:
fail_on_preserved_artifact_expectation_unmet: true # or: unreviewed| Value | The gate is held by an entry that reads |
|---|---|
false (the default) | nothing — the plan never reaches the gate |
true | absent or not_evaluable: fewer declared rows than its minimum |
unreviewed | those two, and also present_unreviewed or present_not_accepted: the rows are there, but not every one carries an applied accepted decision |
A met_and_accepted entry never holds the gate. "The capture exists" and "a named human accepted it" stay two separate choices, which is why they are two values.
A held gate exits 1 with the reason code GATE_PRESERVED_ARTIFACT_EXPECTATION_UNMET; the violation's ids name the plan entries, and its text gives each entry's kind, minimum, matching-row count and state. The gate compared the rows declared for this run with a plan you wrote: it is not a judgement of the evidence, and the plan's own sentence inside preserved-artifacts.json ("It reaches no release gate …") describes a run without this policy. START-HERE.md's evidence-plan line names the key instead of "none reaches a gate" whenever the run's gate enforced it.
With the key set, a run that declared no plan, a plan with no evaluated entry, or a plan any entry of which was refused cannot be evaluated: the gate blocks with GATE_DATA_UNAVAILABLE rather than passing, because "no entry is below its minimum" over no entries is a statement about nothing. Declare the plan (or stop enforcing the key) to clear it.
The gate reads only the expectations block of preserved-artifacts.json as the run wrote it. No row, record or review decision of the custody lane enters the evidence pack, the approval digest, VEX or any requirement mapping, with the key on or off. store verify-gates and gate simulate replay the index the run recorded, so a release held on its plan verifies rather than reading as tamper. With the key off, every artifact of the run is byte-identical to a run without it.
What a file declares about itself#
Some preserved files state, in their own header, what produced them. A SARIF report names the tool and the source revision it ran against; a CTF metadata stream carries a trace UUID; a perf.data file records the kernel release and the architecture it was recorded on. Today an operator types those values into an association by hand, so the evidence records a transcription where the file carries the statement.
A reader exists here only for a format with a published, citable specification, and its row carries that citation. SBOMFlow reads no proprietary trace, dump or export format, and no format whose specification is paid or licensed. The file's name selects the readers that may run — its extension, or the whole name for a tool that writes a fixed one such as perf.data or a CTF trace directory's metadata — and the leading bytes confirm which one. A name no reader claims is read by nothing, whatever the file contains. One case is narrower than a claim. A name as common as .bin selects a reader only when that format's own marker sits in the file where the format's code puts it. Any other .bin reads exactly as if no reader existed, and a release run or custody set records no key for it; a file whose bytes do not confirm is an unreadable file of that format* and is never handed to another reader. Only fields the cited specification names are read. A value the file stores is recorded as extracted with the citation it was read under; a value SBOMFlow computed from the bytes (a count, a derived number, a position it searched for, a check's outcome) is recorded as computed, and a name it looked up in a pinned table for a stored number as looked_up. Which fields a reader derives is fixed in its registry row, and sbomflow validate refuses a derived value labelled extracted. Declared text is shown only when none of its code points is, in Unicode 16.0.0, a control character, a format character (such as a bidirectional control), a surrogate, a private-use character or unassigned. That table is pinned in the source rather than asked of the running Python, whose Unicode database changes between versions, so the same bytes are shown or withheld the same way on every supported interpreter; a withheld value names the code point and which of those it is. The same pinned table decides which characters a reviewer's note may not consist of alone and which the custody set's readable index escapes, so a note is accepted or refused, and a page is escaped, identically on every supported Python.
A format whose layout is defined only by open-source code, with no specification document, may be read on six conditions: the code is under an OSI-approved licence; the citation pins the exact upstream commit and names the defining file and its SHA-256; only header and identity fields are read; a version or layout the reader does not recognise is refused, never guessed; byte order comes from a marker in the file, never from the machine reading it; and the row itself says basis: source code at <commit> (the citation carries basis, the pinned source and a basis_meaning sentence). The row's meaning names the same ground, derived from basis: a source-code row says its values were read "under the cited source code", never "under the cited specification". Wherever a reader read a file, explain --preserved-artifact says it read only the identity fields that reader names and decoded nothing else, rather than that the file was not parsed; a file no reader read keeps "did not produce, parse or judge". The registry refuses a source-code row missing any of the first two, and a format that is neither published nor open stays an opaque, hashed file that no reader may claim. Its row says so: not_applicable, with a meaning that names the format as closed and says the hash, the custody record and the review are recorded all the same — in a run's preserved-artifacts.json and in custody show --json — so a closed format is never read as one nobody has written a reader for yet. .svdat (SEGGER SystemView) is such a format in this build.
A file of any size reaches its reader. A kept original is read range by range: each reader asks for the structures it needs — a header, a directory, a table, a note region, a zip's central directory and metadata members — bounded by its own named caps, and nothing else is loaded, so a minidump with memory, an ELF core, a perf.data with samples, a pcapng with packets or a bug report of tens of megabytes is read the same as its small fixture. Five formats must be parsed whole: a SARIF report (a JSON document), an LCOV tracefile (whose counts and comparison stand for the whole file), an AFL++ fuzzer_stats file, a DFM console capture (whose framing is checked line by line) and a ROS 2 bag's metadata.yaml. Each reads a file of at most 1 MiB (1,048,576 bytes); a longer one is unreadable with a reason naming both sizes, and none of it is parsed — never a prefix. A microcontroller load image — Intel HEX, S-record or UF2 — has no header stating its extent: the framing of every record is its identity, so its reader walks the whole file once, under a whole-file cap of 32 MiB (33,554,432 bytes) and a line cap of 1,024 bytes, and its row's meaning says every record was walked and no data byte recorded. A longer file is unreadable with both sizes; a longer line is a named malformed record. When an original truly cannot be handed to a reader, the row says why: it is not at its path any more, its path is a symbolic link (never followed), it is not a regular file, the operating system refused to open it, or a read failed at a stated offset. None of those rows describes a file by its size.
| Format | Specification cited | Read | Never read as a value |
|---|---|---|---|
| SARIF | Static Analysis Results Interchange Format (SARIF) Version 2.1.0 Plus Errata 01 | Tool name, version and semantic version; invocation start and end; automation id; version-control revision and repository URI | invocations[].machine, .account, .commandLine; any result, artifact or location |
| pcapng | PCAP Now Generic (pcapng) Capture File Format, draft-ietf-opsawg-pcapng-06 | Section header version and its hardware, OS and application options; the interface description blocks that follow it. A section of a major version other than 1 is refused by name, since the specification says a reader of one major version cannot read another | Anything at or past the first block that is not an interface description block |
| ELF package note | UAPI.8 Package Metadata for Executable Files 1.0 | GNU build id and the .note.package well-known keys | Anything outside the note; a core file is reported as not applicable here and is read by the ELF core reader below, which a core's name selects (.elf never does) |
| CTF 1.8 metadata, text or packet-based | Common Trace Format v1.8.3 (section 7.1 for both forms) | For a packet-based stream, the form LTTng writes (magic 0x75D11D57 in either byte order): the first packet header's byte order, UUID, CTF version (only 1.8 is read), packet_size, content_size and schemes, how many packets the walk reached and any bytes after the last packet, counted; the TSDL is read across packets exactly as the text form below, one packet at a time and only as far as the walk goes; a bad magic, a size that does not hold or a packet past the end of the file is refused naming the packet and its offset, and a declared compression, encryption or checksum scheme is named and nothing is decoded. For either form: the /* CTF version (the text form), trace { uuid }, trace { major }/{ minor }, and the env { } entries. The walk covers at most 256 KiB (262,144 bytes) and 4096 top-level constructs, and one block at most 64 KiB: a trace or env block past a bound is withheld under its own name as not reached, with the byte offset where the walk stopped and the bound that stopped it, and a block too long to capture is withheld with its length — never read as a stream that declares none. A stream that simply ends at the cap is not stopped. The walk stops at the first event block by design, so a trace or env block written after it is withheld as not reached with that block's offset, never read as absent; and a block with more than 128 entries says how many were left unread | Anything at or past the first event block; an env entry naming a host or a command line |
| CTF 2 | CTF2-SPEC-2.0: Common Trace Format version 2 | The preamble fragment's version and UUID; the trace class fragment's namespace, name, uid and environment. The walk covers at most 256 KiB and 8 fragments: a trace class fragment past a bound is withheld as trace-class not reached, with the byte offset and the bound, the preamble's values are kept, and a fragment the cap cut in two is not parsed (and not called invalid JSON). A fragment longer than 64 KiB (65,536 bytes) is not parsed: a preamble or trace class fragment that long is withheld under its own name with its length, and one of any other type ends the walk with trace-class withheld as not reached, naming the fragment's type. A type key written twice is refused as ambiguous at every length, as a parsed fragment's repeated key is; the first occurrence is never taken, and a trace class fragment whose end lies past the scan cap, where a repeat could hide, is not named | Anything in a data stream class or event record class fragment; an environment entry naming a host or a command line |
perf.data | perf.data format (Linux kernel, read at v6.12) | HEADER_OSRELEASE, HEADER_VERSION, HEADER_ARCH, and the build ids of HEADER_BUILD_ID, each at the length perf declares (20 bytes, or the record's size byte when PERF_RECORD_MISC_BUILD_ID_SIZE is set; a larger declared size is withheld as malformed, never clipped) | HEADER_HOSTNAME and HEADER_CMDLINE, each recorded as declared and withheld; the image path and pid inside each build-id record; every byte of the data section, where the samples are |
| Android bug report zip | Bugreport file format (AOSP dumpstate, android-15.0.0_r1) | version.txt, and the main entry's name from both documented spellings (main-entry.txt and main_entry.txt) | The main entry itself, systrace.txt, title.txt, description.txt and anything under FS/ |
AFL++ fuzzer_stats | The afl-fuzz approach (AFL++ v5.03c) | afl_version, target_mode, start_time | command_line, afl_banner and fuzzer_pid, each recorded as declared and withheld; every counter, because how many crashes a campaign found is a finding |
LCOV tracefile (*.info) | geninfo(1) section TRACEFILE FORMAT (LCOV v2.5, docs/man/geninfo.rst) | Each TN: test name, each SF: source path (an absolute build-host path is shown as outside-scan-root/<file name> with a note, never raw), each VER: source version ID, and counts of the records examined by kind | Every coverage record: DA, LH/LF, FN, BR, MCDC/MCF/MCH — nothing is taken from a hit or found count and no figure is computed from them; the # comment text |
Zephyr core dump (*.zephyr-coredump; coredump.bin as a name other dumps share) | Zephyr core dump documentation, section File Format (read at v4.4.2) | The 12-byte file header's identifier, header version, target code, pointer size, flags and fatal error reason, as the numbers the file carries; a header version other than 2 is refused by name before anything after it is read. The row states declares_no_build_identity: the format has no field naming the firmware or the build | The architecture, threads and memory blocks; no architecture or reason is named from a code |
Zephyr core dump printed on the console (*.zephyr-coredump-log; coredump.log, the documentation's own name, as a name other logs share) | Basis: source code at Zephyr v4.4.2: scripts/coredump/coredump_serial_log_parser.py (cited with its SHA-256), with include/zephyr/debug/coredump.h for the markers and the logging backend for the line length; the logging backend is Zephyr's default (subsys/debug/coredump/Kconfig); the header as the File Format section specifies it | Where the first #CD:BEGIN# line is (within the first 256 KiB), then, up to #CD:END# or #CD:ERROR CANNOT DUMP# and at most 8 MiB of log: how many #CD: lines, how many bytes they decode to, how many other log lines sit between the markers, whether the end or error marker was printed, and the 12-byte file header the decoded bytes begin with, read exactly as the zephyr-coredump row reads it. The marker is found behind any log prefix. A dump line that is not an even run of hexadecimal digits, or a second begin marker before an end marker, makes the log unreadable naming the line; a header version the header reader does not know is withheld by name while the framing still shows | Every log line outside the markers, every decoded byte after the 12-byte header (registers and memory), and the content of every #CD: line past the header, which is only checked to be hexadecimal |
CrashCatcher dump (cc_coredump.dmp, .crashcatcher; any other .dmp name, such as crash.dmp, trap.dmp or fault.dmp, as a name other dumps share, so the cC signature decides) | CrashCatcher README, section Dump Format (read at its pinned commit) | The cC signature, the major and minor format version and the flags word — eight bytes; a version other than 3.0 is refused by name before the flags word is read. The row states declares_no_build_identity: the format carries no build identifier, firmware version or timestamp, so the release link must come from your own declaration | The integer and floating-point registers, every memory region and the fault status registers |
Memfault-format MCU core dump (*.memfault-coredump) | The vendor's public documentation page, section Memfault Coredump Format (cited with the page's digest; the block type codes are derived from the page's ordered list of types and its example 0x01 (Memory Region), which the page does not state per type) | The header's magic, version and size, every block's type from its block header, and the footer's flags; the Build ID block's data as lowercase hex and the Software version block's data as strict UTF-8, both verbatim and at most 256 bytes (a longer block is withheld with its length; bytes that are not text are withheld as not_text); a count of blocks per documented type. The Build ID is recorded as declared and never compared with a release image's GNU build id, because the page does not say what kind of identifier it is. A frame that fails anywhere is unreadable with the offset | Every other block's data: registers, memory regions, the device serial, the hardware version and the rest are counted and never read |
ELF core file (core, core.<pid>, *.core, vmcore — names other formats share, so a file under them that is not ELF reads not applicable) | The Linux elf(5) manual page for the note header, with the System V gABI chapters 4 and 5 for ET_CORE, e_machine and PT_NOTE (each cited with the digest of the copy read). The gABI's note text says a 64-bit object's note entries are 8-byte words; the reader does not follow that sentence and says so in its citation, because elf(5) defines the layout Linux cores are written in (three 4-byte words, 4-byte alignment) | e_type, the class and data encoding (ELFCLASS32/ELFCLASS64, ELFDATA2LSB/ELFDATA2MSB), e_machine as its number with the gABI name beside it only where the gABI table lists the number, and for each note in file order its owner name (strict text, bounded), its type number and its descriptor size. A note that runs past its segment is unreadable with its file offset; a note segment declaring an alignment other than 0, 1 or 4 is not walked and is counted as not read; more than 2048 notes are listed to the cap and the cap is stated with the offset where the walk stopped. The row states declares_no_build_identity. In an ESP-IDF core, the one ESP_CORE_DUMP_INFO note's 72-byte descriptor: its version and the application ELF's SHA-256 prefix, under the ESP-IDF release pinned for that version. An owner name whose namesz counts NUL padding after the terminator (ESP-IDF before 2025) is read, and its note says so (see "Did this ESP-IDF crash come from the application ELF the release declares?") | Every other note descriptor and every byte of a loadable (PT_LOAD) segment — so no process name, register set, mapped-file list or mapped object's build id is recovered; no note type is ever named, because the gABI leaves types to each owner and the manual page that names core note types gives no numbers |
ESP-IDF core-dump image (*.bin, read only when an ELF core begins at offset 12, 20 or 24, where the header puts it; the core-dump partition as esptool read_flash reads it back, or ESP-IDF's base64 output decoded) | Basis: source code: Espressif's own decoder, espressif/esp-coredump esp_coredump/corefile/loader.py (Apache-2.0, pinned by commit and SHA-256), which defines every version's header and checksum, with the ESP-IDF writer's header for each generation (v4.4, v5.3 and the pinned current source) cited beside it | The header's total length, the version word (the chip id in its high 16 bits, as a number; the data format and checksum in its low 16), the chip revision where the header carries one, the header size and checksum algorithm the version names, whether the stored CRC-32 or SHA-256 holds over the header and data (computed exactly as the decoder computes it, over at most 16 MiB; a cut or longer image says why it was not checked), and the bytes after the image (an erased partition's tail); then the ELF core between the header and the checksum, read by the ELF core reader unchanged, so its machine, note headers and ESP_CORE_DUMP_INFO prefix appear and are compared exactly as for a saved core. A version whose header size does not put the ELF where it is found, and the older binary (non-ELF) data format, are refused by name | Every byte of the dump data except through the ELF core reader's own bounded reads; the checksum hashes the bytes and decodes none of them. A binary-format image is recorded by its digest only |
Minidump (.mdmp; .dmp as a name other dumps share, so a .dmp that is not a minidump reads not applicable) | Basis: source code at Google Breakpad commit 868abe6f016cfeabfb7208681e89031ae667c336, src/google_breakpad/common/minidump_format.h (BSD-3-Clause, cited with the file's SHA-256) for the signature and version values, the string and fixed-file-information layouts, the 108-byte module size and the CodeView layouts MDCVInfoPDB70, MDCVInfoPDB20 and MDCVInfoELF (each cited by line at that commit), which the vendor's minidumpapiset.h reference does not state; that reference (each page cited with the digest of the copy read) for the header, stream directory, stream type numbers and module list | The header's signature, version (its low word must be 42899, or nothing after it is read), implementation word, stream count, time_date_stamp (a number, never turned into a date) and flags; a count of directory entries per stream type number; and for each module in the one module list its file name only (strict UTF-16, at most 1024 bytes; the directory part of the declared path is never shown), size, timestamp, the four version words of its fixed file information (only under the pinned signature and structure version), and the identity fields of its CodeView record (at most 512 bytes) under the layout the pinned header defines for the record's signature: for an RSDS record (MDCVInfoPDB70, the common form) its GUID, its age and the PDB file name only; for an NB10 record (MDCVInfoPDB20) its 32-bit signature (a number, never a date), its age and the PDB file name only; for an ELF record (MDCVInfoELF) its build id as lowercase hex, declared and never compared with a release image's build id. A record whose signature the header names without a layout records that signature and its byte count; any other record records its byte count only. The PDB path a record declares is usually a directory on the build machine, so only the part after its last / or \ is shown and the directory is never written anywhere. A file beginning PMDM (the other byte order) is refused by name; a module list whose size is not the pinned layout, more than one module list, more than 4096 streams or modules, and any structure or stream location that runs past the end of the file are unreadable with the offset; a module name or CodeView location that lies inside another stream's data is withheld with that stream's offset, and none of it is read | Every other stream's data — threads, memory, exception, system information and application streams are counted, never read — and within a module its base address, checksum, miscellaneous record and reserved fields; no CodeView record's bytes, and no directory of a declared PDB path |
TraceRecorder streaming trace (trace.psf; *.psf as a name other formats share, so a .psf that is not a streaming trace reads not applicable) | Basis: source code at the TraceRecorder library's commit c3055e4b6ae5627407b002e200f769596b7cfe01, TraceRecorder/trcStreamingRecorder.c (Apache-2.0, cited with the file's SHA-256) for the header's fields, its format version and its byte-order identifier, with include/trcRecorder.h and each kernel port's header cited by digest; no specification document exists. The byte offsets are derived from the field order and sizes, and were checked against the compiler's offsetof in a host build of the pinned library | The byte order, taken only from the identifier's four bytes; the format version (anything but 0x000e is refused before anything after it is read); the kernel id, named only through the pinned kernel ports (FreeRTOS, BareMetal, ThreadX, Zephyr — any other id is shown as the number and said to be outside the table); the option word and the three bits the code names (IRQ priority order, test mode, 64-bit); the core count and the stream port's multistream value; the platform definition's name (at most 8 bytes, strict printable ASCII) and its major.minor.patch version. Option bits or core values the code never writes are withheld with the reason. Then, only in a one-core recording whose kernel is not ThreadX, and only when the start event the pinned code writes next (0x1001) is found right after the declared entries, before any entry is read: the timer frequency and OS tick rate from the timestamp block (timestamp_frequency_hz, os_tick_rate_hz); the used-entry count and a count of entries per class, from each entry's option word (entries_object, entries_extension, entries_state_machine and so on); and each extension's name and major.minor.patch version (extension_name[N], extension_version[N]). Otherwise timestamp_and_entry_table is withheld with the reason and the header stands. These offsets are derived from include/trcTimestamp.h and include/trcEntryTable.h (each cited by SHA-256) and checked against the compiler's offsetof in the host build and a 32-bit ARM layout probe. The row states declares_no_build_identity: the header names no firmware, build or library release | The ISR tail-chaining threshold inside the header; the timestamp block's type, period, wraparound count, latest timestamp and tick count; every entry's address and states (an extension's version aside); every entry name but an extension's — the names an application gives its tasks, interrupts, queues and other objects are user text, never requested, and only counted (entry_names is withheld with the count); and every event after the start event's code. A .psfs RAM image is read by the stream-port buffer row below instead, because the header does not sit at its start |
TraceRecorder RingBuffer snapshot (a .bin whose 12 start markers 05 06 07 08 75 76 77 78 f5 f6 f7 f8 are at offset 4, such as trace.bin saved with dump binary value trace.bin *RecorderDataPtr; any other .bin is not examined further) | Basis: source code at percepio/percepio commit c3055e4b6ae5627407b002e200f769596b7cfe01 (TraceRecorder v4.12.0), TraceRecorder/streamports/RingBuffer/include/trcStreamPort.h (Apache-2.0, cited with the file's SHA-256) for TraceRingBuffer_t, with trcStreamPort.c for the markers and the streaming header's own source for the header inside it | The streaming header inside the ring buffer, read with the same rules as a .psf (byte order from its identifier, format version 0x000e or refused by name, kernel id, option bits, core count, platform definition name and version), and whether the 12 end markers are where the file's declared entry-table and event-buffer sizes put them. The note shows the derivation and states its one assumption: pointers are as wide as the recorder's base type. Otherwise the row says the position was not established. The row states declares_no_build_identity | The unwritten reserved words, the timestamp block, every entry-table slot (object names live there) and every event |
TraceRecorder stream-port buffer (dfm_trace.psfs, the payload DFM attaches; *.psfs as a name other formats may share) | Basis: source code as above, for TraceStreamPortBuffer_t and xTraceGetEventBuffer() | The same values. The ring buffer follows one pointer per core, so its start markers are searched for from offset 4 at 4-byte steps within the first 1040 bytes. The row records the offset found and says it was found by searching. The file is refused when that offset contradicts the core count and word size the header declares | The per-core pointers, which the search passes over and never interprets, and everything the RingBuffer row never reads |
TraceRecorder legacy snapshot RecorderData (v4.10.3 and earlier; a .bin that begins with the start markers 01 02 03 04 71 72 73 74 f1 f2 f3 f4, or dfm_trace.trc) | Basis: source code at percepio/TraceRecorderSource tag Tz4/4.10/v4.10.3, commit 225720467bd286267377d588e8581dc337abc4c8, include/trcRecorder.h (Apache-2.0, cited with the file's SHA-256), with trcSnapshotRecorder.c for how the head is filled | The byte order and kernel id from the version word's two bytes. At that tag only the FreeRTOS port (0x1aa1) defines snapshot mode, and any other pair is refused. Also read: the minor version (anything but 7 is refused by name), the IRQ priority order, the declared filesize, the 16-bit object handle setting, and whether the end markers are at filesize - 12. The row states declares_no_build_identity | The event counters, clock and heap figures, the object property table, the symbol table, the system info text and every event. A RAM dump with the structure somewhere inside it is not searched |
DFM alert entries (*.dfm-alert: one entry, or the concatenated entries a receiver, storage port or file keeps) | Basis: source code at percepio/percepio commit c3055e4b6ae5627407b002e200f769596b7cfe01, DFM/dfmEntry.c (Apache-2.0, cited with the file's SHA-256), with include/dfmAlert.h, dfmAlert.c, include/dfmTypes.h and include/dfm.h cited by digest and line. The code is followed where its own layout comment disagrees with it: the device name sits between the session id and the description | Byte order from the 0x0FF0 marker; entry version (only 1) and count; per entry (the first 16 listed, up to 64 walked) its type, alert id and chunk index/count, and a payload header's payload name; for the first alert its session id, device name and description (strict printable ASCII, NUL-terminated within their declared sizes, at most 256 bytes); in a file holding several alerts, every later alert's firmware version too, as firmware_version[N] (N its entry number), read under the same header checks, so declared_version_context covers every alert in the file; from the first alert's header (only version 3) the product, alert type, symptom count, maximum symptom count, the firmware version string — shown as declared by the device build, unverified, never matched or verified, set beside the release's stated version only as declared_version_context context — and the checksum word, shown and not verified because the pinned checksum function returns 0. The firmware version's position comes from the header's own symptom-slot and field-size bytes, never an assumed configuration; a header whose sizes do not describe its data is withheld with the offset. Every entry is checked end to end — markers, version, type, chunk rules, sizes within the file, reversed footer — before the next is read | The symptom ids and values, the alert's own copy of the description, a payload header's struct and every payload byte (the crash dump or trace the alert carries) |
DFM Serial console capture (*.dfm-alert-log, kept from the port's first [[ DevAlert Data Begins ]] line) | Basis: source code at the same commit, DFM/cloudports/Serial/dfmCloudPort.c (cited with the file's SHA-256; the copy in percepio/demos differs only in its line ending), with the entry sources above | The framed hex, decoded one entry per block, then read as a .dfm-alert file is, plus the block count, the count of console lines outside the framing and the checksums the end lines print (the pinned port prints 0). \n and \r\n line ends are accepted. A framing error — a begin inside a block, an end or data line outside one, a malformed data or end line, a data line shorter than 20 bytes before another, an empty block, a capture ending inside a block, a block over 0xFFFF bytes, more than 64 blocks, or a block that is not exactly one entry — makes the capture unreadable with the error's name and line | Every console line outside the framing, which is counted and never interpreted; a capture with other output before its first begin line is not selected (.log and .txt are never claimed) |
Intel HEX (.ihex, .ihx; *.hex as a name plain hexadecimal dumps share, so a .hex that does not begin with : reads not applicable) | Intel, Hexadecimal Object File Format Specification, Revision A, January 6, 1988 (cited with the digest of the copy read) | Every record walked: a count per record type (data, end of file, extended segment address, start segment address, extended linear address, start linear address), checksums that hold and do not (each failing record named by line), malformed records named by line with the reason (a missing record mark, odd or non-hex digits, a RECLEN the record does not carry, an undefined record type, a wrong INFO length), the end-of-file record's line and any record after it, the start linear address or the CS and IP a start segment record states, and the merged, sorted address ranges the applied data records load, with byte counts, overlapping bytes and conflicting bytes. Addresses follow the specification's linear ((LBA + DRLO + DRI) MOD 4G) and segmented (SBA + ([DRLO + DRI] MOD 64K)) rules. A record whose checksum does not hold is not applied | Every data byte: the ranges say where bytes load, never what they are |
Motorola S-record (.srec, .s19, .s28, .s37, *.mot) | Motorola, M68000 Family Programmer's Reference Manual (1992), Appendix C S-Record Output Format (cited with the digest of the copy read) | Every record walked: a count per defined type (S0, S1, S2, S3, S5, S7, S8, S9), the data records counted and the count each S5 states (both shown, not reconciled), checksums that hold and do not, malformed records named by line (S4 and S6 are not defined in Appendix C and are named, never read), the S0 header's text as the producer's descriptive text, the termination record's line and the start address each S7, S8 or S9 states, and the merged address ranges with byte counts and overlaps | Every data byte |
UF2 (*.uf2) | The microsoft/uf2 README, sections File format, Flags and Family ID, pinned at commit 90e9741f217f5a40c98ba74d663e408041037578 (cited with the README's digest) | Every 512-byte block walked: blocks by flag (not main flash, file container, family ID present, MD5 present, extension tags present), malformed blocks named by number and offset (a wrong first, second or final magic number, a payload over 476 bytes, a cut-short last block), each family ID with the short_name the family list at the same commit gives it (a number the list does not hold is shown alone), the block totals stated, missing, repeated and out-of-range block numbers per family, the payload sizes, and the merged address ranges of the blocks written to main flash | Every data byte; a not-main-flash or file-container block is counted and never applied |
PX4 ULog flight log (*.ulg) | ULog File Format, the PX4 Guide page that calls itself the format's specification (read at a pinned PX4-Autopilot commit, cited with the document's digest) | The header's version and timestamp (microseconds, as the page defines it); the Flag Bits message's DEFAULT_PARAMETERS and DATA_APPENDED bits — an incompatible bit the page does not define refuses the log, as the page requires; and the definitions section's Information messages the page predefines as identity: sys_name, ver_hw, ver_hw_subtype, ver_sw, ver_sw_branch, ver_sw_release and ver_os_release (with the version and release type the page's 0xAABBCCTT form gives), sys_os_name, sys_os_ver, sys_toolchain, sys_toolchain_ver and sys_mcu, each at the length its key declares. ver_sw — a git hash in PX4's logs — is recorded as declared and never compared: the page calls it the software version (git tag), not a source revision or a GNU build id. A version-0 log (older PX4, before the Flag Bits message existed) is read as the pinned reference parser reads it (basis: source code, PX4/pyulog pyulog/core.py, BSD-3-Clause, cited on the version's own note): the same header, the definitions walked from the byte after it, flag bits reported only when the log carries them; any other version is refused by name; a key declared twice is withheld as ambiguous; a log cut inside its definitions keeps what was read and says where it ended | sys_uuid (the vehicle's identifier), withheld with its reason; the body of every format, parameter, default-parameter and multi-information message (a boot console among them) and the value of every other information key; the whole data section, where the flight data is. Keys PX4 writes that the page's table does not name (sys_os_ver_release, ver_data_format) are not read |
MCAP recording (*.mcap) | MCAP Format Specification (foxglove/mcap, read at a pinned commit, cited with the document's digest) | The magic's major version (only 0; another is refused by name before anything after the magic is read); the Header record's profile and library; the Footer's summary_start and summary_offset_start; the number of Metadata Index records; and each Metadata record the summary indexes (at most 16, 64 entries each), its name and entries under the producer-convention note, found through the Summary Offset section or by stepping over summary record headers whose contents are not requested. A record's prefix is checked before its content is requested, so an index that names another record cannot read it. The row states declares_no_build_identity. A file that does not end with the magic — a recording that stopped before it finished writing — keeps its header values and says its footer was not read; a file with no summary section says its metadata was not looked for | Every Message, Chunk, Schema, Channel and Attachment record; the data section is never walked; a metadata entry naming a host, a command line or a serial number, withheld with its reason |
ROS 2 bag metadata.yaml (a name other tools share, so a file under it that is not a rosbag2 document reads not applicable) | Basis: source code at ros2/rosbag2 commit d51dd4227726cb7cfdcd4a715a6ea97a382a1ec4, rosbag2_storage/include/rosbag2_storage/yaml.hpp (Apache-2.0, cited with the file's SHA-256), with bag_metadata.hpp cited by digest | version (1 to 9, the versions the pinned decoder reads; another is refused by name), storage_identifier, duration.nanoseconds, starting_time.nanoseconds_since_epoch, message_count, the topic count and each topic's name and type (at most 256), and ros_distro from version 8, where the decoder reads it. Parsed with the bundled strict YAML loader on every install, so a file reads the same whether or not the YAML extra is installed; a long string rosbag2's own writer (yaml-cpp) folds over several lines in double quotes is read as YAML 1.2.2 section 7.3.1 defines it, and a document the loader cannot read (a multi-line single-quoted scalar, an unknown escape, a continuation line not indented past its key) is unreadable with the loader's reason, and a key written twice is refused. The row states declares_no_build_identity | custom_data, relative_file_paths and files, and each topic's QoS profiles, serialization format, type hash and message count; the bag's storage files are never opened |
Eclipse ThreadX trace buffer (*.trx, a name other formats share, so a .trx that does not begin with the ThreadX trace ID reads not applicable) | Basis: source code at eclipse-threadx/threadx tag v6.5.1.202602_rel, commit 87ab09cce305eb9cd4aacac4e8c62af72f665bff, common/inc/tx_trace.h (MIT, cited with the file's SHA-256) for TX_TRACE_VALID, the Trace Control Header table and TX_TRACE_HEADER, with common/src/tx_trace_enable.c cited by digest for how the header is filled; no specification document exists. The byte offsets are derived from the field order and sizes, and were checked against the compiler's offsetof in a host harness over the pinned code and on 32-bit little- and big-endian ARM against the pinned Cortex-M3 GNU port | The 48-byte control header: the byte order from the ID 0x54585442's own bytes (42 54 58 54 little endian, 54 58 54 42 big endian), the timer valid mask, the trace base address, the registry start and end pointers, the object name size, the event area's start, end and current pointers, and three derived numbers: the registry entry count, the event capacity and the declared extent, with whether the file holds that extent (from its size, not its bytes). The header carries no version, so the reserved words 0x0000, 0xAAAAAAAA, 0xBBBBBBBB, 0xCCCCCCCC and the pointers' layout stand in for one; a header that departs from them, one written with an 8-byte ULONG among them, is refused naming the check. The row states declares_no_build_identity | Every registry entry and object name and every event; nothing at or past byte 48 is requested |
trace-cmd trace.dat (trace.dat, a name other files share; any other *.dat only when it begins with the trace-cmd magic) | trace-cmd.dat.v7(5) and trace-cmd.dat.v6(5), the trace-cmd project's manual pages at tag trace-cmd-v3.4, commit 3ce20923b3efa60d417da7acc8a327615fbd1419 (each cited with its SHA-256). The UNAME value's four fields are split as the pinned writer (tracecmd/trace-record.c, cited by digest and line) joins them, which the page does not state | The magic, the file version (6 or 7; any other is refused by name), byte order, long size and page size; for version 7 the compression algorithm's name and version and, walking the options sections by their declared sizes (at most 64 sections and 4,096 options, never twice through one), the UNAME option's system name, kernel release and machine, the VERSION option (the trace-cmd application's version) and the TRACEID option. Version 6's page defines no identity option, so nothing after its page size is read | The host name in UNAME, withheld; a UNAME value that is not the writer's four fields, withheld whole; every other option's value; the header info, event formats, kallsyms, printk formats, saved command lines and every trace buffer; a compressed options section is never decompressed |
Perfetto trace (.perfetto-trace, .pftrace) | Basis: source code at google/perfetto tag v58.3, commit 11fbaed836361258f23604daf67c837f3034e663, protos/perfetto/trace/trace_packet.proto (Apache-2.0, cited with the file's SHA-256) for system_info 45, trace_uuid 89 and the compressed packet fields, with trace.proto, common/system_info.proto and trace_uuid.proto cited by digest and line; the wire format under the Protocol Buffers encoding guide, pinned at protocolbuffers/protocolbuffers.github.io commit c00537c22ece49df397a05586e77af033991ab85 | From the first SystemInfo packet, utsname's sysname, version, release and machine and android_build_fingerprint (strict printable ASCII, at most 512 bytes each, last value winning and utsname records merging as the encoding guide says); from the first TraceUuid, msb and lsb as 64-bit hex; a count of packets examined, of compressed bundles passed and of further identity packets. A bounded walker reads one tag at a time and passes every other record by its wire type and length; a group or undefined wire type, an overrunning length or an overlong varint stops the walk and is named. It examines at most 1,024 packets and 16,384 records, and only packets starting before 8 MiB | Every other SystemInfo field (the tracing service's version, hardware models, the serial console, a machine name), the trace configuration, every event, track and interned string, and every later packet; a compressed bundle is never decompressed |
ESP-IDF application image (a .bin whose descriptor magic word 32 54 CD AB is at offset 32; any other .bin reads as before) | Basis: source code at espressif/esp-idf commit 4d59230ddff1… (Apache-2.0): esp_app_desc.h (sha256 452c93fd…) for esp_app_desc_t, esp_app_format.h for the image and segment headers, and the App Image Format page for the fixed offset (see "Does this ESP-IDF image name the application ELF the release declares?") | The image header's magic and segment count and the first segment's length, checked, then the descriptor's app version, project name, compile time and date, ESP-IDF version and the application ELF's SHA-256 (esptool writes it at 0xb0); bytes 0 to 288 only. A wrong image magic, a big-endian descriptor magic or a first segment shorter than 256 bytes is refused by name; an all-zero or Linux-placeholder digest is withheld as not stated | secure_version, the eFuse revisions, the MMU page size, the flash mode, the chip id, every segment byte after the descriptor, the checksum and any appended hash or signature |
Arm debug and trace snapshot snapshot.ini (a name other tools share, so a file under it whose first 4 KiB hold no [snapshot] header reads not applicable) | Debug and Trace Snapshot File Format, Arm document ARM-ECM-0611873 v0.2, published as an open standard (the copy OpenCSD distributes, cited by commit and SHA-256) | version (only 1.0, the one the specification defines; any other is refused by name), the device files [device_list] names and their count, the cluster names and their count, and the trace metadata file [trace] names | The free-text description; a path that is absolute or climbs out with .. is withheld; no file a path names is opened |
Arm debug and trace snapshot device file (any .ini whose first 4 KiB hold a [device] header, read only when [device] is its first section and its class is core, trace_source or memory_space; any other .ini reads as before, or not applicable) | The same specification, §3.2 | The [device] section's name, class and type (a core such as Cortex-M55, or a trace protocol such as ETM4 or ITM), read in 4 KiB chunks only up to the end of that section | [regs] register values, which are never requested; [dump*] memory regions; location |
Arm debug and trace snapshot trace metadata trace.ini (a name other tools share; confirmed by a [trace_buffers] header) | The same specification, §3.3 | Each trace buffer's id, name, format (coresight, source_data) and file list, and every [core_trace_sources] and [source_buffers] association in order (a key repeated there is a second association and both are kept), with their counts and a count of sections the specification does not define | The trace buffer bytes and any memory image; a buffer's size, which the file does not state (the specification defines it as the sum of its files' sizes, and no file is opened to find it) |
| Zephyr CTF | (registered as declaring no identity) | Nothing | The CTF metadata Zephyr ships declares the CTF version and byte order only — no trace UUID and no environment block — so the row says so rather than staying silent |
Three things this is not. The value is what the file says, not something SBOMFlow verified: a file can declare whatever its producer wrote into it. Comparing a file's declared source revision with the one a release declares is an observation about two statements, never a conclusion about which is right. The release side is the revision you declare as release.source_revision in sbomflow.yaml or with --source-revision; with neither, the comparison reads not_declared. When the two disagree the row reads inconsistent and the run prints the preserved_artifact_file_identity_differs warning, which is recorded in preserved-artifacts.json and on stderr only — it reaches no gate, evidence pack or approval digest, and it suspends no decision. An LCOV tracefile's VER: values are compared the same way, one row per declared version; a tracefile that declares no VER: gets no such row. Its declared source paths are held against the source paths the release run's own scan observed, which the run records as observed_source_paths in preserved-artifacts.json: the row reads counted with two counts and both totals — declared paths among the observed ones, the observed total, the declared total — and lists the declared paths that are not among them. No figure is made from those counts. Read outside a release run (custody show), or against a set that is not complete, it reads not_established with the reason. A line that has no form the specification gives is counted and named under withheld as malformed_records, and the readable records around it are still recorded. And a field that cannot be read safely is reported as withheld with its reason, never dropped — a field that vanishes reads as a file that declared nothing.
Where a value's name is a producer's convention rather than something the specification defines, the note travels with the value. CTF 1.8 says of its environment block that "the field names and content are implementation-defined", and CTF 2 says of its environment object that the document "doesn't specify what an environment entry exactly is" — so a tracer's habit of writing tracer_name is not a field either format defines, and it is not presented as one.
Where the row appears, as file_declared_identity beside the file's digest: custody show --json and custody verify --json carry it for a custody set's kept originals, and a release run's preserved-artifacts.json carries it for an artifact declared in that run's own configuration together with its original. A custody-set row joined into a run carries it too when the set keeps the original and the run verified it: the block is read by the same reader custody show uses, so it is the block custody show prints, except that its comparisons are made against the run's declared source revision where the set, which declares none, reads not_declared. A joined row whose original the set does not keep, or whose kept original the run could not read or match, carries no block and reads the run's own original_verification state. A custody set's index file does not carry it, and a run joining the set writes nothing into the set. Nothing here changes the release gate, and no declared value becomes a finding.
Was this trace taken on the build being released?#
A trace or dump that declares a GNU build id can be held against the release it is filed under. Of the formats read above, perf.data is the one whose specification defines one (a build id per mapped executable in HEADER_BUILD_ID); most trace and crash formats carry no build identity at all, and a firmware or application version string is free text whoever built the image typed. This applies to any team and any tool: a format whose reader names a build-id field joins the same comparison.
Every row that carries file_declared_identity also carries build_id_match beside it:
state | When | What else the block says |
|---|---|---|
matched | a declared (or derived) build id equals the GNU build id of a firmware image the run declares and read (or derived) | matches[]: the id, the image's path and the run's own firmware-image state for it (in_release when the image is a file the scan hashed); build_id_origin: derived on a match reached through the original's derived id, and image_build_id_origin: derived with image_build_id_basis on one reached through an image's |
not_matched | ids on both sides, none equal | declared_build_id_count and release_image_build_id_count |
not_established | one side had nothing to compare | reason: original_identity_not_read (also for a file no reader selected or confirmed), original_format_closed (a closed format such as .svdat: nothing in it was read), original_declares_no_build_id (only for a file a reader read), original_build_id_withheld, run_declares_no_firmware_image, declared_images_carry_no_build_id or, for custody show/verify, custody_set_is_not_a_release |
The comparison is exact: full-length, case-normalised hexadecimal equality, so a prefix of an id is a different id. The release side is the GNU build id of each firmware image declared as firmware_image: on any row of the run.
A derived build id. An Intel HEX, S-record or UF2 file declares no build id. When one is identical, byte for byte, to its paired ELF (see Is this load image, byte for byte, its ELF's load image? below) and that ELF's note walk finished and found a GNU build id, the id is carried across, labelled derived with the basis "load image identical, byte for byte, to the paired ELF <name> (sha256 <hex>), whose GNU build id is <hex>" — never labelled declared. On the original's side it is listed under derived_build_ids with derived_build_id_count, and declared_build_id_count does not count it; on the release side a firmware_image declared as a load image gains elf_load_image_match in its declared_by_file entry, and a match on its derived id says image_build_id_origin: derived with image_build_id_basis. Every other comparison state — differs, coverage on one side or both, not_comparable — carries nothing, and validate recomputes the derived id from the row's own comparison, so a hand edit cannot carry one across a difference. A row with nothing derived writes exactly the keys it wrote before. Only the field the reader names as its build id is compared (the readers table in Supported inputs lists it); every other declared value, including a version equal to the release version or a string that happens to spell an image's build id, is listed under declared_strings as declared and is never matched or verified. A match means the two files name the same build. It does not show that the capture was taken on a device running that image, and nothing in the block reaches the release gate, the evidence pack, the approval digest, a VEX status or a review decision. The reviewer console shows the state in the row's run checks.
Does the version the file declares agree with the release's version?#
Some formats declare the version of the build they came from: a DFM alert its firmware version (DFM_CFG_FIRMWARE_VERSION), a Memfault-format core dump its Software version block. That is free text whoever built the image typed, so it is never verified and never a match. A release run sets it beside the version the release states, as context only (decided 2026-09-26 under the owner's authorization). The release side is the product version the run states: product.version in sbomflow.yaml, or --product-version in its place — the value run_checks.release_consistency calls release.release_version. A placeholder such as the 0.0.0 default or an init scaffold value is not a statement and counts as absent.
Every row whose reader names a declared version field (the last column of the readers table in Supported inputs: today dfm-alert, dfm-alert-serial-capture and memfault-coredump) carries declared_version_context beside file_declared_identity; no other row gains the key.
state | When | What else the block says |
|---|---|---|
agree | every declared value equals the release version | declared_field, declared_versions and release_version, each verbatim |
differ | a declared value does not equal it | the same keys |
not_comparable | either side is absent or empty, or one of several declared values was withheld | reason: original_identity_not_read, original_declares_no_version, declared_version_withheld, declared_version_empty, run_states_no_release_version or, for custody show/verify, custody_set_is_not_a_release |
The rule is exact string equality after removing leading and trailing whitespace from each side, and nothing else: v2.4.1, 2.4.1.0 and 2.4.01 all differ from 2.4.1, and RC1 differs from rc1. Both values are recorded as written. agree means the two strings are equal, not that the file came from this release; differ is not a finding. No warning is raised for any state, and nothing in the block reaches the release gate, the evidence pack, the approval digest, a VEX status or a review decision. validate recomputes the state from the two recorded values and refuses a block whose declared values are not the ones the identity block carries, and an agree or differ beside an identity that withholds a declared version. A file that declares several versions (a DFM file holding several alerts) is compared on every one of them or on none: agree is never read from part of it.
Is this load image, byte for byte, its ELF's load image?#
A microcontroller release ships an Intel HEX, S-record or UF2 file and keeps the ELF it was built from, which carries the GNU build id and the symbols a trace or crash dump is read against. Every row whose original one of those three readers read carries elf_load_image_match beside file_declared_identity; no other row gains the key, and a custody-set row (whose original has no directory of its own) does not either.
Pairing is one rule, named in the block (pairing: same_stem_same_directory, with pairing_rule): the ELF is the file in the image's own directory whose name is the image's name with its last extension replaced by .elf, .axf or .out. Exactly one such regular file must exist; a symbolic link is never followed and nothing else is searched. images/zephyr.hex pairs with images/zephyr.elf; zephyr.signed.hex pairs with zephyr.signed.elf, not zephyr.elf. No flag or config key names a pair.
The ELF's load image is every PT_LOAD segment's file-backed bytes (p_filesz, never p_memsz) placed at its physical address p_paddr, which is where objcopy puts them: .data runs from RAM and is loaded from flash, so a layout by virtual address would call a faithful image different. .bss carries no file-backed bytes and is never compared.
state | When | What else the block says |
|---|---|---|
identical | both place exactly the same bytes at exactly the same addresses | elf, elf_sha256, elf_load_segment_count and the six counts below |
differs | some address both place holds different values | the counts and first_differing_address |
image_covers_ranges_elf_does_not | every shared address agrees and only the image places more | the counts, first_image_only_address and image_only_fill (a single byte value such as 0xff, or mixed) |
elf_covers_ranges_image_does_not | every shared address agrees and only the ELF places more | the counts and first_elf_only_address |
ranges_differ_both_ways | every shared address agrees and each places addresses the other does not | the counts and both first addresses |
not_comparable | nothing was compared | reason: image_not_read, image_unavailable, image_has_malformed_records, image_has_checksum_failures, image_has_no_end_record, image_has_records_after_end, image_loads_no_bytes, image_has_conflicting_overlaps, image_names_several_families, image_block_numbers_incomplete, no_paired_elf, several_paired_elfs, paired_elf_is_symlink, paired_elf_unreadable, paired_elf_over_cap, paired_file_is_not_elf, paired_elf_structure_unreadable, paired_elf_has_no_load_image, paired_elf_loads_no_bytes, paired_elf_load_over_cap, paired_elf_load_over_page_cap or paired_elf_has_conflicting_overlaps |
The counts are image_byte_count, elf_byte_count, common_byte_count, differing_byte_count, image_only_byte_count and elf_only_byte_count. A UF2 file written by uf2conv.py pads each 256-byte page with 0xFF, so beside an ELF whose bytes it holds exactly it reads image_covers_ranges_elf_does_not with image_only_fill: 0xff; SBOMFlow shows that and does not decide that the padding does not matter. An image with a damaged record, a missing end record or a byte loaded twice with different values is never compared, because which bytes a device ends up holding is then not established.
Its meaning says the comparison concludes nothing about which build shipped: identical bytes do not show the image was flashed, and a difference does not say which file is right. It reaches no gate, evidence pack, approval digest, VEX status or review decision, and validate refuses a block whose state does not follow from its counts, whose counts do not add up, or whose pairing is not the one rule.
Does this crash come from a machine the release targets?#
An ELF core's e_machine is compared with the machines of the release's own images. The release side is the firmware images the run declares as firmware_image: on any row and read as ELF — the same declarations the build-id comparison uses — each by its e_machine number. Every row whose original the ELF core reader read carries machine_comparison beside file_declared_identity; no other row gains the key.
state | When | What else the block says |
|---|---|---|
consistent | the core's e_machine number equals the number of at least one declared image | dump_e_machine, release_images[] (each image's path, e_machine and the run's own firmware-image state), agreeing_image_count |
inconsistent | it equals none of them | the same keys, with agreeing_image_count 0 |
not_comparable | one side had nothing to compare | reason: dump_machine_not_read, run_declares_no_firmware_image, declared_images_machine_not_read or, for custody show/verify, custody_set_is_not_a_release |
Its meaning is: the dump's declared machine and the release's observed images' machines agree or not; SBOMFlow did not establish that this dump came from this release. A core taken on an application processor is consistent with a release that also ships firmware for another processor, as long as one declared image shares its machine. The comparison reaches no gate, evidence pack, approval digest, VEX status or review decision, and validate refuses a block whose state does not follow from the numbers it carries.
Did this ESP-IDF crash come from the application ELF the release declares?#
An ESP-IDF core dump is kept in one of two forms: the image ESP-IDF stores in its core-dump partition (a header, the ELF core and a checksum), read back with esptool read_flash as any .bin, or the ELF core alone, saved under a name the ELF core reader reads (core, *.core). The image is read by the esp-core-dump-image row, which checks its checksum and reads the ELF core inside it with the ELF core reader. Either way the ELF core carries one note that reads past its header: the ESP_CORE_DUMP_INFO note, type 8266. Its descriptor states the core-dump version (the chip id the firmware was built for in the high 16 bits, as a number; no chip is named) and the leading hexadecimal characters of the SHA-256 of the application ELF the device was running — 9 by default, 8 to 64 by configuration. No specification document defines the note, so it is read under the rule for code-defined formats: the defining ESP-IDF code (Apache-2.0) is pinned by commit and by each file's SHA-256, and every field read from the note says basis: source code on its own row. The same descriptor has been written under five version words, and each is read only under an ESP-IDF release pinned for it: 0x0104 under the pinned current source, 0x0102 and 0x0103 under ESP-IDF v5.3, 0x0100 and 0x0101 under ESP-IDF v4.4 (each value's note names the release it was read under); any other version is refused by name. The descriptor is 72 bytes — sizeof the struct the firmware writes, measured with the compiler, including two bytes of padding — and a note of any other size is not read. Registers, task stacks, memory and the other ESP notes are never read.
Dumps from older ESP-IDF releases. Before ESP-IDF commit e58763c77702 (2025-01-17), the core-dump writer counted a note name's padding in namesz (CORE with 8, ESP_CORE_DUMP_INFO with 20), where elf(5) keeps it outside. The ELF core reader reads such a name when every byte after its terminator is NUL, and the owner's note says how many padding bytes namesz counts; a name with any other byte after its terminator is still withheld, because where it ends is then ambiguous. This was measured on Espressif's own test dumps for the ESP32, ESP32-C3 and ESP32-P4: every note owner was withheld before, and each now states the prefix of the SHA-256 of the application ELF it came from, as Espressif's own test binaries confirm.
Every row whose ELF core records that note carries app_elf_sha256_prefix_comparison beside file_declared_identity:
state | When |
|---|---|
prefix_consistent | the stated prefix is the first characters of the SHA-256 of at least one declared firmware image that this run read as an ELF and hashed |
prefix_inconsistent | it is the first characters of none of them |
not_comparable | reason: prefix_not_read, prefix_is_the_unfilled_placeholder (the firmware carried the source placeholder, not a digest), run_declares_no_firmware_image, declared_images_are_not_elf (no declared image was read as an ELF, and none is an ESP-IDF image whose descriptor states an ELF digest; declare the application ELF) or, for custody show/verify, custody_set_is_not_a_release |
A prefix is context, never proof. Nine characters are 36 bits: prefix_consistent says the two agree in those characters and nothing more, and the state is never called a match. It reaches no gate, evidence pack, approval digest, VEX status or review decision, and validate refuses a block whose state does not follow from the prefix and digests it carries. A core saved under .elf is read by the image reader, which refuses a core as not applicable and says so; save it under .core.
Does this ESP-IDF image name the application ELF the release declares?#
The flashed .bin of an ESP-IDF application is not the ELF: it is what esptool elf2image wrote from it, and its first segment begins with the application descriptor esp_app_desc_t at the fixed offset ESP-IDF documents (32 bytes, after the image header and the first segment header). Every ESP-IDF application build has esptool hash the whole application ELF file and write the 32-byte SHA-256 into that descriptor. A kept image is read by the esp-app-image row (see the table above), under the pinned ESP-IDF source, and the row carries app_elf_sha256_comparison beside file_declared_identity:
state | When |
|---|---|
equal | the stated digest is exactly the SHA-256 of a declared firmware image this run read as an ELF and hashed |
differs | it is the SHA-256 of none of them |
not_comparable | reason: digest_not_stated (all zero, or the Linux-target placeholder), run_declares_no_firmware_image, declared_images_are_not_elf or, for custody show/verify, custody_set_is_not_a_release |
When the release declares the .bin, not the ELF. If no declared firmware image is read as an ELF, but a declared image is an ESP-IDF application image whose descriptor states an ELF digest, both comparisons use that statement as the release side. The release entry is then marked digest_source: image_descriptor_statement and carries the declared image's own image_sha256, and the block's rule and meaning say that no ELF was hashed. A declared ELF, when there is one, is always the release side instead: a digest this run computed is the stronger link, and the two are never mixed. What each result then shows:
- a core dump's
prefix_consistentmeans the dump's prefix begins the ELF digest the declared image states. The crashed firmware and the image's build tooling agree in 9 characters (36 bits) by default. Neither statement was verified, and it does not show the dump came from that image or that ELF. - a kept image's
equalmeans both descriptors name the same ELF file. It does not show either image was built from it, that the two images are the same bytes (compare the recorded SHA-256s for that), or that either was flashed.
declared_images_are_not_elf now means exactly that no declared image was read as an ELF and none states an ELF digest in a descriptor.
What equal means, exactly. The digest here is the full 256 bits, not a prefix, so two equal values are not an accident: the image names exactly the ELF file the release declares. That is all it means. It does not show the image was built from that ELF, because anyone can write 32 bytes into a descriptor and nothing here is signed or recomputed from the image. It does not show the image's other bytes correspond to the ELF, or that the image was flashed or ran. differs means the two name different ELF bytes; a stripped copy of the ELF, or another build, reads that way.
A kept core dump beside a kept image. When a run keeps both an ESP-IDF core dump whose ESP_CORE_DUMP_INFO note states an ELF digest prefix and an ESP-IDF image whose descriptor states a digest, the core's row carries app_descriptor_prefix_context: prefix_consistent when an image's stated digest begins with the dump's prefix, prefix_inconsistent when none does, with each image's review key and stated digest. Each image a prefix is consistent with carries core_dump_prefix_links, naming the cores. custody verify sets kept cores and kept images beside each other in the same way, for every row whose original it reads (a kept original, or one found under --originals); export verification reads no declared identity and does not. Both sides are statements, the dump's by the crashed firmware and the image's by its build tooling, and the prefix is 9 characters (36 bits) by default. So a consistent prefix is context, never proof. None of these blocks reaches a gate, evidence pack, approval digest, VEX status or review decision. validate recomputes each block from the rows' own identity blocks and refuses one whose state does not follow.
What does a kept CoreSight trace snapshot declare?#
Arm Development Studio, the CoreSight Access Library and test rigs keep a CoreSight trace as a snapshot: a directory of .ini files described by Arm's open standard ARM-ECM-0611873, beside binary files holding the trace buffers and memory images. Keep each file as its own preserved artifact, with custody add or an intake manifest's directory entry, and the three readers in the table above read the text files one at a time. snapshot.ini gives the device files and the trace metadata file, each device file gives its core or trace-source type, and trace.ini gives the buffers and which core and buffer each trace source uses. The binary files stay hashed and unread.
A snapshot names cores and trace sources, never a build, so every row says declares_no_build_identity. Nothing is decoded, no path a file names is opened, and register values are never requested. Reading the memory images a device file's [dump] sections name, to set them beside the release ELF's load segments, is later work. A file whose version is not 1.0 is refused by name, and so is a line that is not a comment, a section header or a key=value pair. A repeated section, or a repeated key outside the two association sections, is refused too, because which value was meant cannot be known.
Does this trace's declared target match the image it is filed against?#
A TraceRecorder file declares its byte order and whether the recorder's base type is 64 bit (option_64_bit); a Zephyr core dump declares its pointer size. Every row whose original the tracerecorder-psf, tracerecorder-ringbuffer, tracerecorder-streamport-buffer, tracerecorder-legacy-snapshot (byte order only) or zephyr-coredump (pointer size only) reader read carries target_shape_context beside file_declared_identity, set against the class (elf_class, 32 or 64) and byte order (elf_data) of the firmware images the run declares and read as ELF. Those two values are also recorded, as additive keys, in each image's run_checks.firmware_image.declared_by_file.
state | When | What else the block says |
|---|---|---|
agree | some declared image has the same value for every property both sides declare | declared, release_images[] (each image's path, byte_order, word_bits and the run's own firmware-image state), agreeing_image_count |
differ | no declared image does | the same keys, with agreeing_image_count 0 |
not_comparable | one side had nothing to compare | reason: file_shape_not_read, run_declares_no_firmware_image, declared_images_shape_not_read or, for custody show/verify, custody_set_is_not_a_release (the block still states the shape the file declares) |
differ is context only: not a finding and not a warning. The recorder's base type is set by its port from the target's word size, so it is not the CPU word size itself, and the block's rule says so. A Zephyr dump's pointer size is 2 to the power pointer_size_log2 bits, as the Zephyr core dump documentation defines it (5 is 32-bit, 6 is 64-bit); Zephyr's writer uses 0 for an unknown size, and that or any other value compares no word size. A property one side does not declare is never compared. The block reaches no gate, evidence pack, approval digest, VEX status or review decision, and validate recomputes it from the identity block and the images it carries. explain --preserved-artifact prints it as Target shape.
Does the release's SBOM list the kernel this trace declares?#
A TraceRecorder file declares the kernel port the recorder was built with, by number; the reader names it only through the pinned kernel-port table (FreeRTOS, Zephyr, ThreadX or BareMetal). Every row whose original the tracerecorder-psf, tracerecorder-ringbuffer, tracerecorder-streamport-buffer or tracerecorder-legacy-snapshot reader read carries kernel_component_context beside file_declared_identity, set against the components this release's SBOM lists (cyclonedx-sbom.json).
The comparison is one rule: an SBOM component's ASCII name, case-folded, is exactly one of the names a pinned table gives the declared kernel. Each name is one an SBOM producer or the project itself gives that kernel's component, measured at a pinned commit:
| Kernel | Names compared | Where each name comes from |
|---|---|---|
| FreeRTOS | freertos | ESP-IDF's SBOM manifest for its FreeRTOS kernel (components/freertos/FreeRTOS-Kernel/sbom.yml, tag v5.3), also the CPE product name |
| FreeRTOS | freertos-kernel | the PackageName of the SPDX document the FreeRTOS-Kernel repository ships (sbom.spdx, tag V11.1.0) |
| Zephyr | zephyr | the west project name Zephyr application manifests use for the kernel, which SBOMFlow's west manifest reader records |
| Zephyr | zephyr-sources | the SPDX package Zephyr's own west spdx writes for the kernel's sources (zspdx/walker.py, tag v3.7.0) |
| ThreadX | threadx | the Eclipse ThreadX repository's name and its CMake project name (tag v6.5.0.202601_rel) |
state | When | What else the block says |
|---|---|---|
sbom_lists_component | the SBOM lists at least one component with such a name | declared_kernel, kernel_id, component_names_compared, sbom_components[] (each name, version and purl exactly as the SBOM carries them), sbom_component_count |
sbom_lists_no_such_component | the SBOM lists components, and none has such a name | the same keys, with sbom_components empty |
not_comparable | nothing could be compared | reason: file_kernel_not_read (no header read, or a kernel id outside the pinned table), kernel_is_bare_metal (TraceRecorder's port for a target with no kernel), kernel_has_no_pinned_names or release_sbom_lists_no_component |
It is context only: not a finding and not a warning. A name match does not show the file was recorded on that component or on that version of it, and no version is compared: the platform configuration version the header carries is the version of the host-side platform definition, not the kernel's release. No substring, supplier, purl type or namespace is compared either, so zephyr-rtos-samples is not Zephyr. The block never sets a VEX status or a reachability state and reaches no gate, evidence pack, approval digest or review decision. validate recomputes it from the identity block and the pinned table, and binds it to the SBOM in the same directory: a block whose components are not exactly the SBOM's components under those names is refused, and an SBOM that cannot be read is reported as not bound rather than passed. explain --preserved-artifact prints it as Kernel in the SBOM.
What this is not#
- Not analysis. No event, sample or packet content is parsed, and no finding is derived from a preserved file. The format hint in a custody record comes from the file extension. A declared-identity read (above) reads header and identity fields only, under a cited basis, and reaches no conclusion about what it read.
- Not a test result or coverage. A preserved file existing says nothing about what was tested, what was covered or whether anything passed.
- Not an accreditation or a qualification. A stated performer and a stated independence describe who ran a test and their relationship to the builder. Neither is a credential, neither was checked, and no value in the set claims one.
- Not a retention policy. A retention date is recorded as a person stated it and placed against a pinned
--as-of; nothing computes a period, keeps an original, deletes one or fetches a stated location. - Not a security or conformity statement. Nothing here says a product is secure or meets any regulation, and SBOMFlow never makes a conformity claim.
- Not a connection to any tool. SBOMFlow never runs, drives, authenticates to or downloads from the tools that produced the files.
- Not a gate. No preserved artifact, missing decision or mismatch changes the release gate.
See also#
- Trace and crash evidence in the release record — the short path for traces and crash dumps from any tool, walked across two releases, with what SBOMFlow reads from each format.
- The clean-machine installed journey — this workflow walked from an installed wheel, step by step, with every step asserted.
- Reviewer runbook — recording decisions.
- Evidence outputs —
preserved-artifacts.jsonand the other artifacts. - Observed vs reviewed — why the review boundary matters.