Architecture Decision Record
ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)
Status
Accepted (2026-07-06; revised 2026-07-21).
Context
MMCA.Common is a published framework: it packs its NuGet packages and pushes them to GitHub Packages
on every v* tag (release.yml:59-60), where the two production apps and the reference seed consume
them. A published framework is a supply-chain amplifier: a vulnerable, substituted, or unreproducible
dependency does not stay in one repo, it ships downstream to every consumer. Rubric §32 (Dependency &
Supply-Chain Management) is weighted higher for exactly this reason: its default weight rises to 3 for
a published framework such as MMCA.Common (ArchitectureEvaluationCriteria.md:817), and it asks for
provenance and integrity (SBOM, lock files, trusted sources only) on top of the versioning hygiene
§15 covers (ArchitectureEvaluationCriteria.md:808).
ADR-016 answers two dependency-governance questions (how the packages version and roll out, and the
MassTransit-v8 license pin), but it deliberately stops there. The four controls that actually
establish provenance and integrity, an SBOM release gate, committed lock files, a CI vulnerability
audit, and package source mapping, already live in the release and CI workflows,
Directory.Build.props, and nuget.config, and are summarized for consumers in SECURITY.md
(SECURITY.md:32-41). No ADR owned them as a single coherent posture. This record does, and it exceeds
ADR-016's scope: ADR-016 gestures at lock files as sweep mechanics, this ADR owns supply-chain
integrity as the decision.
Decision
Treat supply-chain integrity as a set of build-gating controls, the same invariant-over-discipline posture ADR-015 applies to architecture rules. Four controls, each a hard gate:
A CycloneDX SBOM is a hard release gate. The release workflow installs the CycloneDX tool and generates a JSON software bill of materials for the whole solution into
./sbom(release.yml:45-49). It then fails the release (exit 1) when generation produced no output (test -n "$(ls -A ./sbom ...)", release.yml:50), and the upload step setsif-no-files-found: error(release.yml:52-57). This step was promoted fromcontinue-on-error(its tooling-validation phase) to a blocking gate (release.yml:41-44), so every published version ships a verifiable SBOM or no packages are pushed.NuGet lock files are committed for reproducible restores.
RestorePackagesWithLockFileis set repo-wide (Directory.Build.props:8), so each project records its full resolved transitive graph in a committedpackages.lock.json(for example Source/Core/MMCA.Common.Domain/packages.lock.json). Restore in CI and in release runs against that committed graph (ci.yml:30-31, release.yml:25-26), so the versions CI vets and the release packs are the ones on record.CI fails on any non-suppressed vulnerable package. The audit step runs
dotnet list MMCA.Common.slnx package --vulnerable --include-transitive(ci.yml:36-38) and fails the build (exit 1) on any vulnerable-package row (ci.yml:45-52). Accepted advisories are the sole exception, and their single source of truth is theNuGetAuditSuppresslist inDirectory.Build.props. Becausedotnet list --vulnerableignoresNuGetAuditSuppress, the step re-derives that accept-list itself by reading theGHSA-*ids out ofDirectory.Build.props(ci.yml:40-48). The accepted-advisory list is currently empty: the one prior entry, the SQLite advisory GHSA-2m69-gcr7-jv3q (CVE-2025-6965), was suppressed from 2026-06-19 while SQLitePCLRaw shipped no patched build. SQLitePCLRaw 2.1.12 (published 2026-07-14) delivered the patched build, so the suppression was removed on 2026-07-20 and replaced with a direct fix: aSQLitePCLRaw.bundle_e_sqlite3pin tracked inDirectory.Packages.props(Directory.Packages.props:42), referenced directly byMMCA.Common.Infrastructure(MMCA.Common.Infrastructure.csproj:23-25) so the patched version flows to consumers through the published package graph, the same pattern used for the MessagePack pin. This complements the build-time audit:NuGetAuditwithNuGetAuditMode=all(Directory.Build.props:9-10) under repo-wideTreatWarningsAsErrors(Directory.Build.props:7) already promotes an advisory to a build failure, and the CI step adds a solution-wide, transitive check carrying an auditable accept-list.Package source mapping pins every dependency to an explicit feed.
nuget.configclears inherited sources and declares nuget.org only (nuget.config:9-12), then apackageSourceMappingblock routes package pattern*to that source (nuget.config:13-17). This is the dependency-confusion and typosquat defense: a package from any other feed cannot be silently substituted (nuget.config:3-8). MMCA.Common needs only the single* -> nuget.orgmapping because it publishes theMMCA.*packages rather than consuming them (nuget.config:6-7), so no GitHub Packages token is required to build or restore it.
Rationale
- Provenance is a gate, not a document. A hard-failing SBOM step means the bill of materials cannot silently go missing on a release: the artifact is produced or the release stops (release.yml:50, release.yml:57). That is the §32 provenance criterion enforced, not merely asserted (ArchitectureEvaluationCriteria.md:808).
- One accept-list, re-applied where the tool ignores it. The audit keeps
Directory.Build.propsas the only place an advisory is accepted, and re-reads that file in CI precisely becausedotnet list --vulnerabledoes not honorNuGetAuditSuppress(ci.yml:40-42). ANuGetAuditSuppressitem is the sanctioned way to accept an advisory, paired with a dated rationale in an adjacent comment, so a reviewer sees every accepted advisory in one place rather than a blanket suppression. No suppressions are active today: the accept-list is empty after the 2026-07-20/21 SQLite fix. - Build-gates-invariants, at the supply-chain layer. SBOM, audit, and source mapping turn "remember to check the dependencies" into red builds, the same lever ADR-015 uses for the layer and event rules and ADR-016 uses for the MassTransit pin.
- Reproducibility feeds the audit. Committed lock files (Directory.Build.props:8) record the exact transitive graph the audit and SBOM run against, so the release ships the versions CI actually vetted.
Trade-offs
- The SBOM is generated and archived, not yet signed or attested. The gate proves a bill of materials exists for each release (release.yml:50); it does not add cryptographic attestation or signature verification of the pushed packages. That is a possible follow-up, not a claim made here.
- Accept-list drift is possible. A
NuGetAuditSuppressentry silences the audit for that id until someone removes it, and its accompanying rationale comment is a review reminder, not an automated expiry. No entries are active today, but the mechanism carries this cost whenever an advisory is accepted. - Audit granularity is text-matched. The CI step matches vulnerable rows and
GHSA-*ids by parsing tool output (ci.yml:43-48). It is deliberately simple and depends on thedotnet listoutput shape rather than a structured feed. - Source mapping constrains where packages come from. Restricting to nuget.org (nuget.config:15)
is the point, but it means adding a dependency from any other feed is a deliberate
nuget.configedit, not an ambient possibility.
Related
ADR-016 (lockstep versioning + the MassTransit-v8 license pin; this record extends dependency governance from versioning and licensing into supply-chain provenance and integrity), ADR-015 (architecture invariants enforced as build-gating fitness functions; the same gate-the-build posture applied here to dependencies), ADR-010 (integration-event schema versioning; another release-discipline control that turns a contract into an enforced signal). See SECURITY.md ("Dependency & supply-chain security", SECURITY.md:32-41) for the consumer-facing summary and rubric §32 (ArchitectureEvaluationCriteria.md:799-817) for the evaluation criteria.