When you ship a file to someone, they may ask what is inside it. Blueprint can answer with a software bill of materials (SBOM) for each file you deliver. This chapter starts with what an SBOM is and how to publish one. The later sections are the reference for each input, rule and format.
What an SBOM is§
An SBOM is a list of the parts that make up one piece of software, and how they relate. CISA calls it "a nested inventory, a list of ingredients that make up software components". It is a machine-readable file, so tools can search it and compare it with other data.
These terms appear throughout this chapter:
- SBOM
A machine-readable inventory of one delivered file: what it contains, what it was built with, and who supplied each part.
- Subject
The delivered file the SBOM describes, such as one executable or one source archive. Blueprint writes one SBOM per subject.
- Component
One part listed in the SBOM, such as a library, a package source or a compiler. Each has a name, a version, a supplier and a license.
- Dependency
A relationship between two components. In Blueprint,
containsmeans the code ships inside the other component.dependsOnmeans it is needed but not inside. Abuildscope means it was only used to make the file, like a compiler.- License expression
The license of a component, written in the SPDX license expression syntax, for example
MITorApache-2.0 OR MIT. Identifiers come from the SPDX License List. Terms that are not on that list use aLicenseRef-name and a link to the terms.- purl
A Package URL: a standard identifier for a package in its ecosystem, such as
pkg:cargo/serde@1.0.0. It is defined in ECMA-427. Vulnerability databases and scanners use it to look a component up.- Hash or digest
A fingerprint of exact bytes, here SHA-256. Change one byte and the digest changes. The SBOM records the digest of the delivered file, so a recipient can check that the SBOM belongs to the file they have.
- VEX
Vulnerability Exploitability eXchange. CISA defines a VEX document as "a form of a security advisory that indicates whether a product or products are affected" by a known vulnerability. A VEX statement is separate from the SBOM. Blueprint does not write VEX documents.
Why customers and regulators ask for one§
Security. When a new vulnerability is announced in a library, the people who run your software need to know whether they are affected. With an SBOM they can check without asking you, and keep checking as new advisories appear.
License compliance. Licenses travel with code. An SBOM lists each component’s license so a legal team can review it.
Procurement and regulation. Customers may ask for an SBOM before they buy or deploy your software. Some markets also regulate it. For example, the EU Cyber Resilience Act (Regulation (EU) 2024/2847) applies to products with digital elements made available in the EU, wherever they were made, and includes SBOM obligations from 11 December 2027. Whether any such rule applies to you depends on where and how you supply your software; check with your legal team rather than relying on this page. CISA and international partners publish the 2026 Minimum Elements for an SBOM, which says what a useful SBOM contains.
This page is a guide, not legal advice. Check your own obligations.
SPDX and CycloneDX§
There are two widely used SBOM formats. CISA’s 2026 minimum elements name both and recommend supporting all widely used formats. Blueprint writes both, from the same inventory.
| Aspect | SPDX | CycloneDX |
|---|---|---|
| Steward | An open project hosted by the Linux Foundation | OWASP, standardized by Ecma International Technical Committee 54 (TC54) |
| International standard | ISO/IEC 5962:2021, which is SPDX 2.2.1 | ECMA-424; its 2nd edition (December 2025) is CycloneDX 1.7 |
| Stated purpose | "An open standard for communicating software bill of material information", covering provenance, license and security data | "Advanced supply chain capabilities for cyber risk reduction" |
| Versions Blueprint writes | 3.0.1, and 2.3 for older readers | 1.7 |
| Vulnerability status (VEX) | SPDX 3.0.1’s Security model has VEX relationship classes | VEX is a listed CycloneDX capability |
| Typical consumers | License compliance and legal review; scanners that read SPDX JSON, such as Trivy | Security tooling such as Dependency-Track, which consumes CycloneDX; Trivy also reads CycloneDX JSON |
Blueprint writes these sidecar files next to each delivered file:
| Sidecar | Contents |
|---|---|
<asset-name>.spdx.json | SPDX 3.0.1 JSON-LD. The primary statement. |
<asset-name>.cdx.json | CycloneDX 1.7 JSON. |
<asset-name>.spdx-2.3.json | SPDX 2.3 JSON, for consumers that read only SPDX 2. |
Each sidecar names the final filename, version, target and SHA-256 of its
subject. In SPDX 3.0.1 the document’s root software_Sbom names that subject
package as its root element. In SPDX 2.3 a DESCRIBES relationship does. In
CycloneDX it is the metadata.component.
Which should I send?§
By default, send both SPDX 3.0.1 and CycloneDX. This is what
bindwrites when you give it no output path. Each recipient can read the format their tools understand.Add SPDX 2.3 only when a recipient’s tools cannot read SPDX 3 yet. It states the same graph as the other two.
If a recipient asks for one format, send that one. All three come from one inventory, so none says more or less about what ships.
Do not edit one rendering by hand. Change the inventory and bind again, so every format stays the same.
What each format can say§
All three files are rendered from one inventory, so they share the same identities, relationships, coverage and subject. Each format spells them differently, and CycloneDX cannot spell all of them in its standard fields.
| Information | SPDX 3.0.1 | SPDX 2.3 | CycloneDX 1.7 |
|---|---|---|---|
| Delivered file, its version and SHA-256 | Root software_Package with a Hash | Package named by DESCRIBES, with a checksum | metadata.component with hashes |
| Component identity | NAMESPACE#name; purl as software_packageUrl | SPDXRef-name; purl as an external reference | bom-ref and purl; the full identity as a blueprint:identity property |
| Archive or file SHA-256 | verifiedUsing | checksums | hashes |
| Supplier | suppliedBy an agent | supplier | supplier (organization) or authors (person) |
Declared license, including NOASSERTION and NONE | hasDeclaredLicense, with named individuals for both | licenseDeclared | licenses expression; NOASSERTION is omitted and NONE is a blueprint:license property |
Shipped code (contains) | contains | CONTAINS | nested components, and scope required |
| Build, test and development tools | dependsOn with a lifecycle scope | BUILD_DEPENDENCY_OF and its siblings | a dependency, and scope excluded |
generates, patchedBy, hasVariant, copiedTo, other | the same relationship types | GENERATES, PATCH_FOR, VARIANT_OF, COPY_OF, OTHER | blueprint:relationship properties only |
| Coverage, input dispositions and reasons | an Annotation | an OTHER annotation | a blueprint:evidence property |
Where CycloneDX loses meaning:
A dependency is not containment. Nested
componentsstate containment; CycloneDXdependenciesstate only actualdependsOnrelationships. Readscopeto tell shipped code from tools:requiredis reached through shipped or run-time edges,excludedis only a build, test or development dependency, andoptionalis anything else, such as an unresolved resolution candidate.Scope belongs to a component, not to an edge. A component that is a build tool for one part and shipped by another is
required. The per-edge scopes are in theblueprint:relationshipproperties.Some relationship types have no CycloneDX field. They stay only in
blueprint:properties, which standard CycloneDX tools ignore.
Nothing is changed silently. Blueprint reads its own CycloneDX back from the
blueprint: properties, and the tool’s check refuses a BOM whose
standard containment, dependencies or scopes disagree with them, or whose graph differs
from the SPDX renderings of the same document. The checks compare
identities, relationships, coverage and subjects, not bytes.
What no format claims:
A tree or NAR digest of a source is its identity, not the SHA-256 of an archive or file. It appears in the component’s identity, never as a hash.
A Lock alone does not establish what an artifact contains. Composition comes from the reviewed composition, and unknown parts are recorded as
incompletecoverage rather than left out.blueprint lock show --jsonreports committed pins for scripts. It is not an SBOM, and it does not show that a build happened or what it contains.
From a project to published sidecars§
This walkthrough uses the standalone reporting tool, so it works in any
project. Replace <tool> with the immutable tool URL from the Blueprint
release notes (see Use the released standalone tool). The example
publishes an x86_64 Linux executable called my-app.
Build the final file. Build the exact file you will deliver, for example into
dist/my-app. Do not change it after this point. Any change, even a rebuild with a new timestamp, needs a new SBOM.Export the evaluated Spec and the Lock. The tool reads them, not
Blueprint.roc. Runblueprint updatefirst if the Lock is not current.mkdir -p sbom blueprint spec > sbom/Spec cp Blueprint.lock sbom/LockWrite the reviewed composition.
sbom/composition.jsonsays what ships in the file, and why. You write it, and someone who knows the code reviews it. A minimal example, with one shipped library and the compiler that built the file:{ "profile": "blueprint-spdx-3.0-v2", "components": [ {"id": "parser", "name": "example-parser", "version": "2.4.1", "producer": "Organization: Example Upstream", "license": "MIT", "license_url": "", "identity": "pkg:cargo/example-parser@2.4.1", "download": "https://example.org/example-parser-2.4.1.tar.gz", "sha256": "<sha256 of that archive>", "modified": "", "target": "x86_64-linux", "origin": "reviewed"}, {"id": "compiler", "name": "<compiler name>", "version": "<exact version>", "producer": "Organization: <its supplier>", "license": "<its license expression>", "license_url": "", "identity": "<stable URL of that release>", "download": "<its download URL>", "sha256": "<its sha256>", "modified": "", "target": "x86_64-linux", "origin": "reviewed"} ], "relationships": [ {"from": "Subject", "to": "parser", "relation": "contains", "scope": "", "basis": "Statically linked into my-app"}, {"from": "Subject", "to": "compiler", "relation": "dependsOn", "scope": "build", "basis": "Compiles my-app; not shipped"} ], "coverage": [ {"scope": "native-closure", "status": "incomplete", "reason": "System libraries linked by the toolchain are not yet listed", "contact": "https://example.org/security"} ], "inputs": [], "imported_documents": [], "unavailable_sboms": [] }inputsneeds one entry for every input your Spec and Lock know about, including ones that do not ship. Names have the formsource:NAME,program:NAME:SYSTEM,tool:SOURCE#NAME,roc-package:NAME,build-source:NAME,task:NAMEandbuild:NAME. Give each acomponentit became, or an emptycomponentand areasonit is excluded. A component can take its version, identity, download and digest from the Lock instead: see Inspect without executing a provider.Inspect. This checks the composition against the Spec and Lock and writes the inventory. It runs nothing and downloads nothing.
roc run <tool> -- inspect sbom/Spec sbom/Lock sbom/composition.json x86_64-linux sbom/inventory.jsonA refusal names the rule that failed, such as
known input omitted or unknown/duplicate input disposition.Describe the subject and the document.
sbom/subject.jsonnames the file. Leavesha256empty andbindcomputes it.sbom/context.jsonidentifies this SBOM document.{"filename": "my-app", "version": "1.2.0", "target": "x86_64-linux", "sha256": "", "url": "https://example.org/releases/1.2.0/my-app"}{"namespace": "https://example.org/sbom/my-app/1.2.0/x86_64-linux/revision-1", "revision": "1", "created": "2026-10-21T00:00:00Z", "author": "Organization: Example Ltd", "generator": "Tool: blueprint-sbom-0.7.0"}Bind the final bytes. This hashes
dist/my-appand writesdist/my-app.spdx.jsonanddist/my-app.cdx.jsonbeside it. It never overwrites an existing file. Add the SPDX 2.3 rendering only if a recipient needs it.roc run <tool> -- bind sbom/inventory.json sbom/subject.json sbom/context.json dist/my-app roc run <tool> -- bind sbom/inventory.json sbom/subject.json sbom/context.json dist/my-app dist/my-app.spdx-2.3.json --spdx-2.3Check.
checkholds each sidecar to its format’s specification and the profile. With--artifactsit confirms each describes the actual file, with--inventorythat it matches the inventory, and it requires the renderings of one document to state the same graph:roc run <tool> -- check --inventory sbom/inventory.json --artifacts dist dist/my-app.spdx.json dist/my-app.cdx.jsonPublish beside the artifact. Upload the sidecars to the same place as the file, under the file’s name plus the format suffix:
my-app,my-app.spdx.json,my-app.cdx.json. Publish them at the same time as the file, at an immutable location. Keepcomposition.jsonunder review in your repository, so the next release starts from it.
Check the quality before you publish§
An SBOM is only useful if it is accurate. Before you publish, check:
The digest is the final file’s. Bind after the last build, signing or packaging step. A recipient compares the SHA-256 with the file they downloaded.
One SBOM per delivered file. Each executable, per target, and each source archive gets its own sidecars. Never reuse one file’s SBOM for another.
Every component has an exact version. Not a range, and not "latest".
Suppliers are named. Use
Organization: ...orPerson: .... UseNOASSERTIONonly when you do not know.Licenses are real expressions.
NOASSERTIONmeans you made no determination, or chose not to say.NONEmeans the component has no license information at all. Neither means "no restrictions". For custom terms, use aLicenseRef-name with alicense_url.Package URLs are present where they exist. Scanners match vulnerabilities by purl. Use the component’s real purl as its
identity.Your dependencies bring their suppliers' SBOMs. Pin each dependency file by its SHA-256 and import its supplier’s SBOM, bound to it. Where a supplier publishes none, list it in
unavailable_sbomswith the reason;inspectrefuses an inventory that does neither. See Chain SBOMs across your dependencies.Build tools are build dependencies, not shipped code. A compiler is
dependsOnwith scopebuild. It is nevercontains.Gaps are stated, not hidden. If you do not know part of the inventory, add an
incompletecoverage entry with a reason and a contact. A recipient reads a missing component as "not present". Useverified-emptyonly when you checked and found nothing.Corrections get a new revision and namespace. Never replace a published SBOM in place. Publish a corrected one with a new
revisionand a namespace ending in that revision.All formats come from one inventory. Bind every format from the same inventory, subject and context. Do not edit a rendering by hand.
What recipients do with the files§
Security teams load the SBOM into a vulnerability scanner or a supply-chain platform. It matches components, usually by purl, against vulnerability advisories. When a new advisory appears, they can check it without asking you.
Legal and compliance teams read the licenses and the
LicenseRef-terms to decide whether the software can be used or redistributed.Procurement teams and regulators keep the SBOM with the product’s records, and may ask for it again later.
Anyone can compare the SBOM’s SHA-256 with the file they have, to be sure the SBOM describes it.
Publishers and signers that rehash every asset before signing read the evidence manifest beside the sidecars,
<artifact>.sbom-manifest.json, for the mapping they would otherwise reconstruct by hand: the subject’s name, version, target, SHA-256 and size, each sidecar’s name, format, SHA-256 and size, and the SHA-256 of the inventory they were bound from.check --manifestconfirms that mapping against the files (see Bind the final bytes separately).
What an SBOM does not prove§
It is not a vulnerability verdict. Listing a component with a known vulnerability does not mean your product is exploitable. That is what a VEX statement says, and Blueprint does not write one.
It is not build provenance. It says what the file contains, not who built it, on which machine, or from which commit. Provenance and signed attestations are separate records.
It is only as complete as its coverage. Blueprint states what was reviewed and what is unknown. Valid SPDX or CycloneDX alone says nothing about whether the list is complete.
It does not prove an opaque tool’s output. If a build step does not disclose what it links in, the SBOM cannot know. Record that as an
incompletecoverage gap.
The rest of this chapter is reference material.
What about build and development environments?§
An SBOM describes the file you deliver. Your build and development environments matter to your supply chain too, but they answer different questions, and mixing them into a delivered file’s SBOM makes it worse.
Tools that built the file appear only where they shaped it: the compiler
and the pinned sources of the build environment are dependsOn with scope
build (CycloneDX scope excluded), never contains. A recipient then knows
which toolchain produced the bytes without reading it as shipped code. What
the toolchain copies into the file is different: a statically linked library,
or a runtime the linker adds, ships, so it is contains.
Everything else your environments provide is a known input that needs a
disposition. Every tool, task and build your Spec declares, a linter or test
runner in a development shell included, must be listed in inputs, with the
component it became or a reason it is excluded, such as "development only;
does not reach the file". Nothing vanishes silently, and nothing that does
not ship is listed as if it did. Listing every development tool as a
component would make scanners report vulnerabilities in software your users
never receive.
How the file was built is provenance, not composition. A build attestation (such as SLSA provenance, or the GitHub attestation Blueprint’s own releases carry) records which workflow, inputs and environment produced which bytes. Publish it beside the SBOM; neither replaces the other.
The full toolchain closure — every package in the build environment, and
what each of those contains — is usually more than a composition lists. Say
so with an incomplete coverage scope, as Blueprint’s own release SBOMs do
for their native and compiler closure. Where a tool can describe that closure
as an SBOM of its own, it can be imported as an unbound document (see
Import your suppliers' SBOMs).
An SBOM of a development environment itself, such as for an internal audit of the tools developers and CI run, is a separate document about that environment. Keep it out of the SBOMs you deliver.
Assign the knowledge to its owner§
Blueprint collects resolved identities and build relationships and emits the
standard document. A repository reviews which components its recipes ship,
their licenses, vendored changes and native dependencies. Installed tools
and Needs describe availability and build order; neither proves that code
is contained in a deliverable.
roc-automation orchestrates the release build and owns independent release policy validation, attestation, publication, retention and later advisory correlation. Blueprint’s offline conformance tests validate its own adapter and declared coverage. They do not replace the shared release-policy validator. An SBOM is a composition statement; provenance and vulnerability verdicts are separate records.
Use the released standalone tool§
Each release ships the reporting tool in two forms: a native binary per
system, blueprint-sbom-<system>, beside the blueprint binaries, and
roc-blueprint-sbom-<content-hash>.tar.zst, a supported Roc app bundle
containing the same wrapper and filesystem boundary used here.
The binary is the recommended way for CI to run the tool. A job downloads
blueprint-sbom-<system> from the release (blueprint-sbom-x86_64-linux,
blueprint-sbom-aarch64-linux, blueprint-sbom-aarch64-darwin or
blueprint-sbom-x86_64-windows.exe), verifies it against the line of that
name in the release’s blueprint.sha256, and runs it. It needs no Roc
compiler and compiles nothing: the core and the native platform host are
compiled in, and nothing is fetched at run time. Pin the release and the
reviewed digest in automation, as roc-automation’s setup-blueprint action
does for the CLI; the binary’s own sidecars and the release attestation cover
it (see Blueprint's own release evidence).
curl -fsSLO https://github.com/lukewilliamboswell/roc-blueprint/releases/download/<version>/blueprint-sbom-x86_64-linux
curl -fsSLO https://github.com/lukewilliamboswell/roc-blueprint/releases/download/<version>/blueprint.sha256
sha256sum --ignore-missing -c blueprint.sha256
chmod +x blueprint-sbom-x86_64-linux
./blueprint-sbom-x86_64-linux inspect Spec Blueprint.lock composition.json x86_64-linux inventory.json
./blueprint-sbom-x86_64-linux members dist/my-package.tar.zst members.json
./blueprint-sbom-x86_64-linux inspect Spec Blueprint.lock composition.json source inventory.json --members members.json
./blueprint-sbom-x86_64-linux bind inventory.json subject.json context.json dist/my-binary
./blueprint-sbom-x86_64-linux bind inventory.json subject.json context.json dist/my-binary dist/my-binary.spdx-2.3.json --spdx-2.3
./blueprint-sbom-x86_64-linux check --inventory inventory.json --artifacts dist dist/my-binary.spdx.json dist/my-binary.cdx.json dist/my-binary.spdx-2.3.json
./blueprint-sbom-x86_64-linux check --manifest dist/my-binary.sbom-manifest.json --inventory inventory.json --artifacts dist
The first bind writes dist/my-binary.spdx.json, dist/my-binary.cdx.json
and the evidence manifest dist/my-binary.sbom-manifest.json; the second
adds the SPDX 2.3 rendering, and the first check checks all three. The
second check holds the manifest to the files it lists, and checks those
sidecars. See Bind the final bytes separately. members and
inspect --members inventory a source archive from its own contents; see
Inventory a source bundle from its archive. They need only a tar that
reads zstd on PATH.
The app bundle is the alternative for a machine that has the matching Roc
compiler and wants the tool as source. The release notes link to its exact
immutable URL. Pin that URL in automation: its release version and Roc
content hash identify the source bytes. The compiler pin matches
blueprint compiler-version for that release. The same commands take the
form roc run <immutable-tool-bundle-url> -- inspect .... A matching Roc
compiler prepares the immutable core and native platform dependencies in its
package cache before execution; materialize those dependencies before taking
a release job offline. The source app’s own sidecar distinguishes its
contained wrapper/I/O source from the core/platform/compiler dependencies
fetched separately.
In either form no Blueprint project is required; even an unrelated invalid
Blueprint.roc in the working directory is ignored. Supply the already
evaluated Spec and Lock paths explicitly. The report itself performs no
resolution, provider execution or network request.
Inspect without executing a provider§
The pure core.Sbom.inspect(spec, lock, target, composition) function consumes
an already evaluated Spec and committed Lock. It never evaluates a project,
opens a Store, runs Nix, downloads a package, updates a pin or builds a file.
The existing optional extension seam accepts
Custom("sbom-v1", subjectName, Str(compositionJson));
Sbom.from_spec(spec, subjectName) reads that declaration. A separate reviewed
JSON file can supply the same Sbom.Composition value.
When developing Blueprint itself, the repository entry point runs the same code as the published app bundle:
nix develop -c scripts/sbom.roc inspect Spec Blueprint.lock composition.json x86_64-linux inventory.json
Spec is the evaluated S-expression, rather than Blueprint.roc. The JSON
composition contains these fields:
profile:blueprint-spdx-3.0-v2.components: reviewed component records with unique local identifiers (letters, digits,-and.), canonicalname, SPDXproducer(Organization: ...,Person: ..., orNOASSERTION),license,license_url,modified,targetandorigin. An identifiernamebecomesNAMESPACE#namein SPDX 3.0.1 andSPDXRef-namein SPDX 2.3.relationships: records withfrom,to, an SPDX 3.0.1relation, a lifecyclescope(""for none) and reviewedbasis.Subjectnames the final deliverable.coverage: records withscope,component,aspect,status,reasonandcontact.componentnamesSubjector a declared component;aspectisassembly(direct contained membership) ordependencies(direct dependency closure). Status iscomplete,verified-empty,incomplete,unknown, orexcluded. Reasons and contacts remain evidence in every format.inputs: one reviewed disposition for every known input, withinput,componentandreason. An empty component means an explained exclusion.imported_documents: verified upstream graphs, or an empty list.unavailable_sboms: one record for each locked dependency whose supplier publishes no SBOM, withcomponent,reasonandcontact. Every other locked dependency needs a supplier SBOM inimported_documents, bound to it: see Chain SBOMs across your dependencies.
Coverage is a reviewed assertion about exactly one component and aspect. It
never cascades to contained children or transitive dependencies. For example,
a complete shipped-source assembly does not claim the compiler’s dependencies
are known. Use verified-empty only after reviewing an aspect and finding no
relationships; inspection rejects it when matching relationships exist. Use
unknown for an unexamined closure and incomplete for a known partial graph.
Conflicting statuses for the same address are rejected.
CycloneDX writes addressed claims as standard compositions, using
assemblies or dependencies references. Complete and verified-empty map to
complete, incomplete to incomplete, and unknown/excluded to unknown.
A dependency entry is emitted when known outgoing dependencies exist, or a
complete/verified-empty dependency claim examines that component. Unknown
leaves remain absent; known incoming build dependencies remain present.
Thus an empty examined leaf and an unknown closure differ in standard fields.
SPDX preserves these assertions in its evidence annotations; the cross-format
checker compares that evidence and validates CycloneDX’s standard assertions.
Supplier imports preserve addressed assertions with namespaced component
references and retain the separate conservative import coverage gap.
For legacy unaddressed declarations, add component: "" and aspect: "" to
preserve the original scope, status, reason and contact as evidence only.
These declarations emit no standard completeness claim and never justify an
empty dependency leaf. To migrate to addressed completeness, review the facts
and supply an explicit component and aspect. Blueprint does not infer an
address by interpreting a scope name or reason. Partially addressed or dangling
records produce diagnostics identifying the required fields.
A component’s origin is reviewed, source:<locked-name>, or
program:<locked-name>:<system>. Locked origins populate version,
identity, download, sha256 and, for a program, target from the Lock when
the corresponding field is empty. Supplied pin-derived values must agree.
A component’s target is source for an architecture-independent archive,
the system its native code was built for, or empty when it states nothing;
see Import your suppliers' SBOMs for what each may enter.
A source revision is the version where available; otherwise its documented
content identity is used. A fetched program with no reviewed upstream version
uses sha256-<digest> as a byte identity, without inventing a release version.
Changing a Lock pin updates the inventory without duplicating that pin in the
composition. Tree/NAR identities never become archive checksums. A file
source’s sha256:<hex> pin is the flat digest of its original file bytes.
For a reviewed origin, supply exact version, stable identity, immutable
download and available sha256; unavailable license data is NOASSERTION.
Actual Package URLs become SPDX package-manager external references. Other
identities use documented source/revision URLs or organization-owned URNs.
Keep upstream correlation in modified when a vendor revision or patch changes
its own identity. Custom/proprietary terms use a LicenseRef-... expression
and an accessible license_url; they remain in the inventory.
contains records shipped code and dependsOn external dependencies. A
lifecycle scope of build, test, development or runtime on
dependsOn distinguishes tooling roles: the subject dependsOn its compiler
with scope build. generates, patchedBy, hasVariant and copiedTo are
also available. A known resolution candidate whose target/features or role
remain unresolved uses other with an explicit basis, rather than asserting a
runtime edge. Each relationship has one SPDX 2.3 spelling, so both renderings
state the same graph:
| SPDX 3.0.1 composition | SPDX 2.3 |
|---|---|
A contains B | A CONTAINS B |
A dependsOn B | A DEPENDS_ON B |
A dependsOn B, scope build | B BUILD_DEPENDENCY_OF A |
A dependsOn B, scope test | B TEST_DEPENDENCY_OF A |
A dependsOn B, scope development | B DEV_DEPENDENCY_OF A |
A dependsOn B, scope runtime | B RUNTIME_DEPENDENCY_OF A |
A generates B | A GENERATES B |
A patchedBy B | B PATCH_FOR A |
A hasVariant B | B VARIANT_OF A |
A copiedTo B | B COPY_OF A |
A other B | A OTHER B |
All components must connect to the subject. Duplicate identities, disconnected or dangling graphs, conflicting pins and unexplained known-input omissions are rejected.
Every known locked source, selected target’s fetched program, tool, Roc
package, build source, task and build needs a disposition. Sources and tools
unrelated to this particular artifact can be excluded with a reason. Opaque
argv cannot establish composition. Use incomplete coverage for an undisclosed
native/provider/task closure and name its scope, reason and contact.
verified-empty explicitly records reviewed absence; an unknown closure is
never represented as verified absence. complete and excluded are also
available for individually justified scopes. Schema validity alone says
nothing about the completeness of the underlying software inventory.
Import your suppliers' SBOMs§
You cannot review every dependency yourself. When a supplier publishes an SBOM for something you consume, Blueprint can check it and fold it into your own inventory, so your SBOM covers that supplier’s components too.
An imported_documents entry supplies id, url, sha256, namespace,
target, document, relation, basis, contact and subject. document
holds the supplier’s SBOM bytes as a string, and relation is an SPDX 3.0.1
composition relationship. The document’s SHA-256 and identity must match the
entry: the namespace of an SPDX document, or the serialNumber of a CycloneDX
BOM. Reporting never fetches the URL or runs anything in the document. Use an
immutable release URL and keep the exact verified bytes for your recipients.
| Supplier’s format | What Blueprint reads |
|---|---|
| SPDX 3.0.1 JSON-LD | Packages, their relationships and declared licenses. The document’s root element, a software_Sbom root, or a describes relationship names the subject. |
| SPDX 2.3 and 2.2 JSON | Packages and their relationships. DESCRIBES, DESCRIBED_BY or documentDescribes names the subject. |
| CycloneDX 1.4 to 1.7 JSON | metadata.component is the subject. components, nested ones as contains, and dependencies as dependsOn. |
Each document is checked against its format’s schema first. Blueprint then
reads what its package graph can hold and counts what it leaves out: files,
snippets, relationships to them or into other documents, relationship types
with no composition meaning, and CycloneDX sections that are not composition,
such as vulnerabilities and services. Those counts go into the import’s
coverage, which is always incomplete, so a recipient can see exactly what a
supplier said that your SBOM does not carry. Nothing is dropped silently.
Some values a supplier writes cannot be published as they are. Blueprint replaces them and records the original in the component’s notes:
A missing name or version becomes
NOASSERTION.A download location that is not a plain HTTPS URL, such as
registry+https://...orgit+ssh://..., becomesNOASSERTION.A license that is not an SPDX expression, or is named only in text, becomes
NOASSERTION. ALicenseRef-with no terms location keepsNOASSERTIONas its location.A package URL without a version, or one another imported package already uses, gives way to an identity in the supplier’s document.
An import that would publish an absolute developer path or a credential is refused, with the imported component named.
Imported identifiers are prefixed with the entry’s id, so they cannot
collide with yours, and every imported component keeps the supplier’s
document identity and SHA-256 in its notes. CycloneDX bom-refs that are not
valid identifiers are mapped to ones that are, with a short hash so distinct
references stay distinct. CycloneDX states no build or test scope for a
dependency, so imported CycloneDX dependencies carry none. A component a
CycloneDX BOM lists but relates to nothing is related to its subject as
other, which claims no containment.
subject contains component, filename, version and sha256. To bind
the supplier’s SBOM to what you actually consumed, set component to the
declaration of that archive or program. Its resolved Lock digest, version and
target must then match the supplier’s described subject, and a copiedTo
relationship records the match. This lets a reviewed upstream graph stand in
for a duplicated dependency inventory while the Lock stays the authority for
the bytes you consumed. An empty component imports an unbound statement:
coverage says the subject is not bound to a resolved artifact and its claims
are unverified. An unbound subject may state no SHA-256, as a supplier’s SBOM
of a source tree often does; a bound one must.
The entry’s target is what the supplier’s document says of its subject:
source for an architecture-independent archive, or the system its native
code was built for. A source import, and a source component, belong in
an inventory of any target, because each consumer’s compiler builds a source
archive for the consumer’s own target: a Roc package bundle a Linux
executable was built from keeps its source identity in that executable’s
x86_64-linux inventory, and is never relabelled as native code. A native
import or component must have the inventory’s target, so an aarch64-linux
library cannot enter an x86_64-linux inventory. A source inventory, such
as a platform bundle that carries a linker member for each of its targets,
admits either. The relationship still says what the archive is to the
subject: a build-scoped dependsOn from the subject to a source archive
states that the archive went into compilation, not that its files, which
may include the fonts, data and notices its modules import, ship unchanged
in the executable. Nor does importing a source SBOM establish the
executable’s native closure; state what remains unknown in coverage.
The importer is tested against what Syft and Trivy write, in both CycloneDX and
SPDX (tests/sbom/fixtures/upstream/), and the sbom-read fuzz target requires
that every document Blueprint can read also imports.
Inputs are limited to 1 MiB, JSON nesting to 64 levels, imports to 16, components to 4,096 and relationships to 16,384. Licenses and exceptions are checked against the pinned SPDX License List 3.26.0 and expression grammar.
Inventory a source bundle from its archive§
A source bundle’s inventory comes from the archive the bundler produced, not
from the list of files you asked it to pack. roc bundle follows
import "file" as x : Str (or List(U8)) and packs those files even when
they are not command arguments, so a font, a lookup table or a licence
notice that a module imports is in the shipped archive whether or not
anyone named it. The explicit arguments choose what to bundle; the archive
is the proof of what was bundled.
members lists a produced .tar.zst member by member. It runs the system
tar, which must read zstd, lists the archive, refuses a member that is a
symbolic or hard link, a device or a fifo, or whose name has a .. part or a
leading /, and only then extracts the members into a fresh private
temporary directory, hashes each one and deletes the directory. Nothing is
executed from the archive, and no Blueprint project or Nix is needed.
roc bundle main.roc --output-dir dist
roc run <tool> -- members dist/<hash>.tar.zst sbom/members.json
The listing names the archive, its SHA-256 and each member’s name, size and SHA-256 in archive order:
{"archive": "<hash>.tar.zst", "sha256": "<the archive's SHA-256>",
"members": [
{"name": "main.roc", "size": 139, "sha256": "<its SHA-256>"},
{"name": "Example.roc", "size": 41, "sha256": "<its SHA-256>"},
{"name": "LICENSE", "size": 8, "sha256": "<its SHA-256>"},
{"name": "message.txt", "size": 6, "sha256": "<its SHA-256>"}]}
Pass it to inspect with --members, for a source target:
roc run <tool> -- inspect sbom/Spec sbom/Lock sbom/composition.json source sbom/inventory.json --members sbom/members.json
inspect then refuses a composition that does not account for every member.
A member is accounted for by a component whose sha256 is the member’s, or
by an input disposition named archive-member:NAME:SHA256 with a reason, for
example a notice file that is not code. The refusal names each member that
has neither. The usual way to account for them is to generate one component
per member and a contains relationship from the bundle’s component to each,
as Sbom.archive_members in blueprint-core does and as Blueprint’s own
release does: the member’s name, sha256-<digest> as its version and
urn:blueprint:archive-member:<name>:<digest> as its identity, with
NOASSERTION for the supplier, download and license. A reviewer replaces a
generated component with a fuller one, naming the licence of a bundled
third-party asset, as long as it states the same SHA-256. A member no
component states is a known input, so its disposition is exported in the
sidecar’s evidence like any other input’s. Without --members, nothing
checks the archive against the composition.
Chain SBOMs across your dependencies§
When every project publishes an SBOM beside each release file and every consumer imports the SBOMs of what it consumes, the SBOMs chain. A platform’s SBOM includes the SBOMs of the packages it uses, and an app’s includes the platform’s. Every link is checked by digest, so the SBOM you publish covers your whole supply chain, and what nobody published is stated as a gap rather than missing. Nothing in this is specific to one kind of dependency: a Roc package bundle, a C library tarball and a downloaded tool work the same way.
Publish beside the file. Publish FILE.spdx.json and FILE.cdx.json next
to each release file at the same URL, as bind writes them by default. A
consumer can then find a dependency’s SBOM from the dependency’s own URL.
Pin the file you consume by its SHA-256. A supplier’s SBOM describes a file
by its SHA-256, so bind it to a dependency pinned the same way: a
SourceFile(name, url, sha256) (see
Original files with published hashes)
or a fetched program. A source pinned by a tree or NAR digest cannot be bound,
because that digest is not the file’s SHA-256.
Declare it from the pin, and import its SBOM bound to it. For example, a
platform that uses a package published as
https://example.org/releases/1.2.0/dep-abc.tar.zst:
{
"components": [
{"id": "dep", "name": "dep-abc.tar.zst", "version": "1.2.0",
"producer": "Organization: Example", "license": "MIT", "license_url": "",
"identity": "", "download": "", "sha256": "", "modified": "",
"target": "source", "origin": "source:dep"}
],
"relationships": [
{"from": "Subject", "to": "dep", "relation": "dependsOn", "scope": "build",
"basis": "Imported by the platform's Roc modules"}
],
"imported_documents": [
{"id": "dep-sbom", "url": "https://example.org/releases/1.2.0/dep-abc.tar.zst.spdx.json",
"sha256": "<SHA-256 of that SBOM file>", "namespace": "<its namespace>",
"target": "source", "document": "<the SBOM file's contents>",
"relation": "dependsOn", "basis": "Supplier's published SBOM",
"contact": "https://example.org/security",
"subject": {"component": "dep", "filename": "dep-abc.tar.zst",
"version": "1.2.0", "sha256": "<the bundle's SHA-256>"}}
],
"unavailable_sboms": []
}
The source:dep origin takes the download location and SHA-256 from the
Lock, so the composition never repeats a pin. The reviewed version is the
release the supplier’s SBOM names, and the source target is what the
supplier’s SBOM says of the bundle, whichever target your own inventory is
for. inspect then requires the SBOM’s described file to have exactly the
locked SHA-256, that version and that target, and records a copiedTo
relationship from the supplier’s subject to your component. The supplier’s
components and relationships join your inventory under the dep-sbom-
prefix, so your SBOM states what the package contains without you
inventorying it again. Because the supplier’s SBOM already holds whatever it
imported, the chain is transitive.
Requiring SBOMs is the default. inspect refuses an inventory in which a
locked dependency, a component with a source: or program: origin, has
neither a bound supplier SBOM nor an unavailable_sboms entry. The message
names the dependency and what to add. When a supplier publishes no SBOM,
say so:
"unavailable_sboms": [
{"component": "dep", "reason": "The supplier publishes no SBOM for 1.2.0",
"contact": "https://example.org/security"}
]
Each entry becomes an incomplete coverage scope, sbom:dep, in every
rendering, so a recipient can see which dependencies were not covered by
their supplier and whom to ask. An unbound import does not count: its claims
are unverified, so the dependency still needs a bound SBOM or an entry.
Fetch outside Blueprint. Blueprint never downloads while it reports. Fetch
each dependency’s SBOM in your build or release automation, and put its bytes
and SHA-256 in the import entry; inspect verifies them. Shared CI, such as
roc-automation’s workflows, can find a dependency’s SBOM from its URL by the
naming convention above.
Bind the final bytes separately§
Sbom.render, Sbom.render_spdx2 and Sbom.render_cyclonedx, each taking
(inventory, subject, context), are pure. A separate native command reads and
hashes a regular final file in 64 KiB chunks:
nix develop -c scripts/sbom.roc bind inventory.json subject.json context.json dist/my-binary
Without an output path, bind writes ARTIFACT.spdx.json (SPDX 3.0.1),
ARTIFACT.cdx.json (CycloneDX 1.7) and ARTIFACT.sbom-manifest.json, the
evidence manifest listing both, beside the artifact. It renders all three
before writing any, so an inventory, subject or context that one format
refuses leaves no file behind. To write a single document to a path of your
choice, name it, with a flag for a format other than SPDX 3.0.1, and
--manifest for the manifest of that one sidecar, which must sit beside it:
nix develop -c scripts/sbom.roc bind inventory.json subject.json context.json dist/my-binary out/my-binary.spdx.json
nix develop -c scripts/sbom.roc bind inventory.json subject.json context.json dist/my-binary out/my-binary.spdx-2.3.json --spdx-2.3
nix develop -c scripts/sbom.roc bind inventory.json subject.json context.json dist/my-binary out/my-binary.cdx.json --cyclonedx --manifest out/my-binary.sbom-manifest.json
Bind every format from the same inventory, subject and context: they are then
renderings of one document, and check requires them to agree (see Read the CycloneDX rendering).
The evidence manifest§
A publisher that rehashes every asset before signing needs, per release
file, the subject’s SHA-256, size, version and target, each sidecar’s name,
SHA-256 and size, the digest of the inventory they came from, and the
generator. bind has all of these when it writes the sidecars, so it states
them once, in one JSON document rendered from the shared types:
{"profile":"blueprint-spdx-3.0-v2",
"subject":{"filename":"my-binary","version":"1.0.0","target":"x86_64-linux","sha256":"<sha256>","size":1234},
"inventory_sha256":"<sha256 of inventory.json>",
"context":{"namespace":"https://example.org/sbom/my-binary/revision-1","revision":"1","created":"2026-10-21T00:00:00Z","generator":"Tool: blueprint-sbom-0.7.0"},
"sidecars":[{"filename":"my-binary.cdx.json","format":"cyclonedx-1.7","sha256":"<sha256>","size":5678},
{"filename":"my-binary.spdx.json","format":"spdx-3.0.1","sha256":"<sha256>","size":6789}]}
The manifest is evidence about files, not a fourth rendering of the SBOM: it
holds no package, and nothing in it is read as composition. Files are named
by name alone, resolved beside the manifest (the sidecars) or in the artifact
directory (the subject); it never holds a path, an absolute developer path or
a credential, and bind refuses to write one that would. The text is one
line with a fixed key order and the sidecars by name, so the same evidence is
the same bytes. The formats are spdx-3.0.1, spdx-2.3 and
cyclonedx-1.7; the inventory digest is the SHA-256 of the inventory file as
bind read it.
check --manifest holds a manifest to the files it names and checks the
sidecars it lists as documents, so no document need be named:
nix develop -c scripts/sbom.roc check --manifest dist/my-binary.sbom-manifest.json --artifacts dist --inventory inventory.json
It rehashes the subject in --artifacts (or beside the manifest), each
listed sidecar beside the manifest, and the inventory, and refuses a manifest
of another profile, a subject whose digest or size differs, a sidecar whose
digest or size differs or that is absent, an inventory digest that differs
from --inventory, or any file named with a path separator. Each listed
sidecar must also describe the manifest’s subject, in the listed format, and
passes the same profile, subject, inventory and one-graph checks as any
named document. Without --inventory the inventory digest is recorded but
not measured.
subject.json contains filename, version, target, sha256 and url.
An empty SHA-256 asks the binder to compute it; a supplied digest must match.
context.json contains namespace, revision, created, author and
generator. Use a UTC ISO 8601 timestamp, an SPDX person/organization author,
a Tool: name-version generator, and a unique HTTPS namespace ending in
revision-<revision>. Identical context and inputs produce identical bytes
with stable ordering. A correction changes the document revision and namespace,
while retaining the unchanged software digest at a new immutable location.
In CycloneDX the namespace determines the BOM’s serialNumber (a UUID URN
derived from it) and the revision its version.
Metadata inputs and final subjects must be regular files. Sidecars and
manifests use exclusive creation, in every format: an existing file, symlink,
hardlink or ../ alias is rejected, preserving all inputs. Generate into a
fresh staging directory.
Metadata reads are bounded before parsing; credentials and absolute developer
paths are rejected. Generation needs no publication credentials or advisory
service. Ordinary task outputs and sandboxed build outputs use this same
binding operation.
Read the CycloneDX rendering§
The CycloneDX BOM has bomFormat CycloneDX and specVersion 1.7. Its
metadata.component is the subject with its reviewed kind, final
SHA-256 and distribution URL. metadata.timestamp, metadata.tools, and
metadata.authors or metadata.manufacturer carry the context’s creation
time, generator and author. Each inventory component carries its reviewed
kind (file, application or library), and its bom-ref is its local
identifier. Unknown kinds use the documented adapter fallback below. Each
component has:
name,versionand a SHA-256hashesentry;supplierfor anOrganization:producer, orauthorsfor aPerson:producer (none forNOASSERTION);a
licensesexpression, omitted when the license isNOASSERTIONorNONE;purlfrompackage_url, or from a legacypkg:identity;typed
vcsorsource-distributionreferences fromsource_refs, adistributionreference for its download, and alicensereference for thelicense_urlof aLicenseRef-...expression.
license_url is supported only for LicenseRef-... terms; leave it empty
for standard SPDX licenses. Subject and component declarations using the
same LicenseRef must name the same terms URL.
CycloneDX has no relationship types, so a component’s scope summarizes its
role for the subject:
scope | When |
|---|---|
required | The subject reaches the component through contains, or through dependsOn with no scope or scope runtime, directly or transitively. |
excluded | Otherwise, the component is the target of a dependsOn with scope build, test or development, such as the compiler that built the subject. |
optional | Anything else, for example a component reached only through an unresolved other candidate or a patchedBy, generates or hasVariant edge. |
Containment is the standard component assembly tree: components contained by
Subject are nested under metadata.component.components; their children
nest recursively in their components. Components with no containment parent
appear in the BOM’s top-level components. Each bom-ref occurs once. The
writer rejects containment cycles, a contained subject, children with multiple
distinct parents, and chains deeper than 24 parents, with a diagnostic; it
never substitutes dependency edges or duplicate identifiers. SPDX can still
represent shared containment graphs.
The dependencies section lists actual dependsOn targets. Containment alone
does not create a dependency. The full graph travels losslessly in
blueprint: properties: each
relationship, with its scope and basis, is a blueprint:relationship
property of the BOM; each component carries blueprint:identity,
blueprint:origin, blueprint:target and blueprint:modifications; the
metadata carries blueprint:namespace, blueprint:evidence and the
blueprint:cyclonedx-profile marker standard-containment-v1.
A consumer that reads only standard CycloneDX sees what ships, what was only
used to build it, and what remains unresolved. A consumer that needs the exact
relationships, coverage gaps or evidence reads the properties or the SPDX
rendering.
Sbom.read_cyclonedx reads a BOM that Blueprint rendered back to the same
package graph as Sbom.read gives for its SPDX renderings, after validating
it against CycloneDX 1.7 and checking that its nested containment,
dependencies and scopes are what its relationships state; Sbom.read uses it for such BOMs. Other
CycloneDX BOMs, such as a supplier’s, are read by their standard fields (see
Import your suppliers' SBOMs), and what those cannot carry is
counted in the import’s coverage.
Blueprint’s legacy 0.8.0 CycloneDX profile has no
blueprint:cyclonedx-profile marker. The reader continues to accept that
profile only with flat components and the legacy dependency union of
contains and dependsOn, checked against its properties. Its standard
fields cannot distinguish archive membership from dependencies; use the SPDX
sidecar or regenerate it with 0.9.0. New renderings always use the marked
profile and reject disagreement between nesting and properties. Unknown or
repeated profile markers are rejected. Importing a supplier’s ordinary
CycloneDX BOM continues to read nesting as contains and dependencies as
dependsOn, without Blueprint extensions.
Blueprint’s own release evidence§
The read-only release build runs scripts/release_sbom.roc VERSION after the
four CLI executables, the four standalone tool executables, both
library/platform source bundles and the standalone tool app bundle exist. Its
generation
time comes from the selected source commit, not the wall clock. The extended
command VERSION CREATED SOURCE_COMMIT DOCUMENT_REVISION supplies an explicit
context for reproducible local checks or corrections.
The recipe observes actual CLI imports, the embedded compiler table,
compiler-version, link-inputs.lock.json and the build environment’s
flake.lock. Changed imports/compiler/native producer require a reviewed
composition update. Every observed input receives an exported disposition;
no empty project Lock substitutes for release-input knowledge.
The compiler used to build Blueprint is a build input. The target compiler
archive imported into its executable is a separate contained component with
its own archive SHA-256. A source bundle does not contain that compiler, and
neither does a standalone tool executable: its sidecar names the wrapper,
I/O module and core modules compiled in, and the basic-cli host linked, with
the embedded-archive input of its target explicitly excluded.
Each source bundle, the core, the platform and the standalone tool, is
inventoried from the archive in dist/, as
Inventory a source bundle from its archive describes: every member is a
component stated by its own SHA-256, contained by the bundle’s source
component, so a file a module imports is listed whether or not
scripts/bundle.roc named it. The platform source bundle records each
declared target linker member with its own SHA-256, separately from the
original input archive, as the same component the archive listing yields,
and includes known musl/Zig and Windows startup inputs and depends on the
sibling core bundle fetched later by Roc. scripts/check_sbom.roc --release-root refuses a source bundle sidecar that omits a member of its
archive.
The native CLI inventory preserves the pinned basic-cli Cargo graph and a
known SQLite native relationship; reviewed provenance lives in sbom/upstream/.
Unclassified Cargo feature/target/build candidates are explicit other edges;
they do not claim all resolved crates ship in every target. Unknown license
and optimized native/compiler closure scopes retain explicit gaps.
Every release subject has all three sidecars, .spdx.json, .spdx-2.3.json
and .cdx.json, and the evidence manifest .sbom-manifest.json that lists
them with the subject by digest and size (see The evidence manifest).
The inventory each was bound from is written beside them in dist/ as
<subject>.inventory.json for the check, and is not published.
scripts/check_sbom.roc runs the same checks as the tool’s check, plus
checks against this repository’s release authorities, offline before the
release job gathers the sidecars and manifests into the same tested and
attested asset set: each document must be valid SPDX or CycloneDX of its
version, meet the profile, describe the actual artifact bytes, account for
the release’s observed inputs, and state the same package graph as every
other rendering of the same document (the same namespace); the manifest
found beside it, or named with --manifest, must be what the subject, the
sidecars and that inventory file hash to, and each document must describe
the manifest’s subject. Conformance is Roc throughout: core.Spdx3,
core.Spdx2 and core.CycloneDx hold the classes or objects, properties, cardinalities,
enumerations and references of each format, and the test suite holds them to
the published SPDX 3.0.1 JSON schema, the SPDX 2.3.1 schema and the CycloneDX
1.7 schema, vendored in tests/sbom/schema/ and pinned by SHA-256. It
adds no publication permission. Examples/documentation archives are not
covered by the first own-release recipe; consumers must not infer their
composition from a sibling executable’s SBOM.
Exercise the stable consumer contract§
tests/sbom/fixtures/ contains native Linux and ARM64 instances, a source
bundle, a verified imported graph, a source supplier imported into a native
consumer, and distinct empty/unknown coverage records,
each as SPDX 3.0.1 (.spdx.json), SPDX 2.3 (.spdx-2.3.json) and CycloneDX
1.7 (.cdx.json).
The matching evaluated Spec/Lock, composition, inventory, subject and context
are under fixtures/input/, and the source supplier import’s, with one
composition per refusal and expected.json naming each outcome, under
fixtures/source-import/. They let roc-automation test an independent
consumer without depending on Blueprint’s implementation.
fixtures/CONTRACT.md states, for every case, its inputs, its expected
outputs or exact refusal, and what it proves. The suite writes the inputs
and the contract from the same values it asserts and fails when a committed
file differs, so none can drift from the cases.
nix develop -c roc-stable test scripts/test_sbom.roc
nix develop -c scripts/check_sbom.roc tests/sbom/fixtures/*.spdx.json tests/sbom/fixtures/*.spdx-2.3.json tests/sbom/fixtures/*.cdx.json
Each release publishes the set as roc-blueprint-sbom-fixtures-<version>.zip,
covered by the release’s checksums, sidecars and attestation like every other
asset, and holding exactly the files the suite asserts. A consumer runs the
released standalone tool over it without a checkout: unpack the archive,
then, in a scratch copy, run the commands CONTRACT.md gives for each case
and compare the outputs with the files beside them. For the source supplier
import, expected.json names each accepted consumer target with the
inventory inspect must write byte for byte, what bind takes and which
committed renderings it must reproduce, and each tampered composition with
the message inspect must refuse it with:
unzip -q roc-blueprint-sbom-fixtures-<version>.zip
cd roc-blueprint-sbom-fixtures-<version>
roc run <immutable-tool-bundle-url> -- inspect source-import/Spec source-import/Lock source-import/composition.json x86_64-linux out.json
cmp out.json source-import/inventory-x86_64-linux.json
roc run <immutable-tool-bundle-url> -- inspect source-import/Spec source-import/Lock source-import/tamper-subject-digest.json x86_64-linux refused.json
The last command exits 1 and prints
SBOM: Invalid("import subject filename/version/SHA256 mismatch"), the
message expected.json states for that file.
The conformance suite checks a transitive native component’s origin and affected
subject, distinguishes the build compiler from contained code, retains patched
restricted code, separates archive/tree/binary digests, rejects malformed
references and omissions, checks repeatability, reads all three renderings of
every fixture back to one package graph, and exercises read-only binding with
symlink/hardlink aliases and special files. The sbom-render and sbom-read
fuzz targets check rendering and reading in every format with
generated inventories and mutated documents. These checks establish
consistency with declared inputs, not proof that an opaque tool disclosed all
shipped code.
Reviewed kinds, attribution and independent source references (0.9.0)§
The blueprint-spdx-3.0-v2 composition contract adds kind, copyright,
attribution, package_url and source_refs to every component. The reviewed
kinds are file, application, library and unknown; classifications never
come from filenames, origins or dependency scopes. Sbom.archive_members
produces explicit file records because its input is a verified file listing.
Use NOASSERTION for unknown copyright and attribution, an empty package URL,
and an empty source-reference list when none was reviewed. Subject binding
adds its own kind, producer, license, license_url, copyright and
attribution. Those facts are independent of its children: a mixed-license
archive can retain an unknown declared license and supplier. Declaring a
license never manufactures licenseConcluded.
package_url is a separately reviewed, versioned PURL. identity continues
to preserve an opaque source/tree/content identity; a legacy pkg: identity
also supplies the PURL when package_url is empty. Conflicting PURLs are an
error. Each source_refs entry has an explicit type (vcs or
source-distribution) and an HTTPS url. A repository URL is never guessed
to be VCS; no registry or network lookup occurs. Credential-bearing URLs,
queries, whitespace and oversized reference lists are refused. Release
version, commit reference, package URL, tree identity and archive/member
SHA-256 remain separate facts owned and reviewed by the repository producer.
CycloneDX uses standard component type, copyright, purl, supplier,
licenses and typed externalReferences. CycloneDX has no equivalent reviewed
attribution-text field in this adapter, so attribution is retained as
blueprint:attribution. Its schema requires a component type: unknown
uses the historical library fallback for inventory entries and file for
the subject, with an explicit blueprint:kind=unknown declaration. This is
an adapter compatibility default, not a reviewed library classification.
Readers check these exact fallbacks and reject contradictory standard kinds.
SPDX 2.3 uses primaryPackagePurpose (FILE, APPLICATION, LIBRARY or
OTHER), copyrightText, attributionTexts and PURL external references;
SPDX 3.0.1 uses the corresponding standard software purpose, copyright,
attribution and package URL fields. File inventory records are represented
as packages with file purpose so supplied version, supplier and declared
license remain expressible; native SPDX file entries are also imported,
without inventing package metadata or conclusions. Both SPDX adapters put
the explicitly typed source-reference list in standard sourceInfo /
software_sourceInfo text as JSON. The SPDX text field cannot make those
references independently typed links; consumers needing typed URL links
should use CycloneDX. All formats retain opaque identity in Blueprint
metadata, distinct from delivered-file hashes.
Sbom.normalize_input and the standalone inspect/bind input path migrate
legacy JSON with bounded, duplicate-key-aware parsing: the v1 profile becomes
v2, missing component kinds become unknown, subject kind defaults to file,
missing reviewed facts stay unknown, and legacy coverage lacks an addressed
component/aspect and therefore claims no standard completeness. Existing
explicit PURL identities keep working; source-only and opaque identities
are not relabelled. Typed Roc callers must add the new fields explicitly.
Regenerate inspected inventories and bind fresh sidecars to adopt v2; retain
original v1 documents as original evidence rather than rewriting their bytes.
The reviewed-source consumer fixture demonstrates an archive, library,
source file and build compiler with explicit and unknown attribution,
subject declarations, and simultaneous package and commit references.
For Blueprint’s platform release, each delivered linker file has its own
runtime occurrence. The occurrences retain the reviewed runtime’s version,
license, attribution and package/source references, and hasVariant links
them to the shared upstream component. Their local identifiers are distinct;
the measured file hash belongs to the containing member, not the runtime
subset. This states physical copies explicitly while keeping containment a
tree in standard CycloneDX fields.
An unavailable_sboms entry records missing supplier documentation as an
unaddressed evidence gap. It does not assert that dependency closure is
incomplete or override a producer’s explicit assessment. Use addressed
coverage to state unknown, incomplete, complete or verified-empty closure,
even when the supplier publishes no document.