diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index f450520..5f98df8 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -41,9 +41,47 @@ on:
workflow_dispatch:
jobs:
+ # Slice 002. The bill of materials is derived from `mix.lock` alone: it is the same document
+ # whichever runner produces it, and `mix trinity.sbom` needs the project compiled in the dev
+ # environment, which the three packaging jobs do not otherwise do. Generating it once here and
+ # handing it to each of them keeps the bill travelling with every binary without paying for a
+ # second full compile on three operating systems, and without giving Windows a chance to
+ # produce a bill that differs from Linux's for no reason a reader could act on.
+ sbom:
+ name: bill of materials
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+
+ - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
+ with:
+ version-file: .tool-versions
+ version-type: strict
+
+ - run: mix deps.get
+
+ - name: Generate the bill of materials
+ run: mix trinity.sbom
+
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: sbom
+ path: sbom.cdx.json
+ if-no-files-found: error
+
package:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
+ needs: sbom
+ permissions:
+ contents: read
+ # Slice 002: build provenance is an attestation signed through GitHub's OIDC identity for
+ # this workflow run, recorded in the public transparency log. `id-token` mints that
+ # identity and `attestations` writes the record; neither grants anything else.
+ id-token: write
+ attestations: write
strategy:
fail-fast: false
matrix:
@@ -319,12 +357,64 @@ jobs:
[ -n "$CHILD" ] && kill "$CHILD" 2>/dev/null || true
fi
+ # Slice 002. The bill of materials travels with the binary rather than being published
+ # somewhere a reader has to go and find: whoever has the artifact has the list of what is
+ # in it, and the bill states in its own metadata what it does not cover.
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ name: sbom
+ path: burrito_out
+
+ # The bill is placed beside the binary rather than listed as a second upload path, so that
+ # both land at the root of the artifact. Listing them separately makes upload-artifact take
+ # the workspace as their common ancestor and file the binary under `burrito_out/` inside the
+ # zip, which is a layout change every earlier release's consumers would have to absorb for
+ # no benefit.
+ #
+ # Corrected 2026-09-23, because the first version of this comment named a cause that is not
+ # true. It said `gh run download --name` refuses the two-path layout with "would result in
+ # path traversal". It does refuse, but the layout has nothing to do with it: gh 2.45.0 (the
+ # version in Ubuntu's archive) raises that error for any artifact when extracting into the
+ # current directory, including a pre-slice-002 single-file one, and passing `-D
`
+ # succeeds for both. gh 2.63.2 downloads both without `-D`. The error was measured against
+ # unlike invocations and read as a regression it was not.
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ${{ matrix.artifact }}
- path: burrito_out/${{ matrix.artifact }}
+ path: |
+ burrito_out/${{ matrix.artifact }}
+ burrito_out/sbom.cdx.json
if-no-files-found: error
+ # Build provenance: a signed statement of which workflow, at which commit, produced these
+ # exact bytes. It proves where the artifact came from, not what it does. Verified in this
+ # same job, because an attestation nobody checks is a file rather than an assurance.
+ - name: Attest the artifact's provenance
+ id: attest
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
+ with:
+ subject-path: burrito_out/${{ matrix.artifact }}
+
+ - name: Verify the attestation, and record the result in the run summary
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ run: |
+ set -o pipefail
+ gh attestation verify "burrito_out/${{ matrix.artifact }}" \
+ --repo "${{ github.repository }}" 2>&1 | tee attestation.txt
+ # `gh attestation verify` draws its result with carriage returns, which the log
+ # viewer collapses to nothing. One plain line the log keeps, reached only because
+ # `set -e` and `pipefail` end the step on a failed verification.
+ echo "gh attestation verify: PASS for ${{ matrix.artifact }}"
+ {
+ echo "### Build provenance: ${{ matrix.artifact }}"
+ echo
+ echo '```'
+ cat attestation.txt
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
diff --git a/.gitignore b/.gitignore
index d123a64..50898c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,7 @@ trinity-*.tar
npm-debug.log
/assets/node_modules/
*.db-*
+
+# Slice 002: the bill of materials is generated by the gate and attached to releases,
+# never committed: it is a statement about a build, not a source file.
+/sbom.cdx.json
diff --git a/README.md b/README.md
index eee1477..ffffc41 100644
--- a/README.md
+++ b/README.md
@@ -145,6 +145,14 @@ documents themselves for consistency.
Packaging as a single binary is documented in `docs/packaging.md`, with measured sizes and
start-up times for each target; `docs/fips-leg.md` describes the FIPS build leg.
+**Transport and message authentication.** Outbound connections use Erlang/OTP's TLS, whose default
+client versions on the pinned toolchain are TLS 1.3 and TLS 1.2: **1.2 is the floor**, and Trinity
+does not lower it (TLS 1.1 and 1.0 are available in the runtime but are not in the default set). The
+only message authentication code Trinity itself mints is the **AES-256-GCM** tag over the state
+envelope a held tool call carries between round trips, which is an approved algorithm in **FIPS**
+mode. Both statements are pinned by a test against the runtime and the source rather than against
+the sentence, so the line cannot outlive the fact.
+
## How the work is organised
Trinity is built in reviewed increments. Each one is small enough to review in one sitting and
@@ -178,8 +186,8 @@ rather than described by it.
| Auditability | Decisions and effects are recorded in an Ed25519-signed hash chain with checkpoints and a verifier, so an operator can reconstruct what was done, by whom and under what decision. |
| Untrusted content | Anything arriving from outside the machine is marked untrusted at the boundary and is never treated as instruction. |
| Approved cryptography | A dedicated CI leg builds from source and runs the cryptographic properties inside a FIPS-mode container, so statements about approved algorithms are measured on that leg rather than asserted. |
-| Supply chain | Dependency and licence audits run on every commit; dependency versions are pinned in `VERSIONS.md` and verified against the lock file by the gate. |
-| Provenance | Every commit carries a Developer Certificate of Origin sign-off, enforced by a hook and independently by CI. |
+| Supply chain | Dependency and licence audits run on every commit; dependency versions are pinned in `VERSIONS.md` and verified against the lock file by the gate. A CycloneDX bill of materials is generated by the gate on every commit and ships beside every packaged binary, so whoever holds the artifact holds the list of what is in it; the bill states its own coverage inside the document, including what it does not cover. |
+| Provenance | Every commit carries a Developer Certificate of Origin sign-off, enforced by a hook and independently by CI. Every packaged binary carries build provenance attested through the workflow's own identity and recorded in a public transparency log, verified in the same run that produced it, so a holder can check where the bytes were built without trusting this page. |
| Independent self-certification | The project holds the [OpenSSF Best Practices passing badge](https://www.bestpractices.dev/projects/14772), assessed against the Open Source Security Foundation's published criteria for basics, change control, reporting, quality, security and analysis. Three suggested criteria are recorded as unmet rather than stretched: semantic versioning, which begins at the first supported release, and two concerning dynamic analysis tooling, which the project does not yet run. |
| Argued, not asserted | `docs/10-assurance-case.md` decomposes the top-level security claim into ten claims, each with its argument, the evidence a reader can check, and the limit on what it covers; the assumptions the case rests on are named rather than implied. |
| Claim discipline | `docs/09-standards-register.md` records one row per control a regulated deployment might ask about, each with an evidence path and a status. No public claim about a regulation or requirement is made without a row there saying it is true. |
diff --git a/VERSIONS.md b/VERSIONS.md
index b87a3c3..78e7b27 100644
--- a/VERSIONS.md
+++ b/VERSIONS.md
@@ -89,6 +89,7 @@ never pin a version hex marks as retired or vulnerable.
| `postgrex` | >= 0.0.0 (optional) | ✅ in `mix.lock` | Secondary DB driver, `optional: true` so the desktop build carries none of it; compiled in only under `TRINITY_DB=postgres`, which the CI job proves. Added at Slice 010. Was one row with pgvector; pgvector keeps its own row below. |
| `pgvector` | ~> 0.4.1 | ✅ in `mix.lock` | Vectors on the Postgres path: `memories.embedding_vector vector(384)` under an HNSW cosine index, searched by `Trinity.Memory.VectorStores.Pgvector` (Slice 032). The postgres job runs on the `pgvector/pgvector:pg17` image. Split from the postgrex row at Slice 010. |
| `oban` | ~> 2.24 | ✅ in `mix.lock` | Uses `Oban.Engines.Lite` on SQLite and `Oban.Engines.Basic` on Postgres (the adapter is compile time, ADR-0002, so the engine is too). ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050 (2.24.1 measured on hex.pm 2026-09-22, Apache-2.0). |
+| `sbom` | ~> 0.11 | ✅ in `mix.lock` | CycloneDX software bill of materials, generated by the gate from mix.lock (`mix sbom.cyclonedx`). Maintained by erlef, the Erlang Ecosystem Foundation; BSD-3-Clause, CC0-1.0, MIT and Apache-2.0; 0.11.0 measured on hex.pm 2026-09-23, released 2026-09-22. Dev and test only: it ships in no artifact, and the gate validates its output, so a defect in it fails the build rather than producing a quietly wrong bill. Added at Slice 002. |
| `jose` | ~> 1.11 | ✅ in `mix.lock` | JOSE (JWK, JWS, JWKS) over OTP's crypto, for the MCP resource server's token validation and the personal profile's embedded authorization server. MIT; 1.11.12 measured on hex.pm 2026-09-22. Not joken, a layer over it this tree does not need. Added at Slice 062. |
| `oban_web` | ~> 2.13 | ✅ in `mix.lock` | The Oban dashboard, a LiveView mounted at /oban (dev, and behind `config :trinity, :oban_web, true` elsewhere). Apache-2.0 on hex.pm since the 2.12 line (2.13.0 measured 2026-09-22); the plan of 2026-09-05 wrote it in when it was commercial. Added at Slice 050. |
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
diff --git a/coverage.tsv b/coverage.tsv
index 465a8a1..7e2d7d3 100644
--- a/coverage.tsv
+++ b/coverage.tsv
@@ -22,3 +22,4 @@ slice_id percent sha date
060 80.32 49f40a4 2026-09-21
061 80.05 c6b6faa 2026-09-22
050 80.31 c01466d 2026-09-22
+002 80.31 d65c48c 2026-09-23
diff --git a/docs/10-assurance-case.md b/docs/10-assurance-case.md
index 6de57fa..4de68b7 100644
--- a/docs/10-assurance-case.md
+++ b/docs/10-assurance-case.md
@@ -137,19 +137,35 @@ algorithms are measured on a dedicated build rather than asserted.
## C8. The supply chain is controlled and the provenance of changes is established
-**Argument.** Dependencies are pinned and audited; every change carries an attested author; and no
-change reaches the main branch without the full gate.
+**Argument.** Dependencies are pinned and audited; what is in a build is enumerated in the build;
+where a released binary came from can be checked by whoever holds it; every change carries an
+attested author; and no change reaches the main branch without the full gate.
**Evidence.**
- Versions pinned in `VERSIONS.md` and verified against the lock file by the gate; `hex.audit` and
`deps.audit` run on every commit.
+- A CycloneDX bill of materials is generated by the gate on every commit (`mix trinity.sbom`) and
+ uploaded with each platform's artifacts, so whoever has the binary has the list of what is in
+ it. The bill states its own coverage in a `metadata.properties` entry rather than in a document
+ beside it.
+- Each released artifact carries build provenance attested through GitHub's workflow identity and
+ recorded in a public transparency log, and `gh attestation verify` checks it in the same job
+ that produced it, with its output in the run summary. An attestation nobody checks is a file
+ rather than an assurance.
- Every commit carries a Developer Certificate of Origin sign-off, enforced by a local hook and
independently by CI, so a bypassed hook still fails the build.
- The main branch is protected: changes merge only through a pull request with the gate green.
-**Gap, stated rather than omitted.** The project does not yet publish a signed Software Bill of
-Materials. It is planned work, and until it exists this claim is weaker than current federal
-guidance asks for.
+**Gaps, stated rather than omitted.** Two remain, and they are narrower than the one this document
+stated before slice 002 rather than absent.
+
+- The bill covers Hex dependencies resolved from `mix.lock` and does not cover the Rust crates the
+ desktop shell links. A reader who misses the coverage property would conclude there is no Rust
+ in the product, which is false. Slice 121 adds the crate half.
+- The bill is not itself signed with a project-held key, and releases are not signed. Provenance
+ establishes where a binary was built; it does not establish that the project intended to publish
+ it. Signing is slice 101, and until it exists this claim is weaker than current federal guidance
+ asks for.
## C9. Memory safety by construction
diff --git a/lib/mix/tasks/trinity.sbom.ex b/lib/mix/tasks/trinity.sbom.ex
new file mode 100644
index 0000000..1ee0bf2
--- /dev/null
+++ b/lib/mix/tasks/trinity.sbom.ex
@@ -0,0 +1,125 @@
+# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
+# SPDX-License-Identifier: Apache-2.0
+defmodule Mix.Tasks.Trinity.Sbom do
+ @shortdoc "Generates the CycloneDX software bill of materials, and says what it is blind to"
+ @moduledoc """
+ Slice 002. Runs `mix sbom.cyclonedx` over `mix.lock` and writes `sbom.cdx.json`, then makes two
+ corrections to what it generated. Neither is decoration: without the first the bill is read as
+ saying more than it knows, and without the second it is not a valid CycloneDX document at all.
+
+ **What the bill covers, and what it does not.** It is generated from `mix.lock`, so it is exact
+ for Hex dependencies and blind to the Rust crates the desktop shell links. A reader who takes it
+ for the whole picture would conclude the product has no Rust in it, which is false. Rather than
+ leave that to a paragraph in a document nobody reads beside the file, the coverage is written
+ into the bill itself as a `metadata.properties` entry, where a tool or a person reading the
+ artifact alone will find it.
+
+ **Licence identifiers that SPDX does not define.** CycloneDX validates `license.id` against the
+ SPDX enumeration and rejects a bill carrying anything else, while a Hex package's `licenses`
+ metadata is free text that its author typed. `sbom` emits a lone licence string as an `id`
+ without checking it (`deps/sbom/lib/sbom/cyclonedx.ex`, `license: {:id, license}`), so one
+ dependency writing `BSD 2-Clause` for `BSD-2-Clause` makes the whole document fail validation:
+ measured, not supposed, on the first bill this project generated. Any identifier this project
+ cannot find in the vendored SPDX list is therefore moved to `license.name`, which is where
+ CycloneDX puts a licence it cannot resolve to an identifier. Nothing is dropped and nothing is
+ guessed: `BSD 2-Clause` stays `BSD 2-Clause`, recorded as a name rather than asserted as an
+ identifier it is not.
+
+ The gate runs this, so a bill that cannot be generated fails the build rather than being missing
+ from a release nobody checked.
+ """
+ use Boundary, classify_to: Trinity
+ use Mix.Task
+
+ @output "sbom.cdx.json"
+ @spdx_ids_path "priv/spdx/license-ids.txt"
+ @coverage_property "trinity:coverage"
+ @coverage_value "Hex dependencies resolved from mix.lock. This bill does NOT cover the Rust " <>
+ "crates linked by the desktop shell (src-tauri/Cargo.lock); that half is " <>
+ "slice 121. Absence of a component here is not evidence of its absence " <>
+ "from the product."
+
+ @impl Mix.Task
+ def run(argv) do
+ output = Enum.at(argv, 0, @output)
+ Mix.Task.run("sbom.cyclonedx", ["--force", "--pretty", "--output", output])
+ annotate!(output)
+ Mix.shell().info("trinity.sbom: #{output} written and annotated with its coverage")
+ end
+
+ @doc """
+ The SPDX licence identifiers CycloneDX accepts, as a set.
+
+ Read from the vendored list rather than from a dependency: see the file's own header for where
+ it came from and for which way it fails when it goes stale.
+ """
+ @spec spdx_ids() :: MapSet.t(String.t())
+ def spdx_ids do
+ Application.app_dir(:trinity, @spdx_ids_path)
+ |> then(fn path -> if File.exists?(path), do: path, else: @spdx_ids_path end)
+ |> File.read!()
+ |> String.split("\n", trim: true)
+ |> Enum.reject(&String.starts_with?(&1, "#"))
+ |> MapSet.new()
+ end
+
+ @doc "The property name the bill carries its coverage statement under."
+ @spec coverage_property() :: String.t()
+ def coverage_property, do: @coverage_property
+
+ @doc "The coverage statement written into the bill."
+ @spec coverage_value() :: String.t()
+ def coverage_value, do: @coverage_value
+
+ # Read, add the property, write back. A generation that produced something unreadable fails
+ # here rather than shipping: the point of the bill is that it can be read.
+ defp annotate!(output) do
+ bom =
+ case output |> File.read!() |> Jason.decode() do
+ {:ok, %{"bomFormat" => "CycloneDX"} = bom} ->
+ bom
+
+ {:ok, other} ->
+ Mix.raise("#{output} is not a CycloneDX document: #{inspect(Map.keys(other))}")
+
+ {:error, reason} ->
+ Mix.raise("#{output} is not readable as JSON: #{inspect(reason)}")
+ end
+
+ metadata = Map.get(bom, "metadata", %{})
+
+ properties =
+ metadata
+ |> Map.get("properties", [])
+ |> Enum.reject(&(Map.get(&1, "name") == @coverage_property))
+ |> Kernel.++([%{"name" => @coverage_property, "value" => @coverage_value}])
+
+ annotated =
+ bom
+ |> Map.put("metadata", Map.put(metadata, "properties", properties))
+ |> Map.replace_lazy("components", &normalise_licenses(&1, spdx_ids()))
+
+ File.write!(output, Jason.encode_to_iodata!(annotated, pretty: true))
+ end
+
+ # An identifier SPDX does not define is not an identifier. It keeps its text and changes field.
+ defp normalise_licenses(components, ids) when is_list(components) do
+ Enum.map(components, fn component ->
+ Map.replace_lazy(component, "licenses", fn choices ->
+ Enum.map(choices, &normalise_choice(&1, ids))
+ end)
+ end)
+ end
+
+ defp normalise_licenses(components, _ids), do: components
+
+ defp normalise_choice(%{"license" => %{"id" => id} = license} = choice, ids) do
+ if MapSet.member?(ids, id) do
+ choice
+ else
+ %{choice | "license" => license |> Map.delete("id") |> Map.put("name", id)}
+ end
+ end
+
+ defp normalise_choice(choice, _ids), do: choice
+end
diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex
index 2e240d6..4973f20 100644
--- a/lib/trinity/versions.ex
+++ b/lib/trinity/versions.ex
@@ -153,6 +153,13 @@ defmodule Trinity.Versions do
note:
"Uses `Oban.Engines.Lite` on SQLite and `Oban.Engines.Basic` on Postgres (the adapter is compile time, ADR-0002, so the engine is too). ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050 (2.24.1 measured on hex.pm 2026-09-22, Apache-2.0)."
},
+ %{
+ name: "sbom",
+ pin: "~> 0.11",
+ lock: "sbom",
+ note:
+ "CycloneDX software bill of materials, generated by the gate from mix.lock (`mix sbom.cyclonedx`). Maintained by erlef, the Erlang Ecosystem Foundation; BSD-3-Clause, CC0-1.0, MIT and Apache-2.0; 0.11.0 measured on hex.pm 2026-09-23, released 2026-09-22. Dev and test only: it ships in no artifact, and the gate validates its output, so a defect in it fails the build rather than producing a quietly wrong bill. Added at Slice 002."
+ },
%{
name: "jose",
pin: "~> 1.11",
diff --git a/mix.exs b/mix.exs
index 4366cd9..264896c 100644
--- a/mix.exs
+++ b/mix.exs
@@ -216,6 +216,9 @@ defmodule Trinity.MixProject do
{:mox, "~> 1.2", only: :test},
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.15", only: [:dev, :test], runtime: false},
+ # Slice 002: the CycloneDX bill of materials the gate generates. Dev and test only:
+ # it produces a record about the build and ships in no artifact.
+ {:sbom, "~> 0.11", only: [:dev, :test], runtime: false},
{:ex_doc, "~> 0.38", only: :dev, runtime: false},
{:nimble_options, "~> 1.1"},
# Slice 001 line 1, arm (a) recorded `only: :dev`. **Corrected at G4**, and the reason is
@@ -299,6 +302,9 @@ defmodule Trinity.MixProject do
# a FIPS property, so measuring it outside the mode loses nothing; 024's FIPS
# properties are measured by `mix test --trace test/fips`, which stays in the mode.
"cmd env ERL_AFLAGS= ./scripts/prod_check.sh",
+ # Slice 002: the bill of materials is generated by the gate, so a bill that cannot
+ # be produced fails the build rather than being quietly missing from a release.
+ "trinity.sbom",
"credo --strict",
"sobelow --exit --skip",
# `cmd` runs it as its own OS process: Hex's tasks are not reliably resolvable from
diff --git a/mix.lock b/mix.lock
index 8e8dd5c..913ce06 100644
--- a/mix.lock
+++ b/mix.lock
@@ -35,6 +35,7 @@
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
+ "hex_core": {:hex, :hex_core, "0.19.0", "b9b5d3cde2d5227b85c62c4ab9f9db586e6b38cf65a9bf5b683757cae9ced5eb", [:rebar3], [], "hexpm", "c2cc414f3893a7edbcab28207cadca13829407abe41f689c82f379fe22f026a5"},
"hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
"igniter": {:hex, :igniter, "0.8.4", "f79f1bbdc2fb7b9ca030a22d12a585b060cbf5b94b9d3f23b1148578a9e05d11", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "a9b1cbec996ccb100b4f7d8130129b2dd3f18eb4224ac9a0e907e428ca90dbd7"},
@@ -64,6 +65,7 @@
"oban": {:hex, :oban, "2.24.1", "2a609c54697ad2c44ba339df30491df2a40eda0758c95b5b879426e4e478bd1f", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef8482472cf198554400b7f8e36a0ffee75c3a64de425d5c7ee625d271925ac7"},
"oban_met": {:hex, :oban_met, "1.3.1", "1d8f5ab69e94802c8fbc154dfb3de346528f55f08724f591d3c327bf4fca2ef8", [:mix], [{:oban, "~> 2.21", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "abf49524a305bad413fef7cad798fcd3a2d42ec739a0736475582c5dc8cbca1c"},
"oban_web": {:hex, :oban_web, "2.13.0", "9c37ab5c2b976681a647dead0de8a67139620e9ff636d041b5324f90c34467b3", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.24", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.1", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "78af08760ca455ab7d19a311c77de07a0e7fb512d7d68043185cee04d51fbee2"},
+ "optimus": {:hex, :optimus, "0.6.1", "b493bdc7ee71035fdf8cbfbf158b1863e2d622b65c3906153e4042815c901fca", [:mix], [], "hexpm", "c0db4107a51f5af94de8b05e4208333ebb8016a3bfdbcd74df6e5c99829db17f"},
"owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"},
"pgvector": {:hex, :pgvector, "0.4.1", "f45b3be1ad37efa944b418757da5414eb59085997c6de85ea83ac5b5b82701af", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:nx, "~> 0.5 or ~> 1.0", [hex: :nx, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "2bb5c468ff6c435941ca326b408a0a01a1526dcd8f2c3c478be44be091ad88e4"},
"phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"},
@@ -78,12 +80,15 @@
"plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"},
"polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"},
"postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"},
+ "protobuf": {:hex, :protobuf, "0.17.0", "39e24e43c9648e148feba16ed51100b5b2028ea900b55460377b0476f6e10613", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ca6c91f6f63e2c147b47f03eefd10b80538aa6fc55ff4b12b795efb786b0152f"},
+ "purl": {:hex, :purl, "0.5.0", "67cc2a57142236ad4a93184e8db5b83e8285b4b7cf7ad4a67eeefcb61e3134f3", [:mix, :rebar3], [{:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "658057133f7698b250821828185997279dba75f3991f5b570357cf3d73f64b4d"},
"req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"},
"req_llm": {:hex, :req_llm, "1.24.0", "cdc5c5cd7f38c0e17cf7a949ced99b6760dcba0d860cd4d066fea1aeba422929", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ex_aws_auth, "~> 1.4", [hex: :ex_aws_auth, repo: "hexpm", optional: true]}, {:goth, "~> 1.4", [hex: :goth, repo: "hexpm", optional: true]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jsv, "~> 0.11", [hex: :jsv, repo: "hexpm", optional: false]}, {:llm_db, ">= 2026.9.3 and < 2027.0.0", [hex: :llm_db, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:server_sent_events, "~> 1.1.0", [hex: :server_sent_events, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:websockex, "~> 0.5.1", [hex: :websockex, repo: "hexpm", optional: false]}, {:zoi, "~> 0.14", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "8ab6bda68e28afc3adf070cc6616d51831b42b8ff144116a9f104999af4c763f"},
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
"rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"safetensors": {:hex, :safetensors, "0.1.3", "7ff3c22391e213289c713898481d492c9c28a49ab1d0705b72630fb8360426b2", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fe50b53ea59fde4e723dd1a2e31cfdc6013e69343afac84c6be86d6d7c562c14"},
+ "sbom": {:hex, :sbom, "0.11.0", "b36348696dc1a0a0a350ebf26de12b7508fb774e565e213cba95751fc73a5447", [:mix], [{:hex_core, "~> 0.19.0", [hex: :hex_core, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:optimus, "~> 0.6.1", [hex: :optimus, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.17.0", [hex: :protobuf, repo: "hexpm", optional: false]}, {:purl, "~> 0.5.0", [hex: :purl, repo: "hexpm", optional: false]}], "hexpm", "edbd95e75155fed8fe61a19f21ea590c58178fe237c816afa534bc514ae53eb9"},
"server_sent_events": {:hex, :server_sent_events, "1.1.0", "54606238b9182ba673a10ce90bd95f81a0939c1baa91cdf86b66acc04edd1832", [:mix], [], "hexpm", "8e164db8e295a2d869a8faafbf4a1eeaa749b62fe93f66271adff6394d93ce15"},
"sobelow": {:hex, :sobelow, "0.15.0", "b067d7f8522a9d758fa89cb2bfcbab7ad72c45a0993cb958c989c6fd956fdd56", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24a800e2d7fa8c3bd21561b6ad8ad4745ed726a09fd606598981d9048708da98"},
"sourceror": {:hex, :sourceror, "1.12.3", "f58eebef0765c7a369a49a755ab2a5ee88d92777f403ea5a08b722cefcc37f51", [:mix], [], "hexpm", "d5f2f37099de794840f08c54ae546d7f6e4ea015e397be64aebe4996fa9f7da7"},
diff --git a/priv/spdx/license-ids.txt b/priv/spdx/license-ids.txt
new file mode 100644
index 0000000..43465fc
--- /dev/null
+++ b/priv/spdx/license-ids.txt
@@ -0,0 +1,722 @@
+# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
+# SPDX-License-Identifier: Apache-2.0
+#
+# Slice 002. The SPDX licence identifiers that CycloneDX 1.6 accepts in a `license.id` field,
+# one per line. This is not a copy of the SPDX licence list for its own sake: it is the exact
+# enumeration the CycloneDX validator checks `license.id` against, so that `mix trinity.sbom`
+# applies the same rule offline that a validator applies to the published bill.
+#
+# Source: https://raw.githubusercontent.com/CycloneDX/specification/1.6/schema/spdx.schema.json
+# Ref: tag 1.6
+# sha256: baa9d3bd1ed57b6751b0887edead6b5063ff53ff7429cf85d476c6c94af0166e
+# Taken: 2026-09-23 (704 identifiers)
+#
+# Staleness is safe in one direction only, and that is the direction this list fails in: an
+# identifier added to SPDX after the date above is not found here, so `trinity.sbom` records it
+# as a licence `name` rather than an `id`. That is still valid CycloneDX and still names the
+# licence; it is only less machine-readable. The reverse mistake, emitting a string as an `id`
+# that no validator recognises, is the one that makes a bill invalid, and this list prevents it.
+0BSD
+AAL
+Abstyles
+AdaCore-doc
+Adobe-2006
+Adobe-Display-PostScript
+Adobe-Glyph
+Adobe-Utopia
+ADSL
+AFL-1.1
+AFL-1.2
+AFL-2.0
+AFL-2.1
+AFL-3.0
+Afmparse
+AGPL-1.0
+AGPL-1.0-only
+AGPL-1.0-or-later
+AGPL-3.0
+AGPL-3.0-only
+AGPL-3.0-or-later
+Aladdin
+AMDPLPA
+AML
+AML-glslang
+AMPAS
+ANTLR-PD
+ANTLR-PD-fallback
+Apache-1.0
+Apache-1.1
+Apache-2.0
+APAFML
+APL-1.0
+App-s2p
+APSL-1.0
+APSL-1.1
+APSL-1.2
+APSL-2.0
+Arphic-1999
+Artistic-1.0
+Artistic-1.0-cl8
+Artistic-1.0-Perl
+Artistic-2.0
+ASWF-Digital-Assets-1.0
+ASWF-Digital-Assets-1.1
+Baekmuk
+Bahyph
+Barr
+bcrypt-Solar-Designer
+Beerware
+Bitstream-Charter
+Bitstream-Vera
+BitTorrent-1.0
+BitTorrent-1.1
+blessing
+BlueOak-1.0.0
+Boehm-GC
+Borceux
+Brian-Gladman-2-Clause
+Brian-Gladman-3-Clause
+BSD-1-Clause
+BSD-2-Clause
+BSD-2-Clause-Darwin
+BSD-2-Clause-FreeBSD
+BSD-2-Clause-NetBSD
+BSD-2-Clause-Patent
+BSD-2-Clause-Views
+BSD-3-Clause
+BSD-3-Clause-acpica
+BSD-3-Clause-Attribution
+BSD-3-Clause-Clear
+BSD-3-Clause-flex
+BSD-3-Clause-HP
+BSD-3-Clause-LBNL
+BSD-3-Clause-Modification
+BSD-3-Clause-No-Military-License
+BSD-3-Clause-No-Nuclear-License
+BSD-3-Clause-No-Nuclear-License-2014
+BSD-3-Clause-No-Nuclear-Warranty
+BSD-3-Clause-Open-MPI
+BSD-3-Clause-Sun
+BSD-4-Clause
+BSD-4-Clause-Shortened
+BSD-4-Clause-UC
+BSD-4.3RENO
+BSD-4.3TAHOE
+BSD-Advertising-Acknowledgement
+BSD-Attribution-HPND-disclaimer
+BSD-Inferno-Nettverk
+BSD-Protection
+BSD-Source-beginning-file
+BSD-Source-Code
+BSD-Systemics
+BSD-Systemics-W3Works
+BSL-1.0
+BUSL-1.1
+bzip2-1.0.5
+bzip2-1.0.6
+C-UDA-1.0
+CAL-1.0
+CAL-1.0-Combined-Work-Exception
+Caldera
+Caldera-no-preamble
+CATOSL-1.1
+CC-BY-1.0
+CC-BY-2.0
+CC-BY-2.5
+CC-BY-2.5-AU
+CC-BY-3.0
+CC-BY-3.0-AT
+CC-BY-3.0-AU
+CC-BY-3.0-DE
+CC-BY-3.0-IGO
+CC-BY-3.0-NL
+CC-BY-3.0-US
+CC-BY-4.0
+CC-BY-NC-1.0
+CC-BY-NC-2.0
+CC-BY-NC-2.5
+CC-BY-NC-3.0
+CC-BY-NC-3.0-DE
+CC-BY-NC-4.0
+CC-BY-NC-ND-1.0
+CC-BY-NC-ND-2.0
+CC-BY-NC-ND-2.5
+CC-BY-NC-ND-3.0
+CC-BY-NC-ND-3.0-DE
+CC-BY-NC-ND-3.0-IGO
+CC-BY-NC-ND-4.0
+CC-BY-NC-SA-1.0
+CC-BY-NC-SA-2.0
+CC-BY-NC-SA-2.0-DE
+CC-BY-NC-SA-2.0-FR
+CC-BY-NC-SA-2.0-UK
+CC-BY-NC-SA-2.5
+CC-BY-NC-SA-3.0
+CC-BY-NC-SA-3.0-DE
+CC-BY-NC-SA-3.0-IGO
+CC-BY-NC-SA-4.0
+CC-BY-ND-1.0
+CC-BY-ND-2.0
+CC-BY-ND-2.5
+CC-BY-ND-3.0
+CC-BY-ND-3.0-DE
+CC-BY-ND-4.0
+CC-BY-SA-1.0
+CC-BY-SA-2.0
+CC-BY-SA-2.0-UK
+CC-BY-SA-2.1-JP
+CC-BY-SA-2.5
+CC-BY-SA-3.0
+CC-BY-SA-3.0-AT
+CC-BY-SA-3.0-DE
+CC-BY-SA-3.0-IGO
+CC-BY-SA-4.0
+CC-PDDC
+CC0-1.0
+CDDL-1.0
+CDDL-1.1
+CDL-1.0
+CDLA-Permissive-1.0
+CDLA-Permissive-2.0
+CDLA-Sharing-1.0
+CECILL-1.0
+CECILL-1.1
+CECILL-2.0
+CECILL-2.1
+CECILL-B
+CECILL-C
+CERN-OHL-1.1
+CERN-OHL-1.2
+CERN-OHL-P-2.0
+CERN-OHL-S-2.0
+CERN-OHL-W-2.0
+CFITSIO
+check-cvs
+checkmk
+ClArtistic
+Clips
+CMU-Mach
+CMU-Mach-nodoc
+CNRI-Jython
+CNRI-Python
+CNRI-Python-GPL-Compatible
+COIL-1.0
+Community-Spec-1.0
+Condor-1.1
+copyleft-next-0.3.0
+copyleft-next-0.3.1
+Cornell-Lossless-JPEG
+CPAL-1.0
+CPL-1.0
+CPOL-1.02
+Cronyx
+Crossword
+CrystalStacker
+CUA-OPL-1.0
+Cube
+curl
+D-FSL-1.0
+DEC-3-Clause
+diffmark
+DL-DE-BY-2.0
+DL-DE-ZERO-2.0
+DOC
+Dotseqn
+DRL-1.0
+DRL-1.1
+DSDP
+dtoa
+dvipdfm
+ECL-1.0
+ECL-2.0
+eCos-2.0
+EFL-1.0
+EFL-2.0
+eGenix
+Elastic-2.0
+Entessa
+EPICS
+EPL-1.0
+EPL-2.0
+ErlPL-1.1
+etalab-2.0
+EUDatagrid
+EUPL-1.0
+EUPL-1.1
+EUPL-1.2
+Eurosym
+Fair
+FBM
+FDK-AAC
+Ferguson-Twofish
+Frameworx-1.0
+FreeBSD-DOC
+FreeImage
+FSFAP
+FSFAP-no-warranty-disclaimer
+FSFUL
+FSFULLR
+FSFULLRWD
+FTL
+Furuseth
+fwlw
+GCR-docs
+GD
+GFDL-1.1
+GFDL-1.1-invariants-only
+GFDL-1.1-invariants-or-later
+GFDL-1.1-no-invariants-only
+GFDL-1.1-no-invariants-or-later
+GFDL-1.1-only
+GFDL-1.1-or-later
+GFDL-1.2
+GFDL-1.2-invariants-only
+GFDL-1.2-invariants-or-later
+GFDL-1.2-no-invariants-only
+GFDL-1.2-no-invariants-or-later
+GFDL-1.2-only
+GFDL-1.2-or-later
+GFDL-1.3
+GFDL-1.3-invariants-only
+GFDL-1.3-invariants-or-later
+GFDL-1.3-no-invariants-only
+GFDL-1.3-no-invariants-or-later
+GFDL-1.3-only
+GFDL-1.3-or-later
+Giftware
+GL2PS
+Glide
+Glulxe
+GLWTPL
+gnuplot
+GPL-1.0
+GPL-1.0+
+GPL-1.0-only
+GPL-1.0-or-later
+GPL-2.0
+GPL-2.0+
+GPL-2.0-only
+GPL-2.0-or-later
+GPL-2.0-with-autoconf-exception
+GPL-2.0-with-bison-exception
+GPL-2.0-with-classpath-exception
+GPL-2.0-with-font-exception
+GPL-2.0-with-GCC-exception
+GPL-3.0
+GPL-3.0+
+GPL-3.0-only
+GPL-3.0-or-later
+GPL-3.0-with-autoconf-exception
+GPL-3.0-with-GCC-exception
+Graphics-Gems
+gSOAP-1.3b
+gtkbook
+HaskellReport
+hdparm
+Hippocratic-2.1
+HP-1986
+HP-1989
+HPND
+HPND-DEC
+HPND-doc
+HPND-doc-sell
+HPND-export-US
+HPND-export-US-modify
+HPND-Fenneberg-Livingston
+HPND-INRIA-IMAG
+HPND-Kevlin-Henney
+HPND-Markus-Kuhn
+HPND-MIT-disclaimer
+HPND-Pbmplus
+HPND-sell-MIT-disclaimer-xserver
+HPND-sell-regexpr
+HPND-sell-variant
+HPND-sell-variant-MIT-disclaimer
+HPND-UC
+HTMLTIDY
+IBM-pibs
+ICU
+IEC-Code-Components-EULA
+IJG
+IJG-short
+ImageMagick
+iMatix
+Imlib2
+Info-ZIP
+Inner-Net-2.0
+Intel
+Intel-ACPI
+Interbase-1.0
+IPA
+IPL-1.0
+ISC
+ISC-Veillard
+Jam
+JasPer-2.0
+JPL-image
+JPNIC
+JSON
+Kastrup
+Kazlib
+Knuth-CTAN
+LAL-1.2
+LAL-1.3
+Latex2e
+Latex2e-translated-notice
+Leptonica
+LGPL-2.0
+LGPL-2.0+
+LGPL-2.0-only
+LGPL-2.0-or-later
+LGPL-2.1
+LGPL-2.1+
+LGPL-2.1-only
+LGPL-2.1-or-later
+LGPL-3.0
+LGPL-3.0+
+LGPL-3.0-only
+LGPL-3.0-or-later
+LGPLLR
+Libpng
+libpng-2.0
+libselinux-1.0
+libtiff
+libutil-David-Nugent
+LiLiQ-P-1.1
+LiLiQ-R-1.1
+LiLiQ-Rplus-1.1
+Linux-man-pages-1-para
+Linux-man-pages-copyleft
+Linux-man-pages-copyleft-2-para
+Linux-man-pages-copyleft-var
+Linux-OpenIB
+LOOP
+LPD-document
+LPL-1.0
+LPL-1.02
+LPPL-1.0
+LPPL-1.1
+LPPL-1.2
+LPPL-1.3a
+LPPL-1.3c
+lsof
+Lucida-Bitmap-Fonts
+LZMA-SDK-9.11-to-9.20
+LZMA-SDK-9.22
+Mackerras-3-Clause
+Mackerras-3-Clause-acknowledgment
+magaz
+mailprio
+MakeIndex
+Martin-Birgmeier
+McPhee-slideshow
+metamail
+Minpack
+MirOS
+MIT
+MIT-0
+MIT-advertising
+MIT-CMU
+MIT-enna
+MIT-feh
+MIT-Festival
+MIT-Modern-Variant
+MIT-open-group
+MIT-testregex
+MIT-Wu
+MITNFA
+MMIXware
+Motosoto
+MPEG-SSG
+mpi-permissive
+mpich2
+MPL-1.0
+MPL-1.1
+MPL-2.0
+MPL-2.0-no-copyleft-exception
+mplus
+MS-LPL
+MS-PL
+MS-RL
+MTLL
+MulanPSL-1.0
+MulanPSL-2.0
+Multics
+Mup
+NAIST-2003
+NASA-1.3
+Naumen
+NBPL-1.0
+NCGL-UK-2.0
+NCSA
+Net-SNMP
+NetCDF
+Newsletr
+NGPL
+NICTA-1.0
+NIST-PD
+NIST-PD-fallback
+NIST-Software
+NLOD-1.0
+NLOD-2.0
+NLPL
+Nokia
+NOSL
+Noweb
+NPL-1.0
+NPL-1.1
+NPOSL-3.0
+NRL
+NTP
+NTP-0
+Nunit
+O-UDA-1.0
+OCCT-PL
+OCLC-2.0
+ODbL-1.0
+ODC-By-1.0
+OFFIS
+OFL-1.0
+OFL-1.0-no-RFN
+OFL-1.0-RFN
+OFL-1.1
+OFL-1.1-no-RFN
+OFL-1.1-RFN
+OGC-1.0
+OGDL-Taiwan-1.0
+OGL-Canada-2.0
+OGL-UK-1.0
+OGL-UK-2.0
+OGL-UK-3.0
+OGTSL
+OLDAP-1.1
+OLDAP-1.2
+OLDAP-1.3
+OLDAP-1.4
+OLDAP-2.0
+OLDAP-2.0.1
+OLDAP-2.1
+OLDAP-2.2
+OLDAP-2.2.1
+OLDAP-2.2.2
+OLDAP-2.3
+OLDAP-2.4
+OLDAP-2.5
+OLDAP-2.6
+OLDAP-2.7
+OLDAP-2.8
+OLFL-1.3
+OML
+OpenPBS-2.3
+OpenSSL
+OpenSSL-standalone
+OpenVision
+OPL-1.0
+OPL-UK-3.0
+OPUBL-1.0
+OSET-PL-2.1
+OSL-1.0
+OSL-1.1
+OSL-2.0
+OSL-2.1
+OSL-3.0
+PADL
+Parity-6.0.0
+Parity-7.0.0
+PDDL-1.0
+PHP-3.0
+PHP-3.01
+Pixar
+Plexus
+pnmstitch
+PolyForm-Noncommercial-1.0.0
+PolyForm-Small-Business-1.0.0
+PostgreSQL
+PSF-2.0
+psfrag
+psutils
+Python-2.0
+Python-2.0.1
+python-ldap
+Qhull
+QPL-1.0
+QPL-1.0-INRIA-2004
+radvd
+Rdisc
+RHeCos-1.1
+RPL-1.1
+RPL-1.5
+RPSL-1.0
+RSA-MD
+RSCPL
+Ruby
+SAX-PD
+SAX-PD-2.0
+Saxpath
+SCEA
+SchemeReport
+Sendmail
+Sendmail-8.23
+SGI-B-1.0
+SGI-B-1.1
+SGI-B-2.0
+SGI-OpenGL
+SGP4
+SHL-0.5
+SHL-0.51
+SimPL-2.0
+SISSL
+SISSL-1.2
+SL
+Sleepycat
+SMLNJ
+SMPPL
+SNIA
+snprintf
+softSurfer
+Soundex
+Spencer-86
+Spencer-94
+Spencer-99
+SPL-1.0
+ssh-keyscan
+SSH-OpenSSH
+SSH-short
+SSLeay-standalone
+SSPL-1.0
+StandardML-NJ
+SugarCRM-1.1.3
+Sun-PPP
+SunPro
+SWL
+swrule
+Symlinks
+TAPR-OHL-1.0
+TCL
+TCP-wrappers
+TermReadKey
+TGPPL-1.0
+TMate
+TORQUE-1.1
+TOSL
+TPDL
+TPL-1.0
+TTWL
+TTYP0
+TU-Berlin-1.0
+TU-Berlin-2.0
+UCAR
+UCL-1.0
+ulem
+UMich-Merit
+Unicode-3.0
+Unicode-DFS-2015
+Unicode-DFS-2016
+Unicode-TOU
+UnixCrypt
+Unlicense
+UPL-1.0
+URT-RLE
+Vim
+VOSTROM
+VSL-1.0
+W3C
+W3C-19980720
+W3C-20150513
+w3m
+Watcom-1.0
+Widget-Workshop
+Wsuipa
+WTFPL
+wxWindows
+X11
+X11-distribute-modifications-variant
+Xdebug-1.03
+Xerox
+Xfig
+XFree86-1.1
+xinetd
+xkeyboard-config-Zinoviev
+xlock
+Xnet
+xpp
+XSkat
+YPL-1.0
+YPL-1.1
+Zed
+Zeeff
+Zend-2.0
+Zimbra-1.3
+Zimbra-1.4
+Zlib
+zlib-acknowledgement
+ZPL-1.1
+ZPL-2.0
+ZPL-2.1
+389-exception
+Asterisk-exception
+Autoconf-exception-2.0
+Autoconf-exception-3.0
+Autoconf-exception-generic
+Autoconf-exception-generic-3.0
+Autoconf-exception-macro
+Bison-exception-1.24
+Bison-exception-2.2
+Bootloader-exception
+Classpath-exception-2.0
+CLISP-exception-2.0
+cryptsetup-OpenSSL-exception
+DigiRule-FOSS-exception
+eCos-exception-2.0
+Fawkes-Runtime-exception
+FLTK-exception
+fmt-exception
+Font-exception-2.0
+freertos-exception-2.0
+GCC-exception-2.0
+GCC-exception-2.0-note
+GCC-exception-3.1
+Gmsh-exception
+GNAT-exception
+GNOME-examples-exception
+GNU-compiler-exception
+gnu-javamail-exception
+GPL-3.0-interface-exception
+GPL-3.0-linking-exception
+GPL-3.0-linking-source-exception
+GPL-CC-1.0
+GStreamer-exception-2005
+GStreamer-exception-2008
+i2p-gpl-java-exception
+KiCad-libraries-exception
+LGPL-3.0-linking-exception
+libpri-OpenH323-exception
+Libtool-exception
+Linux-syscall-note
+LLGPL
+LLVM-exception
+LZMA-exception
+mif-exception
+Nokia-Qt-exception-1.1
+OCaml-LGPL-linking-exception
+OCCT-exception-1.0
+OpenJDK-assembly-exception-1.0
+openvpn-openssl-exception
+PS-or-PDF-font-exception-20170817
+QPL-1.0-INRIA-2004-exception
+Qt-GPL-exception-1.0
+Qt-LGPL-exception-1.1
+Qwt-exception-1.0
+SANE-exception
+SHL-2.0
+SHL-2.1
+stunnel-exception
+SWI-exception
+Swift-exception
+Texinfo-exception
+u-boot-exception-2.0
+UBDL-exception
+Universal-FOSS-exception-1.0
+vsftpd-openssl-exception
+WxWindows-exception-3.1
+x11vnc-openssl-exception
diff --git a/test/supply_chain_test.exs b/test/supply_chain_test.exs
new file mode 100644
index 0000000..c601444
--- /dev/null
+++ b/test/supply_chain_test.exs
@@ -0,0 +1,132 @@
+# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
+# SPDX-License-Identifier: Apache-2.0
+defmodule SupplyChainTest do
+ @moduledoc """
+ Slice 002, AC1 and AC3. The bill of materials the gate generates is a real CycloneDX document,
+ it lists the dependencies the lock file holds, and it states what it is blind to inside itself
+ rather than in a document beside it.
+
+ And the two statements the README makes about transport and message authentication are pinned to
+ the facts rather than to the sentence: the test asserts the runtime's default TLS versions and
+ the algorithm that actually seals the envelope, so the line cannot survive the fact changing
+ underneath it.
+ """
+ use ExUnit.Case, async: true
+
+ @output "sbom.cdx.json"
+
+ setup_all do
+ # The gate generates it before the suite runs; a developer running this file alone gets it
+ # generated here rather than a failure about a missing file they did not know to produce.
+ unless File.exists?(@output) do
+ {_, 0} = System.cmd("mix", ["trinity.sbom"], stderr_to_stdout: true)
+ end
+
+ {:ok, bom: @output |> File.read!() |> Jason.decode!()}
+ end
+
+ describe "AC1: the bill of materials" do
+ test "is a CycloneDX document with a specification version", %{bom: bom} do
+ assert bom["bomFormat"] == "CycloneDX"
+ assert bom["specVersion"] =~ ~r/^\d+\.\d+$/
+ assert is_binary(bom["serialNumber"]) or is_nil(bom["serialNumber"])
+ end
+
+ test "lists components, and every one carries a name and a version", %{bom: bom} do
+ components = bom["components"]
+ assert is_list(components) and length(components) > 50
+
+ for component <- components do
+ assert is_binary(component["name"]) and component["name"] != ""
+ assert is_binary(component["version"]) and component["version"] != ""
+ end
+ end
+
+ test "covers the dependencies the lock file holds", %{bom: bom} do
+ names = MapSet.new(bom["components"], & &1["name"])
+
+ # A sample across the tree's own dependency kinds: the web layer, the database driver, the
+ # protocol core and the cryptography. If the bill were generated from something other than
+ # the lock, one of these would be missing.
+ for expected <- ~w(phoenix ecto_sql exqlite beam_mcp jose) do
+ assert expected in names, "#{expected} is in mix.lock but not in the bill"
+ end
+ end
+
+ # The one property a validator rejects a whole document over, asserted here so that the next
+ # dependency whose author types a licence by hand fails this test rather than the bill.
+ # Measured on the first bill generated in this slice: `yamerl` declares `BSD 2-Clause`, which
+ # SPDX spells `BSD-2-Clause`, and the official CycloneDX validator refused the document over
+ # that single field.
+ test "never asserts a licence identifier SPDX does not define", %{bom: bom} do
+ ids = Mix.Tasks.Trinity.Sbom.spdx_ids()
+ assert MapSet.size(ids) > 500, "the vendored SPDX list did not load"
+
+ asserted =
+ for component <- bom["components"],
+ choice <- component["licenses"] || [],
+ id = get_in(choice, ["license", "id"]),
+ id not in [nil],
+ not MapSet.member?(ids, id),
+ do: {component["name"], id}
+
+ assert asserted == [], "these components assert a licence id SPDX does not define"
+ end
+
+ test "keeps a licence it cannot resolve, as a name rather than an identifier", %{bom: bom} do
+ named =
+ for component <- bom["components"],
+ choice <- component["licenses"] || [],
+ name = get_in(choice, ["license", "name"]),
+ name not in [nil],
+ do: name
+
+ # Nothing is dropped on the way: whatever could not be resolved is still in the document,
+ # under the field CycloneDX keeps an unresolved licence in.
+ for name <- named, do: assert(is_binary(name) and name != "")
+ assert "BSD 2-Clause" in named or named == []
+ end
+
+ test "states what it is blind to, inside the document", %{bom: bom} do
+ properties = get_in(bom, ["metadata", "properties"]) || []
+
+ coverage =
+ Enum.find(properties, &(&1["name"] == Mix.Tasks.Trinity.Sbom.coverage_property()))
+
+ assert coverage, "the bill carries no coverage property; a reader cannot know its limits"
+ assert coverage["value"] =~ "does NOT cover the Rust crates"
+ assert coverage["value"] =~ "not evidence of its absence"
+ end
+ end
+
+ describe "AC3: the transport and authentication statements" do
+ test "the runtime's default TLS versions are 1.2 and 1.3, and nothing older" do
+ :ssl.start()
+ supported = Keyword.fetch!(:ssl.versions(), :supported)
+
+ assert :"tlsv1.2" in supported
+ refute :tlsv1 in supported, "TLS 1.0 is in the default set"
+ refute :"tlsv1.1" in supported, "TLS 1.1 is in the default set"
+ end
+
+ test "the only authentication tag Trinity mints is AES-256-GCM's" do
+ envelope = File.read!("lib/trinity/mcp/server/envelope.ex")
+ assert envelope =~ ":aes_256_gcm"
+
+ # And no weaker construction anywhere in the tree's own code.
+ {out, 0} = System.cmd("git", ["ls-files", "lib/*.ex", "lib/**/*.ex"])
+
+ for file <- out |> String.split("\n", trim: true) |> Enum.uniq() do
+ source = File.read!(file)
+ refute source =~ ~r/:aes_\d+_(ecb|cbc)\b/, "#{file} uses an unauthenticated cipher mode"
+ end
+ end
+
+ test "the README states both, so a reader need not read the source to learn them" do
+ readme = File.read!("README.md")
+ assert readme =~ "TLS 1.2"
+ assert readme =~ "AES-256-GCM"
+ assert readme =~ "FIPS"
+ end
+ end
+end