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, contains means the code ships inside the other component. dependsOn means it is needed but not inside. A build scope 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 MIT or Apache-2.0 OR MIT. Identifiers come from the SPDX License List. Terms that are not on that list use a LicenseRef- 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§

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.

AspectSPDXCycloneDX
StewardAn open project hosted by the Linux FoundationOWASP, standardized by Ecma International Technical Committee 54 (TC54)
International standardISO/IEC 5962:2021, which is SPDX 2.2.1ECMA-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 writes3.0.1, and 2.3 for older readers1.7
Vulnerability status (VEX)SPDX 3.0.1’s Security model has VEX relationship classesVEX is a listed CycloneDX capability
Typical consumersLicense compliance and legal review; scanners that read SPDX JSON, such as TrivySecurity tooling such as Dependency-Track, which consumes CycloneDX; Trivy also reads CycloneDX JSON

Blueprint writes these sidecar files next to each delivered file:

SidecarContents
<asset-name>.spdx.jsonSPDX 3.0.1 JSON-LD. The primary statement.
<asset-name>.cdx.jsonCycloneDX 1.7 JSON.
<asset-name>.spdx-2.3.jsonSPDX 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?§

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.

InformationSPDX 3.0.1SPDX 2.3CycloneDX 1.7
Delivered file, its version and SHA-256Root software_Package with a HashPackage named by DESCRIBES, with a checksummetadata.component with hashes
Component identityNAMESPACE#name; purl as software_packageUrlSPDXRef-name; purl as an external referencebom-ref and purl; the full identity as a blueprint:identity property
Archive or file SHA-256verifiedUsingchecksumshashes
SuppliersuppliedBy an agentsuppliersupplier (organization) or authors (person)
Declared license, including NOASSERTION and NONEhasDeclaredLicense, with named individuals for bothlicenseDeclaredlicenses expression; NOASSERTION is omitted and NONE is a blueprint:license property
Shipped code (contains)containsCONTAINSnested components, and scope required
Build, test and development toolsdependsOn with a lifecycle scopeBUILD_DEPENDENCY_OF and its siblingsa dependency, and scope excluded
generates, patchedBy, hasVariant, copiedTo, otherthe same relationship typesGENERATES, PATCH_FOR, VARIANT_OF, COPY_OF, OTHERblueprint:relationship properties only
Coverage, input dispositions and reasonsan Annotationan OTHER annotationa blueprint:evidence property

Where CycloneDX loses meaning:

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:

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.

  1. 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.

  2. Export the evaluated Spec and the Lock. The tool reads them, not Blueprint.roc. Run blueprint update first if the Lock is not current.

    mkdir -p sbom
    blueprint spec > sbom/Spec
    cp Blueprint.lock sbom/Lock
    
  3. Write the reviewed composition. sbom/composition.json says 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": []
    }
    

    inputs needs one entry for every input your Spec and Lock know about, including ones that do not ship. Names have the form source:NAME, program:NAME:SYSTEM, tool:SOURCE#NAME, roc-package:NAME, build-source:NAME, task:NAME and build:NAME. Give each a component it became, or an empty component and a reason it is excluded. A component can take its version, identity, download and digest from the Lock instead: see Inspect without executing a provider.

  4. 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.json
    

    A refusal names the rule that failed, such as known input omitted or unknown/duplicate input disposition.

  5. Describe the subject and the document. sbom/subject.json names the file. Leave sha256 empty and bind computes it. sbom/context.json identifies 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"}
    
  6. Bind the final bytes. This hashes dist/my-app and writes dist/my-app.spdx.json and dist/my-app.cdx.json beside 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.3
    
  7. Check. check holds each sidecar to its format’s specification and the profile. With --artifacts it confirms each describes the actual file, with --inventory that 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.json
    
  8. Publish 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. Keep composition.json under 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:

What recipients do with the files§

What an SBOM does not prove§

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:

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 compositionSPDX 2.3
A contains BA CONTAINS B
A dependsOn BA DEPENDS_ON B
A dependsOn B, scope buildB BUILD_DEPENDENCY_OF A
A dependsOn B, scope testB TEST_DEPENDENCY_OF A
A dependsOn B, scope developmentB DEV_DEPENDENCY_OF A
A dependsOn B, scope runtimeB RUNTIME_DEPENDENCY_OF A
A generates BA GENERATES B
A patchedBy BB PATCH_FOR A
A hasVariant BB VARIANT_OF A
A copiedTo BB COPY_OF A
A other BA 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 formatWhat Blueprint reads
SPDX 3.0.1 JSON-LDPackages, 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 JSONPackages and their relationships. DESCRIBES, DESCRIBED_BY or documentDescribes names the subject.
CycloneDX 1.4 to 1.7 JSONmetadata.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:

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:

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:

scopeWhen
requiredThe subject reaches the component through contains, or through dependsOn with no scope or scope runtime, directly or transitively.
excludedOtherwise, the component is the target of a dependsOn with scope build, test or development, such as the compiler that built the subject.
optionalAnything 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.