> Documentation version: main. [HTML page](https://lukewilliamboswell.github.io/roc-blueprint/sbom.html)

<a id="artifact-bound-software-bills-of-materials"></a>

# Describe a delivered artifact with an SBOM

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.

<a id="what-an-sbom-is"></a>

## 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](https://spdx.org/licenses/). 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](https://github.com/package-url/purl-spec). 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.

<a id="why-people-ask-for-an-sbom"></a>

### 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](https://www.cisa.gov/sites/default/files/2026-07/2026_cisa_sbom_minimum_elements_508c.pdf),
  which says what a useful SBOM contains.

This page is a guide, not legal advice. Check your own obligations.

<a id="spdx-and-cyclonedx"></a>

## 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 `.

<a id="which-format-to-send"></a>

### Which should I send?

- **By default, send both SPDX 3.0.1 and CycloneDX.** This is what ` bind `
  writes 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.

<a id="what-each-format-can-say"></a>

### 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 ` components ` state containment;
  CycloneDX ` dependencies ` state only actual ` dependsOn ` relationships. Read
  ` scope ` to tell shipped code from tools:
  ` required ` is reached through shipped or run-time edges, ` excluded ` is only
  a build, test or development dependency, and ` optional ` is 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 the ` blueprint:relationship ` properties.

- **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
  ` incomplete ` coverage rather than left out.

- ` blueprint lock show --json ` reports committed pins for scripts. It is not
  an SBOM, and it does not show that a build happened or what it contains.

<a id="publish-an-sbom"></a>

## 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](#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.

   ``` console
   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:

   ``` json
   {
     "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](#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.

   ``` console
   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.

   ``` json
   {"filename": "my-app", "version": "1.2.0", "target": "x86_64-linux", "sha256": "",
    "url": "https://example.org/releases/1.2.0/my-app"}
   ```

   ``` json
   {"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.

   ``` console
   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:

   ``` console
   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.

<a id="sbom-quality-checklist"></a>

## 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: ... ` or ` Person: ... `. Use
  ` NOASSERTION ` only when you do not know.

- **Licenses are real expressions.** ` NOASSERTION ` means you made no
  determination, or chose not to say. ` NONE ` means the component has no
  license information at all. Neither means "no restrictions". For custom
  terms, use a ` LicenseRef- ` name with a ` license_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_sboms ` with the reason;
  ` inspect ` refuses an inventory that does neither. See
  [Chain SBOMs across your dependencies](#chain-sboms-across-dependencies).

- **Build tools are build dependencies, not shipped code.** A compiler is
  ` dependsOn ` with scope ` build `. It is never ` contains `.

- **Gaps are stated, not hidden.** If you do not know part of the
  inventory, add an ` incomplete ` coverage entry with a reason and a contact.
  A recipient reads a missing component as "not present". Use
  ` verified-empty ` only 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 ` revision ` and
  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.

<a id="what-recipients-do"></a>

## 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 --manifest ` confirms that mapping against the files (see
  [Bind the final bytes separately](#bind-the-final-bytes-separately)).

<a id="what-an-sbom-does-not-prove"></a>

### 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
  ` incomplete ` coverage gap.

The rest of this chapter is reference material.

<a id="build-and-development-environments"></a>

### 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](#import-inert-verified-upstream-graphs)).

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

<a id="assign-the-knowledge-to-its-owner"></a>

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

<a id="use-the-released-standalone-tool"></a>

## 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](#blueprints-own-release-evidence)).

``` console
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](#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](#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.

<a id="inspect-without-executing-a-provider"></a>

## 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:

``` console
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 ` . `), canonical ` name `, SPDX ` producer `
  (` Organization: ... `, ` Person: ... `, or ` NOASSERTION `), ` license `,
  ` license_url `, ` modified `, ` target ` and ` origin `. An identifier ` name `
  becomes ` NAMESPACE#name ` in SPDX 3.0.1 and ` SPDXRef-name ` in SPDX 2.3.

- ` relationships `: records with ` from `, ` to `, an SPDX 3.0.1 ` relation `, a
  lifecycle ` scope ` (` "" ` for none) and reviewed ` basis `. ` Subject ` names the
  final deliverable.

- ` coverage `: records with ` scope `, ` component `, ` aspect `, ` status `, ` reason `
  and ` contact `. ` component ` names ` Subject ` or a declared component;
  ` aspect ` is ` assembly ` (direct contained membership) or ` dependencies `
  (direct dependency closure). Status is ` complete `, ` verified-empty `,
  ` incomplete `, ` unknown `, or ` excluded `. Reasons and contacts remain evidence
  in every format.

- ` inputs `: one reviewed disposition for every known input, with ` input `,
  ` component ` and ` reason `. 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, with ` component `, ` reason ` and ` contact `. Every other
  locked dependency needs a supplier SBOM in ` imported_documents `, bound to it:
  see [Chain SBOMs across your dependencies](#chain-sboms-across-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](#import-inert-verified-upstream-graphs) 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.

<a id="import-inert-verified-upstream-graphs"></a>

## 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://... ` or ` git+ssh://... `, becomes ` NOASSERTION `.

- A license that is not an SPDX expression, or is named only in text,
  becomes ` NOASSERTION `. A ` LicenseRef- ` with no terms location keeps
  ` NOASSERTION ` as 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-ref `s 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.

<a id="inventory-a-source-bundle-from-its-archive"></a>

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

``` console
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:

``` json
{"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:

``` console
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.

<a id="chain-sboms-across-dependencies"></a>

## 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](builds.md#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 `:

``` json
{
  "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:

``` json
"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.

<a id="bind-the-final-bytes-separately"></a>

## 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:

``` console
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:

``` console
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](#read-the-cyclonedx-rendering)).

<a id="the-evidence-manifest"></a>

### 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:

``` json
{"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:

``` console
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.

<a id="read-the-cyclonedx-rendering"></a>

## 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 `, ` version ` and a SHA-256 ` hashes ` entry;


- ` supplier ` for an ` Organization: ` producer, or ` authors ` for a ` Person: `
  producer (none for ` NOASSERTION `);

- a ` licenses ` expression, omitted when the license is ` NOASSERTION ` or
  ` NONE `;

- ` purl ` from ` package_url `, or from a legacy ` pkg: ` identity;


- typed ` vcs ` or ` source-distribution ` references from ` source_refs `, a
  ` distribution ` reference for its download, and a ` license ` reference for
  the ` license_url ` of a ` LicenseRef-... ` 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](#import-inert-verified-upstream-graphs)), 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.

<a id="blueprints-own-release-evidence"></a>

## 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](#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-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.

<a id="exercise-the-stable-consumer-contract"></a>

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

``` console
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:

``` console
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.

<a id="reviewed-kinds-attribution-and-independent-source-references-0-9-0"></a>

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

