SBOM Service Architecture
Verified against commit
942a18727c(2026-09-26). Re-verify with:grep -rnE 'Map(Get|Post|Put|Delete|Group)\(' src/SbomService/StellaOps.SbomService --include=*.cs && ls src/SbomService/__Libraries/StellaOps.SbomService.Persistence/Migrations/*.sql && grep -n 'CheckId =>' src/SbomService/StellaOps.SbomService/Doctor/SbomServiceDoctorChecks.cs
Scope: canonical SBOM storage, projections, ledger history, lineage, registry-source state, the
sbom.versionsowner stream, and durable SBOM event backlogs. Consumers: Console (Web), the CLI, Findings (version stream and raw documents), OfflineKit audit bundles, and AdvisoryAI (SBOM context).
Authenticated tenant boundary. SBOM Service accepts a validated Authority bearer token or a Router-generated signed identity envelope. Tenant identity comes only from the canonical
stellaops:tenantclaim. Legacy tenant/user headers cannot authenticate a request. Existingtenant/tenantIdrequest fields are optional compatibility assertions: an omitted value uses the authenticated tenant, an equal value is accepted, and a mismatch is rejected before repository access.
1) Mission and boundaries
Repository-qualified Composer PURLs survive CycloneDX/SPDX graph extraction, durable graph storage and exact-PURL queries without losing their qualifiers (PCD-332). Official Drupal, explicitly different and unqualified package identities remain separate; tenant fencing still applies. Native PostgreSQL round-trip controls cover both document formats.
PCD-332 source verified against
1e52c13050(2026-10-06). Re-verify:pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/SbomService/__Tests/StellaOps.SbomService.Persistence.Tests/StellaOps.SbomService.Persistence.Tests.csproj -Method "*RepositoryIdentity*" -BuildProjectReferences -MaxThreads 1.
- Serve deterministic, tenant-scoped SBOM projections and ledger history.
- Accept scanner-produced SBOMs or operator-supplied SPDX/CycloneDX uploads.
- Own append-only SBOM versioning, lineage edges, retention, and related audit state.
- Do not perform scanning itself and do not author policy verdicts.
2) Runtime layout
- Host:
src/SbomService/StellaOps.SbomService(deployablesbomservice; release bundledevops/release/bundle/docker-compose.yml, local composedevops/compose/docker-compose.stella-services.ymlplus the owner-database overlaydevops/compose/docker-compose.sbomservice.yml) - Contracts:
src/SbomService/__Libraries/StellaOps.SbomService.Contracts(BCL-only; the only SbomService project another service may reference) - Persistence:
src/SbomService/__Libraries/StellaOps.SbomService.Persistence - Source-only lineage library:
src/SbomService/__Libraries/StellaOps.SbomService.Lineage— its runtime services have no composition call and its migration has no production runner. W3-07 / CM-2 retired Platform’s orphanedSbomLineageMigrationModulePlugin, direct project reference, and Platform-owned preflight/repair resources on 2026-08-24 (the plugin mechanism itself was deleted 2026-09-14, SPRINT_20260722_021 PLT-4) after recorded estate evidence showed thatsbom.sbom_lineage_edges,sbom.sbom_verdict_links, andvex.vex_deltashad never been created there. The library remains auditable source with its focused test pending separately scoped disposition. The live lineage surface is the host’s own minimal-API endpoints over the host-ownedsbom.lineage_edgestable andSbomLineageGraphService. - Host tests:
src/SbomService/StellaOps.SbomService.Tests(endpoint, ledger, lineage-export-truthfulness, and runtime-startup-contract coverage) - Persistence tests:
src/SbomService/__Tests/StellaOps.SbomService.Persistence.Tests - Lineage tests:
src/SbomService/__Tests/StellaOps.SbomService.Lineage.Tests - Contracts tests:
src/SbomService/__Tests/StellaOps.SbomService.Contracts.Tests
Upload memory
The upload service borrows the request’s parsed JSON element; base64 uploads decode and parse once. Validation, normalization, digest generation and dependency extraction use that same element. Clean documents are checked for the mandatory JSON NUL escape in their borrowed UTF-8 bytes before the decoded JSON-Pointer walk runs. The walk still decides candidate matches, so a literal backslash followed by u0000 remains valid.
The legacy ledger digest remains SHA-256 of the existing compact JSON serialization, streamed through the central incremental hash implementation. It is distinct from the exact first-writer sidecar digest. Sidecar persistence sends one owned UTF-8 byte buffer as a PostgreSQL binary parameter, converting it to the existing TEXT column inside PostgreSQL. Whitespace, property order, UTF-8 content, first-writer deduplication and tenant access checks remain unchanged. Ledger persistence also serializes provenance, normalized components and replay inputs directly to UTF-8 binary parameters, converted to the existing JSONB columns in PostgreSQL. Ledger reads use sequential access and deserialize Npgsql’s owned JSON document directly, removing the intermediate UTF-16 JSON string. The same reader handles every tenant-scoped and artifact query; pagination consumes its total count after the version columns to preserve sequential order. No schema migration accompanies these allocation changes. The family deployment and offline bundle budget 768 MiB as a capacity complement. At 512 MiB the optimized upload/read candidate accepted the first real Debian/Ubuntu pair, but the next request failed in the existing Router bridge’s full-payload ToArray before application binding. That run sampled 398.754 MiB current memory and 353.148 MiB working set, with a managed allocation failure, 0 restarts and OOMKilled=false. The bridge still buffers its envelope and body; those costs are outside the service-only allocation counter. The increased budget does not replace the allocation controls or the back-to-back deployed-limit forcing function. A clean d11e6889ad image accepted three consecutive Debian/Ubuntu pairs (6.96/8.21 MB) at 768 MiB on 2026-10-13. The 75 samples peaked at 482.227 MiB current memory and 473.211 MiB working set; anonymous 401, NUL 400, zero OOM and zero restarts also passed. Verification covers six uploads; sustained concurrency requires separate qualification.
Sidecar reads also retain UTF-8: PostgreSQL supplies the TEXT as UTF-8 bytes through a sequential reader, raw export responses send those bytes directly, and dependency backfill parses the same buffer. Production-answer availability reads only sidecar headers through a tenant-derived access grant; it never fetches a multi-megabyte document merely to report an export link.
Allocation checks are opt-in (STELLAOPS_PERFORMANCE_TESTS=1) in SbomUploadAllocationTests: inline and base64 8.26 MB uploads, two concurrent uploads, and an operator-provided document (STELLAOPS_SBOM_ALLOCATION_FIXTURE). These isolate upload-service allocations from request binding and database/network costs; deployment acceptance additionally requires back-to-back real documents at the configured container limit. SbomDocumentSidecarStorageTests additionally bounds two real PostgreSQL reads and two HTTP exports of an 8.21 MB document, including byte-equivalent output. These catch read-side copies that the upload tests’ synchronous repository substitutes cannot measure. It also bounds two real ledger reads with 8.34 MB of normalized metadata and verifies retained metadata and tenant isolation. Model strings remain necessary owned data; the JSON transport representation is not materialized as a second full UTF-16 string.
2.0a Schema convergence has exactly one owner
The runtime defensive-DDL mechanism is retired (SPRINT_20260823_003 SBT-6, closing W3-07 D-W307-2). Sixteen repository/service types used to run CREATE TABLE IF NOT EXISTS on first touch — 82 call sites creating 22 distinct tables with no ledger bookkeeping — a fourth migration mechanism alongside this service’s startup migrator, the central migrator and the CLI, predating the §2.7 auto-migration contract. It is gone. The embedded startup migrations in StellaOps.SbomService.Persistence are the only thing that creates or alters this schema, and SbomServiceDefensiveDdlRetirementTests fails if a repository grows an EnsureTable*Async again.
2.1 Durable state
- Canonical PostgreSQL tables (migrations under
StellaOps.SbomService.Persistence/Migrations, schemasbom) include:sbom.projections,sbom.catalog,sbom.component_lookupssbom.entrypoints,sbom.orchestrator_sources,sbom.orchestrator_controlsbom.ledger_versions,sbom.ledger_chains,sbom.ledger_audit,sbom.analysis_jobs,sbom.watermarkssbom.sbom_documents(first-writer exact uploaded bytes, keyed by the legacy logical digest)sbom.dependency_graphs,sbom.dependency_nodes,sbom.dependency_edgessbom.lineage_edgessbom.artifact_subjects,sbom.artifact_linkssbom.version_events,sbom.asset_events,sbom.inventory_events,sbom.resolver_candidatessbom.registry_sources,sbom.registry_source_runs
- The retained
StellaOps.SbomService.Lineagesource contains an EF Core baseline (00001_InitialSchema.sql) forsbom.sbom_lineage_edges,sbom.sbom_verdict_links, andvex.vex_deltas, each with row-level-security tenant-isolation policies. It has no production migration authority and those tables are not part of the deployed schema. They are distinct from the host-ownedsbom.lineage_edgestable above. sbom.verdict_linksis retired. No deployable host ever registered its writer or reader, so startup migration012_sbomservice_retire_verdict_links.sqldrops it while it is empty. A populated table is refused with SQLSTATE 55000 and kept for an operator decision. The unregistered repository is frozen undersrc/__Obsoleted/SbomService/.- Embedded migrations, in order:
001_v1_sbomservice_baseline.sql(collapsed pre-1.0 baseline),002_registry_source_masks_and_run_reports.sql,003_sbom_documents_sidecar.sql,004_ledger_versions_tenant_id.sql,005_ledger_versions_tenant_chain.sql,006_catalog_component_lookup_tenant_id.sql,007_dependency_edges.sql,008_ledger_version_stream_fence.sql,009_ledger_audit_analysis_jobs_tenant_id.sql,010_sbomservice_retention_classes.sql,011_sbomservice_registry_run_retention.sql,012_sbomservice_retire_verdict_links.sql. Files underMigrations/_archived/are excluded from the assembly. The shared Eventing reliability migrations (eventing.outboxand related tables) converge in the same owner database throughAddEventingReliability(). - Every declared table states its retention class in a forward
COMMENT ON TABLE(migrations010and011). The classes, their evidence and the registry-run sweep are summarized in Retention Policy. - Startup migrations in the persistence assembly are embedded resources (
Migrations\**\*.sql) and run automatically on host startup viaAddStartupMigrations(...). - Durable projection reads never trust
sbom.projections.projection_hashby itself. The repository canonicalizesprojection_jsonwith the sharedCanonJsoncontract, validates its SHA-256 with a fixed-time comparison, and fails the HTTP read with500 projection_integrity_failedinstead of returning a payload when the stored hash is malformed or mismatched.
2.2 Truthful unsupported surface
- The live durable runtime derives dependency paths only from CycloneDX
dependencies[]or SPDXrelationships[]retained in the verbatim raw-SBOM sidecar. An orphan component has zero paths; no artifact-to-component fallback is synthesized. - SBOM lineage evidence-pack export succeeds only when both durable export storage and a real signer are configured. The service canonicalizes the lineage diff into deterministic JSON bytes, writes the evidence pack and signature sidecar through a content-addressed blob store, and signs the canonical evidence bytes through
StellaOps.Provenance.Attestation.ISigner. Missing storage or missing signer returns501 sbom_lineage_export_unsupported; the service does not emit synthetic HTTP download URLs, zero-byte evidence packs, hash-as-signature values, or time-based replay hashes. - The lineage export
downloadUrlcompatibility field is the durablecas://sbom-lineage-export/...URI returned instorageUri; it is not a fabricated direct download endpoint. - Replay hashes require real input providers for feed snapshot digest, policy version, and VEX verdict digest. Provider-unavailable placeholder digests/default policy versions are not valid replay inputs; callers receive unavailable behavior or omit replay hashes rather than a fabricated hash.
POST /internal/sbom/inventory/backfillandPOST /internal/sbom/resolver-feed/backfillstill return truthful501responses: those separate feed producers are not implemented by the dependency graph.
2.3 Tenant-scoped derived read models
sbom.catalog and sbom.component_lookups were created installation-wide — no tenant column — so every authenticated caller could read every row. Migration 006_catalog_component_lookup_tenant_id.sql adds the durable tenant key (SPRINT_20260712_007, SBOM-READMODEL-007-001):
Storage.
tenant_id TEXT(nullable) on both tables, plus partial indexes on(tenant_id, created_at DESC)/(tenant_id, artifact)and(tenant_id, LOWER(purl))/(tenant_id, LOWER(purl), LOWER(artifact)). Forward-only and idempotent; applied byAddStartupMigrationson a fresh database with no manual SQL.Reads.
ICatalogRepositoryandIComponentLookupRepositorytake the tenant on every call (SbomCatalogQuery.TenantId,ComponentLookupQuery.TenantId,ICatalogRepository.ListAsync(tenantId, …)). The Postgres repositories puttenant_id = @tenant_idin the WHERE clause unconditionally; an empty tenant short-circuits to an empty page rather than falling back to an unfiltered scan.Caches. The tenant is part of every query-service cache key (
catalog|{tenant}|…,component|{tenant}|…,paths|{tenant}|…,timeline|{tenant}|…). Path keys include the current graph-version fingerprint and timeline keys include the current ledger-version fingerprint, so a new upload cannot reuse a warmed empty or older result. Without the tenant prefix, a warm tenant-A page would be replayed to tenant B even though the SQL predicate is correct.Legacy rows fail closed. Rows written before this migration keep
tenant_id IS NULL. Because every read filters on an equality predicate, a NULL-tenant row is readable by no tenant. There is no back-fill and no default tenant: guessing an owner would attribute one tenant’s artifacts to another. There is also no in-platform writer for these two tables today (nothing undersrc/INSERTs into them), so the fail-closed default costs no live data. An operator who wants legacy rows back adopts them explicitly:UPDATE sbom.catalog SET tenant_id = '<tenant>' WHERE tenant_id IS NULL; UPDATE sbom.component_lookups SET tenant_id = '<tenant>' WHERE tenant_id IS NULL;Local harness. The in-memory catalog, component, dependency-path, and timeline seeds, plus the
docs/modules/sbom-service/fixtures/lnm-v1fixtures, are owned by tenanttenant-aand are filtered exactly like Postgres rows: a fixture row without atenantIdis owned by nobody and is returned to nobody.Dependency paths. Migration
007_dependency_edges.sqladds a graph header, normalized component nodes, and dependency edges keyed by ledgerversion_id, withtenant_iddenormalized onto every row. Graph insertion succeeds only by selecting a ledger row with the same version and tenant. Every graph, node, and edge read contains an unconditionaltenant_id = @tenant_idpredicate; blank tenants and legacy tenantless ledger rows fail closed. The path cache key begins with the authenticated tenant and includes the selected graph-version fingerprint, so neither cross-tenant reuse nor stale results after a newer upload are possible.Coverage truth. Upload extraction records
complete,partial,zero_edges, orunavailable, plus dangling-reference counts. Path walking is deterministic, cycle-safe, and capped at depth 32 / 500 paths; a cap reportstruncated: true. The config-gated sidecar backfill is idempotent and never guesses an owner for a legacy row.
3) Primary APIs
All routes below are minimal-API endpoints mapped in Program.cs, Endpoints/SbomVersionStreamEndpoints.cs, or Services/RegistrySourceEndpointExtensions.cs; the host has no MVC controllers. Endpoints are gated by the Sbom.Read, Sbom.Write, Sbom.Attest, Sbom.Internal, or Sbom.DoctorHealth named policies (constants in Auth/SbomPolicies.cs); see Section 8a for the auth model.
3.1 Upload, versions, and ledger
POST /sbom/upload(and the canonical aliasPOST /api/v1/sbom/upload) validates and normalizes SPDX or CycloneDX and appends a new ledger version. On success returns202 Accepted; on validation failure returns400 sbom_upload_validation_failed. RequiresSbom.Write.- Upload validation refuses U+0000, which PostgreSQL text and jsonb cannot store: a document with
\u0000in any string value or property name fails validation, andvalidation.errorsnames the JSON Pointer of each location (at most five). A request field with U+0000 (for examplesource.tool) is refused the same way. Nothing is persisted for a refused upload. Other control characters, such as line breaks in descriptions, remain valid. GET /api/v1/sbom/production-answer?limit=...returns the authenticated tenant’s exact latest-version count plus a bounded list of the latest stored SBOM per artifact.producedmeans persisted tenant ledger truth; it does not imply that Stella generated an operator-uploaded document. Each row reports only stored format, production time, component count, and producer provenance, alongside the single-sourced accepted format/version matrix and canonical upload/registry-source ingest paths. The row cap is 100 whiletotalProducedremains exact. RequiresSbom.Read.GET /api/v1/sbom/subject/{subjectRef}/document?version=<guid|latest>is the format-generic raw export handoff. It first proves tenant ownership through the ledger, then returns the content-addressed raw sidecar byte-for-byte with its stored media type (application/vnd.cyclonedx+json,application/spdx+json, or the exact accepted upload media type). Missing ownership or a legacy version without a raw sidecar returns404; content is never reconstructed or fabricated. RequiresSbom.Read.GET /api/v1/sbom/version-stream/events?afterSeq=...&limit=...&streamEpoch=...returns the ordered standard-P6sbom.versionsupload/retire tail with current epoch/head, retained horizon, and explicit epoch/gap bootstrap signals. It is not an alias for/internal/sbom/events.GET /api/v1/sbom/version-stream/snapshot?afterVersionId=...&limit=...&streamEpoch=...&headSeq=...returns live tenant-bearing ledger versions through a bounded UUID keyset at one epoch/head fence. The first page mints/captures the fence; continuations must echo it. Missing exact-document identity or null/empty/untrimmed tenant/artifact/format/version text is counted/withheld while valid non-GUID tenants remain opaque.POST|GET|DELETE /api/v1/sbom/version-stream/consumers[/{consumerId}]manages the remote consumer registration that participates in the producer’s outbox-retention floor. Reports are accepted only for the current epoch at or below committed head. All version-stream routes require installation-wideSbom.Internal/sbom:operate; tenantsbom:readcannot enumerate another tenant’s version metadata or mutate the global consumer floor. These four path keys/five operations exist only when durable PostgreSQL is configured andSbomService:VersionStream:Enabled=true; the default-off host registers neither the owner service nor the outbox pruner and returns404for every operation.GET /sbom/versions?artifact=...and its gateway-reachable aliasGET /api/v1/sbom/versionsreturn the durable timeline for an artifact (Sbom.Read).GET /sbom/ledger/history,/point(requiresat),/range(requiresstart+end),/diff(requiresbefore+afterGUIDs), and/lineageexpose ledger and lineage state (Sbom.Read).GET /api/v1/sbom/ledger/diffis the gateway-reachable alias of/sbom/ledger/diff.GET /api/v1/sbom/uploads?artifact=...andGET /api/v1/sbom/uploads/{artifactRef}are gateway-reachable aliases of/sbom/ledger/history(the route parameter is URL-decoded); both return404when no uploads exist (Sbom.Read).GET /api/v1/sbom/subject/{subjectRef}/cyclonedx?version=<guid|latest>returns the raw CycloneDX document byte-for-byte for the authenticated tenant (application/json, orapplication/vnd.cyclonedx+jsonon request). It is audit-bundle Endpoint A; see audit-bundle-sbom-endpoint.md (Sbom.Read).GET /api/v1/scan/results?subject=...&digest=...proxies Scanner’sGET /api/v1/reports/artifact-vulnerabilitiesand returns its 200 body verbatim; Scanner 404 or empty stays404, and Scanner transport failures return503(Sbom.Read).GET /sbom/context?artifactId=...andGET /sbom/paths?purl=...bind timeline and persisted dependency-path reads to the authenticated tenant (SbomPathQuery.TenantId) and requireSbom.Read/sbom:read./sbom/pathsreturns the concreteSbomPathResultcontract, including coverage and truncation.GET /sboms/{snapshotId}/projection?tenant=...returns the stored Link-Not-Merge projection (Sbom.Read).
3.2 Lineage graph (host minimal-API)
GET /api/v1/lineage/{artifactDigest}?tenant=...returns the lineage graph (optionalmaxDepth,includeBadges,includeReplayHash).GET /api/v1/lineage/diff?from=...&to=...&tenant=...,GET /api/v1/lineage/hover?from=...&to=...&tenant=....GET /api/v1/lineage/{artifactDigest}/childrenand/parents.GET /api/v1/lineage/compare?a=...&b=...&tenant=...(toggles for SBOM diff, VEX deltas, reachability deltas, attestations, replay hashes).POST /api/v1/lineage/diff/exportexports a signed lineage evidence pack for the diff between two artifact digests. It succeeds only after durable storage and a signer are configured; otherwise returns501 sbom_lineage_export_unsupported. Enforces a 50 MB payload cap (413). RequiresSbom.Attest/sbom:attest.POST /api/v1/lineage/verify(requiresSbom.Write) andPOST /api/v1/lineage/compare-drift(requiresSbom.Read) cover replay-hash verification.- Component totals, no signature state. The diff (
sbomDiff.beforeComponentCount/afterComponentCount, also onGET /sbom/ledger/diff) and the compare (sbomDiff.fromComponentCount/toComponentCount) carry the component totals of the two compared versions. The compare’sfromArtifact/toArtifactcomponentCountandvulnerabilityCountare null, because the compare service has no source for them. Graph node badges carry no signature state: SbomService stores no signature, DSSE or attestation state for an SBOM version and has no client for Attestor (hosted by Evidence), so it reports none rather than “unsigned” (SPRINT_20260928_004 PCD-151). - Compare summary counts the full diff. The compare’s
sbomDiff.added,removedandmodifiedlists each hold at most 33 entries (a third of the default 100 component changes).summary.componentsAdded,componentsRemovedandcomponentsModifiedcount the full diff, whether the lists are cut orincludeSbomDiff=falseomits them.sbomDiff.truncatedis true when any list holds fewer entries than the diff, andsbomDiff.totalChangesis the sum of the three full counts (SPRINT_20260930_006 PCD-223). - Duplicate component keys. A component’s key is its purl without version and qualifiers, else its trimmed name, so a version that lists one package at several paths or versions holds that key more than once. The ledger diff (
GET /sbom/ledger/diff) and the lineage diff behindGET /api/v1/lineage/diff,/hoverand/compare,POST /api/v1/lineage/diff/exportand the change-trace routes apply one rule (SbomComponentMap): one entry per key, keys compared ignoring case, and the first component in ordinal key order and then stored order is kept. The normalizer stores components sorted by key and then by ordinal version string, so the first version string in that order is the one compared. The diff and compare component totals count keys, not listed entries; the lineage graph’sbadges.componentCountand the ledger version items (GET /sbom/ledger/history,/point,/range) count listed entries, so a version that lists one key twice shows a higher total there than in a diff (SPRINT_20261007_031 CCP-062). - Compare failure. When the comparison cannot be computed after the inputs are accepted,
GET /api/v1/lineage/compareanswers500witherrorlineage_compare_failedand the fixed detail “The comparison could not be computed.”; the service logs the exception and does not return its text (SPRINT_20261007_031 CCP-062). - VEX deltas are always empty.
SbomLineageGraphServicereturns an empty VEX diff, andLineageCompareServicehas had no VEX-delta source since its Excititor persistence reference was removed (SPRINT_20260722_003 VULN-B1). TheincludeVexDeltastoggle therefore yields no VEX section. Restoring it needs a lineage-aware VEX read from the vulnerability hub, which does not exist. - Summary counts derived from absent sources.
summary.vulnerabilitiesAdded,vulnerabilitiesResolvedandvexStatusChangesare derived from the VEX delta summary andsummary.reachabilityChangesfrom the reachability delta summary; with no such summary (vexDeltas/reachabilityDeltasnull) the compare still states0for them (LineageCompareService.ComputeSummary). - Tenant assertion. The
tenantquery value on the lineage routes is optional: the read is bound to the authenticatedstellaops:tenantclaim, and a different value answers400 tenant_mismatch. - Console consumer (SPRINT_20261007_031 CCP-024). The Console opens the lineage viewer at
/security/lineage/<artifactDigest>and a comparison at/security/lineage/<artifactDigest>/compare?a=<digest>&b=<digest>; the exact digests travel in the URL, so navigation and refresh keep the pair, and a compare result naming another pair is refused. Thetenantassertion is the URL’s value, else the active session tenant, else none (never a literaldefault). The compare view markssbomDiff.truncated,vexDeltas.truncatedandreachabilityDeltas.truncatedlists as partial with the ownertotalChanges, shows the vulnerability, VEX and reachability counts only when the response carries the delta summary they are derived from, and keeps a missing selection,404,401/403ortenant_mismatch, and an unreachable or failing service as separate states instead of an empty or zero-change comparison.
Lineage is minimal-API only. The dead MVC
LineageController/LineageStreamControllerthat formerly declared duplicate routes underapi/v1/lineage(and consumed the unwiredStellaOps.SbomService.Lineageruntime services) were removed in BL-3 (SPRINT_20260713_002). The endpoints above are the soleapi/v1/lineagesurface; there is no route-prefix collision to reconcile. The stream/optimize/levels/metadata/cache operations the oldLineageStreamControllerdeclared were never mapped and had no live equivalent.
3.3 Change-trace compatibility
POST /api/change-traces/buildandGET /api/change-traces/{traceId}build a deterministic change-trace document from a lineage compare result (modernfromDigest/toDigestplus legacyfromScanId/toScanIdfields). RequiresSbom.Read.- Change-trace
summary.changedPackages,packagesAddedandpackagesRemovedcount the full compare diff. Thedeltaslist remains bounded by the compare’s per-list cap;summary.changedPackages > deltas.lengthidentifies an omitted portion of the package list. The response shape is unchanged. Commitments hash the canonical document payload through the shared cryptography library with the wire-mandated SHA-256 algorithm; a corrected full-count summary changes the commitment. GET /api/compare/baselines/{scanDigest}returns a baseline-recommendation stub (currently always empty recommendations). RequiresSbom.Read.
3.4 Artifact associations
POST /api/v1/artifact-links,POST /api/v1/artifact-links/import,GET /api/v1/artifact-links/resolve,GET /api/v1/artifact-links/unlinked,GET /api/v1/artifact-links/candidates, andPOST /api/v1/artifact-links/{linkId}/confirm|reject|verifymanage source/build/image/SBOM/scan/detector/finding/evidence associations. Reads requireSbom.Read; writes requireSbom.Write.
3.5 Console / component lookup / entrypoints
GET /api/v1/sbom/catalog(same handler asGET /console/sboms) andGET /components/lookup?purl=...read the tenant-scoped catalog/component views and requireSbom.Read/sbom:read. Both are bound to the authenticated tenant claim; see Section 2.3. Through the gateway only/api/v1/sbom/catalogreaches this service: the gateway’s static/consoleroute targets Authority.stella sbom listcalls/api/v1/sbom/catalog.POST /entrypointsandGET /entrypoints?tenant=...manage tenant-scoped entrypoint overrides (readSbom.Read, writeSbom.Write).
3.6 Registry sources and webhooks
RegistrySourceEndpointExtensionsmaps the live minimal-API route groupapi/v1/registry-sources. It provides CRUD plusPOST .../test,POST .../{id}/test|trigger|pause|resume,GET .../{id}/runs,GET .../{id}/discover/repositories|tags/{repository}|images,POST .../{id}/discover-and-scan, and image/SBOM query endpoints.- Each registry mask record is a paired
pathRegex+tagRegexrule. A discovered image is included only when at least one enabled record matches both repository/path and tag. Regexes are validated during create/update with bounded execution timeout. - Enabled mask records require a registry write credential reference (
authRefUri/credentialRef) before create/update/test can be considered publish-ready. Missing write credentials fail the connection test withsbomPublish=blocked; stale sources that still have enabled masks but no credential reference produce failed runs with the same error instead of silently queuing scanner work. - Creating a source with enabled mask records triggers an immediate
mask-addeddiscovery/scan run. Updating the enabled mask set triggersmask-updated. The nightly refresh hosted service triggersnightly-refreshfor active scheduled/both sources and submits forced Scanner jobs so vulnerability/feed changes refresh SBOM-derived status. - No inbound registry-webhook route exists at runtime. The dead MVC
RegistryWebhookController(which declaredapi/v1/webhooks/registrywithPOST .../{sourceId},POST .../{sourceId}/{provider}for Harbor/DockerHub/ACR/ECR/GCR/GHCR auto-detect, andGET .../healthz) was removed in BL-3 (SPRINT_20260713_002): it was never mapped (the host has noAddControllers()/MapControllers()), so external registries POSTing webhooks already received404. The supportingIRegistryWebhookService(fail-closed HMAC signature validation,authref://secret resolution) remains DI-registered inProgram.csbut has no HTTP entry point; SBOM refresh is driven by registry-source discovery/scan triggers (mask-added/mask-updated/nightly-refresh) rather than inbound webhooks. The live/api/v1/webhooks/registry/{docker,harbor,generic}receiver is owned by the Policy Gateway (Policy:Gateway:RegistryWebhooks:*, per-registry HMAC secrets) for gate evaluations — it is a separate implementation and was never the SBOM registry-source ingestion path.
3.7 Health, build info, and Doctor
GET /healthz(liveness,status: ok) andGET /readyz(status: warming) are anonymous.- A build-info endpoint is mapped via
MapBuildInfoEndpoint(app, fallbackModuleName: "sbomservice")for the operator verify aggregator. GET /doctor/sbomservice/checksis the doctor-check/v1 surface, mapped only when the owner database is configured and gated bySbom.DoctorHealth(ops.health). Checks (Doctor/SbomServiceDoctorChecks.cs):doctor.sbomservice.database.own-database-cutover,doctor.sbomservice.schema.table-census,doctor.sbomservice.schema.foreign-co-authority,doctor.sbomservice.retention.registry-run-backlog.
4) Internal APIs
All internal endpoints require Sbom.Internal / sbom:operate. Event, asset, inventory, resolver, ledger-audit, analysis, backfill, and retention APIs are installation-wide because their current repository contracts are installation-wide. /internal/orchestrator/* is tenant-scoped and always binds storage access to the authenticated tenant.
GET /internal/sbom/eventsreturns durablesbom.version.createdbacklog state.POST /internal/sbom/events/backfillreplays stored projections into the durable event backlog.GET /internal/sbom/asset-eventsreturns durablesbom.asset.updatedbacklog state.GET /internal/sbom/inventoryreturns durable inventory entries;POST /internal/sbom/inventory/backfillreturns501 sbom_inventory_backfill_unsupportedwhen feed backfill is unavailable.GET /internal/sbom/resolver-feed,GET /internal/sbom/resolver-feed/export(NDJSON,application/x-ndjson), andPOST /internal/sbom/resolver-feed/backfill(501 sbom_resolver_feed_backfill_unsupportedwhen unavailable) expose the persisted resolver candidate store.GET /internal/sbom/ledger/audit?artifact=...andGET /internal/sbom/analysis/jobs?artifact=...expose durable ledger audit and analysis job history.POST /internal/sbom/retention/pruneapplies durable retention policy.GET /internal/orchestrator/sources?tenant=...andPOST /internal/orchestrator/sourcesmanage ingest sources (tablesbom.orchestrator_sources).GET /internal/orchestrator/control?tenant=...andPOST /internal/orchestrator/controlmanage pause/throttle/backpressure state (tablesbom.orchestrator_control).GET /internal/orchestrator/watermarks?tenant=...andPOST /internal/orchestrator/watermarks?tenant=...&watermark=...manage durable watermarks (tablesbom.watermarks).
5) Ingestion and ledger behavior
- Uploads are normalized into deterministic component lists before persistence.
- JSON ingestion accepts exact CycloneDX 1.4, 1.5, 1.6 and 1.7 versions through the existing top-level
componentsnormalizer (PCD-394). Scanner inventory JSON keeps its original 1.7 declaration; callers do not relabel it to 1.6. Unknown versions, including 1.8 and 1.7.1, remain refused. This JSON profile support makes no XML/Protobuf decoder claim. - Normalization preserves lifecycle-relevant CycloneDX/SPDX metadata, including PURLs, component type/scope, selected lifecycle properties, and support/EOL external references. The upload pipeline then runs an offline lifecycle enrichment pass before ledger persistence.
- Lifecycle enrichment is deterministic and does not perform external lookups. It uses SBOM-provided hints plus local catalog rules to tag unsupported components, persists component-level metadata (
lifecycle:status,lifecycle:eolDate,lifecycle:source,lifecycle:reason), and aggregates audit-facing validation tags such ascomponent:eol,lifecycle:eol, andruntime:eol. runtime:eolis the policy-facing tag for EOL runtime components. Policy authors consume that tag throughsbom.has_tag("runtime:eol"); they should not hand-maintain it in policy logic.- Each exact
(tenant, artifact)keeps an independent append-only chain ofSbomLedgerVersionrecords. Migration008_ledger_version_stream_fence.sqladdssbom.ledger_chainsas the durable, never-pruned chain-id/next-sequence authority, so deleting sequence 2 or every live version cannot reset identity. Backfill chooses only a nonempty chain/positive sequence as identity while raising above every observed positive sequence; a first valid upload supplies the chain when malformed history has none. Unchanged migration/defensive reruns preserve cursorxminand the bootstrap index relfilenode. Parent version/digest and shared-build lookups never cross tenants. - The durable repository allocates tenant-local chain sequence under a transaction-scoped advisory lock. When the version stream is explicitly enabled, the version insert,
sbom.uploadedP6 append, row publication fence, and create audit are one owner-database transaction. When disabled, ledger/cursor/audit behavior continues but no stream append or fence occurs. The raw document sidecar is already durable before this transaction;document_digesthashes its exact UTF-8 bytes, while the historicaldigestcolumn retains its logical/minified JSON identity. - Audit entries are written for ledger mutations. Retention locks candidates, appends
sbom.version.retiredtombstones, deletes versions, and writes prune audits in one transaction; a repeated removal is a no-op. Enumeration and deletion use the same exact tenant/artifact key, and a zero-row race is not counted or logged as a touched chain. - A blank tenant is refused, never widened (SPRINT_20260823_003 SBT-1, closing W3-07 M0 §3A T-5). Retention enumerates
ListRetentionChainsAsync, whose SQL drops any row whosetenant_idis null or whitespace, so a legacy pre-004row that nobody owns is not a prune candidate for anybody — it is not merely excluded from other tenants’ policies, it is excluded from all of them, and it survives untouched.RemoveVersionsAsyncthrows on a blank tenant rather than dropping the predicate. Two tenants sharing oneartifact_reftherefore prune independently. Pinned byRetention_IsExactTenantAndArtifact_WhenTenantsShareArtifact,Retention_RefusesABlankTenantRatherThanWidening_WhenALegacyRowSharesTheArtifactandRemoveVersions_WithABlankTenant_Refuses, all red-proved against a reconstruction of the tenant-blind prune. - Analysis jobs are persisted immediately when upload-triggered work is queued.
ledger_auditandanalysis_jobscarrytenant_id, and both reads are tenant-scoped (SPRINT_20260823_003 SBT-4, closing W3-07 M0 §3A T-4). Both tables were keyed onartifact_refalone with no tenant column at all, so knowing any tenant’s artifact ref was enough to read its audit trail and analysis-job history. Forward-only migration009_ledger_audit_analysis_jobs_tenant_id.sqladds the column, tenant-first indexes, and a two-pass back-fill: exact attribution through the survivingledger_versionsrow, then the never-prunedledger_chainsowner only where exactly one tenant owns thatartifact_ref. Rows that remain unattributable keeptenant_id = NULLand are shown to nobody — the same “no back-fill, no leak” disposition migrations 004 and 005 chose for legacyledger_versionsrows.GET /internal/sbom/ledger/auditand…/analysis/jobsnow resolve the authenticated tenant and have no installation-wide escape hatch: they are diagnostic reads of one artifact, unlike the backlog drains, which are installation-wide by the meaning ofsbom:operate. The column stays nullable;009adds no table.- The raw-document sidecar stays digest-objective, and the ACL became a type (SPRINT_20260823_003 SBT-5, closing W3-07 M0 §3A T-6).
sbom.sbom_documentsdeliberately has notenant_id: migration 003 enforces isolation at the ledger join, and DC-34’s contract is “content is digest-objective, visibility is tenant-ACL” — two tenants uploading byte-identical SBOMs share one row, which a tenant column would break. What was missing was that the ACL lived in the callers’ discipline.ISbomDocumentRepository.GetDocumentByDigestAsyncnow takes aSbomDocumentAccess, constructible only from a ledger version that carries a tenant, so a caller holding a bare digest cannot reach the sidecar at all — a compile error, not a review comment. The storage layer is unchanged and still tenant-blind. - The dead tenant-blind reads are deleted, and pinned deleted (SBT-5, T-7).
ISbomLedgerRepository.GetVersionAsync(Guid)andGetVersionByDigestAsync(string)sat two lines above their tenant-scoped twins with no production caller;IOrchestratorControlRepository.ListAsyncreturned every tenant’s control state and only a test called it. All three are gone andSbomServiceTenantBlindSurfaceConformanceTestsfails if any comes back. Two entries in the T-7 list were already tenant-scoped at HEAD and needed nothing:GetChainIdAsync(tenantId, artifactRef)andPostgresSbomLineageEdgeRepository.GetByRelationshipAsync(tenantId, …). - Lineage edges are persisted for parent, base-image, and shared-build relationships.
- Artifact associations are persisted as first-class subjects and links so source snapshots, build runs, images, SBOMs, scan jobs, detector reports, findings, and evidence bundles can be resolved from one graph.
- Retention pruning removes old versions while preserving auditability.
6) Events and derived state
eventing.outboxin the SbomService owner database is the source of truth for the orderedsbom.versionslineage. Standard envelope epoch/sequence/time are not duplicated inside v1 payloads.008_ledger_version_stream_fence.sqlrecords the upload envelope position on each new live version so a bounded snapshot can exclude post-fence writes without a second event ledger. Catch-up also clamps envelopes to the head captured before its query, excluding a concurrently appended sequence beyond that advertised fence.SbomService:VersionStream:Enableddefaults false and gates producer append/fence, owner/pruner DI, and all four path keys/five operations together. When explicitly enabled,sbom.versionsis declaredRemotelyConsumed; Findings registers/reports over the owner API because its durable checkpoint lives in the Findings database. Activation first requires explicit setup-owned database-byte, pause-reserve, version-row/write, WAL-reservation/write, remote-lease, and outbox retention values; missing/invalid values stop startup. Upload and retirement share one transactional admission gate and return typed 503 writer-pause reasons before a configured threshold. Operator retention settings bind fromEventing:OutboxRetention; the outbox pruner itself runs in every host that embeds the eventing chain (SPRINT_20260916_001 RFC-14, owner ruling Q-42) and reclaims only a declared stream; source invents no deployment numbers. The exact contract and recovery procedure are in version-stream.md. Forward-only SBOM 008 and shared Eventing migrations, plus the standard reliability primitives required by the ledger repository, may converge/register while dormant; disabled boot still emits no stream envelope, maps no owner route, mutates no consumer floor, and declares no stream to the outbox pruner.- Full bootstrap/catch-up/document semantics are frozen in version-stream.md.
- Durable event backlogs are stored in:
sbom.version_eventssbom.asset_eventssbom.inventory_eventssbom.resolver_candidates
sbom.version.createdandsbom.asset.updatedremain durable projection-read outputs. They are a separate lineage fromsbom.uploaded/sbom.version.retiredand are not an upload feed.- Inventory and resolver feeds remain durable stores, but their automatic rebuild producers are still unimplemented and continue to fail truthfully rather than treating dependency edges as either feed.
- Backlog reads declare their tenant scope; they cannot omit it (SPRINT_20260823_003 SBT-3, closing W3-07 M0 §3A T-1 and T-3).
SbomTenantScopeis a readonly struct with two constructors —AllTenantsandFor(tenant)— and no usable default: reading adefaultone throws rather than widening.ISbomEventStore’s four reads and two bulk clears,IProjectionRepository.ListAsyncandRegistrySourceQueryall take one, so an installation-wide read is a visible decision at the call site and a forgotten one is a startup-time exception, not a silent whole-installation answer.GET /internal/sbom/events,…/asset-events,…/inventory,…/resolver-feedand…/resolver-feed/exportaccept an optional?tenant=; without it they remain installation-wide, which is whatsbom:operate(“installation-wide SBOM operations”) grants.POST /internal/sbom/events/backfilland the two feed rebuilds are installation-wide and not scopable: a per-tenant replay would leave every other tenant’s backlog missing, which is the failure the recovery path exists to undo.- The registry-source nightly refresh and the inbound-webhook lookup declare
Scope: SbomTenantScope.AllTenantsbecause neither has a caller tenant to scope by; the nightly pass then triggers each source under its ownsource.TenantId, so the cross-tenant read never becomes a cross-tenant write.
7) Registry source management
- Registry source definitions and run history are durable PostgreSQL state.
- Registry credential material is resolved over the Integrations owner API. At build time the host consumes the offline package
StellaOps.Integrations.RegistryCredentials.Contracts(version pinned insrc/Directory.Packages.props) for the wire protocol and owns the small adapter that combines it with the sharedCredentialAeadprimitive. OCI discovery uses the domain-neutralStellaOps.Oci.RegistryClientfoundation. The host does not compile Integrations Contracts, Core, DockerV2 or any other producer implementation; INT-3c’s isolated Release publish with the Integrations tree absent is the binding P19 proof. RegistrySourcestores URL, trigger mode, auth references, tenant ownership, and mask records. Legacy repo/tag filter arrays remain for compatibility, but available-image filtering is driven by paired mask records.RegistrySourceRunstores discovery/scan counts, trigger metadata, completion state, submitted Scanner job ids, and matched image records.- Scan submissions carry registry source metadata (
source_id,source_type=registry,trigger_type,sbom_format=cyclonedx-json, andsbom_refresh=truefor forced nightly refreshes). - Mask records do not own scheduler rows. Nightly refresh is source-level; deleting a mask removes it from automatic mask-change/nightly selection, and deleting all enabled masks causes nightly refresh to skip the source.
- A registry source that belongs to no tenant belongs to nobody, and is served to nobody (SPRINT_20260823_003 SBT-2, closing W3-07 M0 §3A T-2).
registry_sources.tenant_idis nullable with no back-fill. Two independent barriers now stand behind every id-bearing route: the durable and in-memory repositories exposeGetByTenantIdAsync, whose SQL isWHERE id = @id AND tenant_id = @tenant_id(a NULL matches no value, and a blank caller tenant is refused before the database is touched); andRegistrySourceService.TenantMatchesfails closed — a blank on either side is a refusal, never a match. The tenant-blindGetByIdAsync(id, ct)survives for the two installation-wide system paths that have no caller tenant, inbound webhooks and discovery/scan runs, and is documented as unusable from a tenant-facing path.- Before this,
ListAsynchid unowned rows whileGET/PUT/DELETE /api/v1/registry-sources/{id}and its/test,/trigger,/pause,/resume,/runs,/imagesand/discover/*siblings served and mutated them for every authenticated tenant. That asymmetry was the tell, and it is whyRegistrySourceTenantIsolationTestsasserts the list and the by-id read together. - Existing unowned rows are refused, not back-filled — see
Decisions & Risksin the sprint.
- Before this,
- The Web source detail view derives Schedule Health from the latest
nightly-refreshrun. If the nightly job fails because discovery, scanner dispatch, or publish credentials are broken, the failed run and its error remain visible in run history and Schedule Health.
8) Determinism and offline posture
- Stable ordering, UTC timestamps, and canonical hashing are required for all read models and ledger state.
- The service remains offline-friendly: no external calls are required for persistence or replay.
- The service host does not compose fixture-backed or in-memory canonical stores by default.
- Development and Testing may use deterministic fixture/in-memory stores only when
SbomService:LocalHarness:Enabled=trueis set explicitly, or when tests select the explicitDevelopmentLocalHarness/TestingLocalHarnesshost environments. Endpoint metadata now refers to the configured event store rather than implying that internal SBOM event APIs are in-memory-backed. - Staging and production runtimes fail startup if no owner connection is configured (
STELLAOPS_POSTGRES_SBOMSERVICE_CONNECTIONorPostgres:SbomService:ConnectionString); the local harness switch is ignored outside Development/Testing. - Lineage hover/compare caches do not default to in-memory in production-like runtimes. When enabled, both consume the single
StellaOps.Messaging.Abstractions.IDistributedCacheFactoryadmitted by the signed Router messaging transport; they do not register a second Redis client. Otherwise they are absent.
8a) Authentication and authorization model
- The host uses
AddStellaOpsResourceServerAuthentication(...)and runsUseIdentityEnvelopeAuthentication()before bearer authentication. A valid Authority token or Router-signed envelope creates the principal; raw tenant/user headers do not. - Every live minimal-API policy requires an authenticated principal and a canonical Authority scope:
Sbom.Read->sbom:read,Sbom.Write->sbom:write,Sbom.Attest->sbom:attest, andSbom.Internal->sbom:operate.Sbom.DoctorHealth->ops.healthguards the Doctor read path. - Tenant-scoped handlers resolve only
StellaOpsClaimTypes.Tenant(stellaops:tenant). Request tenant values can detect a mismatch but cannot select storage. Audit actors resolve from the authenticated subject/NameIdentifier;x-user-idand body actor fields cannot override them. GET /api/v1/sbom/catalog/GET /console/sboms,GET /components/lookup,GET /sbom/paths, andGET /sbom/contextall useSbom.Readafter their tenant keys, unconditional repository predicates, tenant-prefixed cache keys, and owner-versus-intruder tests landed (Section 2.3).SbomAuthenticationBoundaryTests.ReadScope_CannotUseWriteOrInternalOperationsdeliberately asserts that plainsbom:readreaches path/context while write and installation-wide operations remain forbidden.- Deployment configuration declares the SBOM audiences (
api://sbomservice,stellaops) and the Authority metadata endpoint. Health, readiness, OpenAPI, and build-info discovery remain anonymous. The Doctor surface usesAddStellaOpsScopePolicy(ops.health) so that an operator probe admitted byBypassNetworksis not rejected by an authenticated-user requirement. - The formerly-unmapped MVC controllers (which retained obsolete policy names such as
sbom:read/lineage:export/lineage:adminoutside the live minimal-API boundary) were removed in BL-3; the live route contract is minimal-API only and every policy resolves to a canonical Authority scope.
9) Configuration
STELLAOPS_POSTGRES_SBOMSERVICE_CONNECTIONis the canonical owner-database variable (CoC §8.2 / ADR-039). It is read as a raw environment variable ahead of the canonical configuration keyPostgres:SbomService:ConnectionString; since SPRINT_20260722_020 W3-07 M5 (2026-08-23) nothing else is accepted. A value of either that is present but blank throws rather than being read as unconfigured. There is no generic shared-connection fallback:ConnectionStrings:Defaultis not read by any project in this family. Resolution lives inSbomServiceConnectionResolver; the names live inSbomServiceSchemaTopology. Note the two names differ — the database isstellaops_sbomservice, the schema inside it issbom. The database sits on the shared installation serverdb.stella-ops.localwith its own login rolesbomservice; the ownership boundary is that role plusREVOKE CONNECTon every sibling database, not a dedicated server (ADR-039 P1).SbomService:PostgreSQL:ConnectionStringis RETIRED. It was the pre-window rollback-compatibility key; the 2026-08-22 M4 cutover removed it from the running container and M5 removed it from the resolver. The name survives inSbomServiceSchemaTopology.RetiredConnectionSourcesas a name only, so the retirement is pinned by test. Note the surrounding sectionSbomService:PostgreSQLis still bound — forSchemaName,CommandTimeoutSecondsand pool sizes — so only that one member is dead, andAddSbomServicePersistenceoverwrites it from the resolver. Thedoctor.sbomservice.database.own-database-cutovernon-canonical branch is kept: it is now how an image built before M5 announces itself.SbomService:PostgreSQL:SchemaNameoptionally overrides the default schemasbom.Doctor:Registration:Enabled(default false) opts this host into the Platform doctor registry. Unlike its sibling services, sbomservice cannot simply mint a doctor auth client: it already registers one for the registry-credential resolver wheneverSbomService:Integrations:Authority:Enabledis true, andAddStellaOpsAuthClientconfigures a single shared options instance, so a second registration would silently overwrite the first. The host refuses to start when both are enabled; disable one or reconcile the two identities onto one client.SbomService:LocalHarness:Enabled=trueenables the deterministic local harness in Development/Testing only. Test hosts may instead selectDevelopmentLocalHarnessorTestingLocalHarnessso the harness is visible before top-level startup wiring runs. Both forms compose fixture-backed repositories when configured/found and in-memory seeds only for local tests.SbomService:HoverCache:UseDistributed=trueregistersDistributedLineageHoverCacheover the Router-owned signed messaging cache factory.SbomService:HoverCache:Ttlmust be positive and defaults to five minutes. WithoutUseDistributed, production-like runtimes do not register an in-memory hover cache.SbomService:CompareCache:UseDistributed=trueregistersValkeyLineageCompareCacheover the same factory; its default TTL is ten minutes. Without it, production-like runtimes do not register an in-memory compare cache.- Distributed lineage keys contain SHA-256 tokens of the complete normalized digest and tenant identity, preserve caller-supplied
from -> todirection, and never truncate source digests. Artifact invalidation deletes matching tenant entries in either digest position; tenant invalidation deletes only that tenant’s compare entries. - Cache read failures are treated as misses so authoritative lineage calculation remains available. Invalidation failures propagate and are not counted as successful. A Router-disabled deployment that enables these caches without supplying an admitted messaging factory remains fail-closed on cache resolution; selecting a separate standalone transport is an operator/product decision, not an implicit fallback.
SbomService:LineageExport:StorageRootPathenables filesystem-backed durable lineage evidence-pack storage through the shared content-addressed blob store. When omitted, lineage export remains fail-closed.SbomService:LineageExport:AccessTtlDayscontrols the response expiry hint for stored lineage exports; valid values are 1-30 days and the default is 1 day.- Successful lineage exports also require the host to register
StellaOps.Provenance.Attestation.ISigner. The SBOM host does not register a default signer because unsigned or locally improvised signatures would be evidence forgery. SbomService:Ledger:*controls retention:MaxVersionsPerArtifactMaxAgeDaysMinVersionsToKeep(validated<= MaxVersionsPerArtifactwhen the max is positive)
SbomService:DependencyPaths:Enabled=trueruns the idempotent raw-sidecar graph backfill at startup;SbomService:DependencyPaths:MaxVersionsPerRunbounds one run (default 10,000, range 1-100,000).SbomService:RegistryHttp:*(sectionRegistryHttp) andSbomService:ScannerHttp:*(sectionScannerHttp) configure registry-discovery and scan-emission HTTP behavior (timeouts,AllowedHosts).SbomService:ScannerUrlis the Scanner base address for job submission and the/api/v1/scan/resultsproxy;SbomService:BatchScanSizeandSbomService:BatchScanDelayMspace batch submission.SbomService:Integrations:*(sectionIntegrations,RegistryCredentialResolverOptions) configures registry-credential resolution:Enabled,BaseAddress,Scope,Tenant,TimeoutSeconds,FailClosed, andAuthority:*for the client-credentials token.Integrations:SecretProtectionKeysupplies the credential key.SbomService:RegistrySources:*(section nameRegistrySources, bound toRegistrySourceQueryOptions) bounds the registry-source query endpoints (DefaultPageSize,MaxPageSize,MaxRunHistoryLimit) and controls nightly refresh scheduling (NightlyRefreshEnabled,NightlyRefreshHourUtc,NightlyRefreshMinuteUtc,NightlyRefreshPollSeconds).SbomService:Concelier:*is retired (2026-09-16,SPRINT_20260914_001VRP-7, read-contract register X19). It forwarded every upload to a Concelier host frozen tosrc/__Obsoleted/Concelier/on 2026-09-11, so the call could only 404 into a swallowed best-effort catch. The lawful successor is this service’s ownsbom.versionsowner stream (seeversion-stream.md), which Findings consumes. The keys bind to nothing; remove them from any operator.env.- Configuration is also read from environment variables prefixed
SBOM_.
9a) Observability
- Meter
StellaOps.SbomService(Observability/SbomMetrics.cs) emits:sbom_paths_latency_seconds,sbom_paths_queries_totalsbom_timeline_latency_seconds,sbom_timeline_queries_totalsbom_projection_seconds,sbom_projection_size_bytes,sbom_projection_queries_totalsbom_events_backlogsbom_orchestrator_control_updates,sbom_resolver_feed_publishedsbom_ledger_uploads_total,sbom_ledger_diffs_total,sbom_ledger_retention_pruned_totalsbom_version_stream_writer_paused_total(boundedoperationandreasonlabels)
- ActivitySource
StellaOps.SbomService(Observability/SbomTracing.cs) emits server spans includingentrypoints.list,entrypoints.upsert,console.sboms,components.lookup,sbom.projection,lineage.graph,lineage.diff,lineage.hover,lineage.children,lineage.parents,lineage.export,lineage.compare,lineage.compare-drift,lineage.verify,events.list,asset-events.list, andinventory.list. - A Grafana dashboard ships at
Observability/sbomservice-grafana-dashboard.json.
10) Proof status
- Durable host build and P19 source proof:
StellaOps.SbomServicerestores from the local contract mirror and publishes in Release withsrc/Integrationsphysically absent. Its generated closure (docs/architecture/build-boundary/build-boundary-report.json) has zero violation pairs; published output contains the exact registry-credentials contract plus neutral OCI client and no Integrations host/core/DockerV2 implementation. Findings projects are conformance-limited to the closed BCL-onlyStellaOps.SbomService.Contractsgraph. This is source completion only: the version stream remains default-off until the separate P7 setup values and controlled activation window are supplied. - Durable persistence proof:
StellaOps.SbomService.Persistence.Testscovers ledger, event store, registry-source persistence, and repository-backed timeline rebuild across service instances. - Registry-source proof:
RegistrySourceServiceTestscovers mask validation, write-credential preflight, immediate scan submission on mask create, stale-source run failure without write credentials, and forced nightly refresh submission;ScanJobEmitterServiceTestscovers registry-qualified Scanner requests and refresh metadata;PostgresRegistrySourceRepositoryTestscovers mask/run-report persistence. - Runtime startup proof:
SbomRuntimeStartupContractTests(inStellaOps.SbomService.Tests) verifies missing PostgreSQL fails closed, production-like PostgreSQL wiring resolves durable services without in-memory runtime defaults, and Development/Testing harness behavior requires explicit opt-in. - Lineage export proof:
LineageEvidenceTruthfulnessTests(inStellaOps.SbomService.Tests) covers missing storage, missing signer, canonical byte stability across input ordering, signed durable storage, and digest-mismatch behavior when stored evidence is tampered. - Lineage graph proof:
StellaOps.SbomService.Lineage.Testscovers lineage-model determinism, graph optimizer, and stream-service behavior. - Host lineage-edge persistence proof:
PostgresSbomLineageEdgeRepositoryTestsboots the Production host against fresh PostgreSQL and proves embedded migration/index convergence, insert/read/idempotent AddRange counts, second-host restart convergence, deterministic bidirectional BFS with cycle/depth/negative boundaries, relationship filtering, and identical-digest two-tenant read/traversal/delete isolation. The host tablesbom.lineage_edgesis tenant-fenced by unconditional repository predicates on the system connection; it does not currently use PostgreSQL RLS. - Tenant-scoped read-model proof:
PostgresTenantScopedReadModelTests(inStellaOps.SbomService.Persistence.Tests) proves, against a real Postgres with migration 006 auto-applied, that a tenant-A catalog/component row is invisible to tenant B, that a legacytenant_id IS NULLrow is invisible to every tenant (and fenced, not deleted), and that naming another tenant’s artifact explicitly cannot escape the tenant predicate.SbomTenantScopedReadModelTests(inStellaOps.SbomService.Tests) proves over HTTP that/console/sbomsand/components/lookupsucceed with plainsbom:read, return nothing to a tenant that owns nothing, do not replay a warm cache entry across tenants, and ignore a caller-suppliedtenant/tenantIdquery value. - Dependency-path proof: extractor tests cover CycloneDX, SPDX, dangling refs, zero-edge documents, cycles, and byte-stable output;
PostgresDependencyGraphRepositoryTestsproves migration 007, replay idempotence, tenant fencing, and legacy-row fail-closed behavior against fresh PostgreSQL; repository-backed path tests cover real three-plus-node walks, cycles, orphan non-fabrication, depth truncation, and warm-cache tenant isolation.SbomDocumentSidecarStorageTestsboots the durable host, uploads a real graph, and observes the exact three-node owner path while tenant B receives no path.
11) Open gaps
- Add first-class producers for the inventory and resolver feed backfills; the dependency graph intentionally does not impersonate either feed.
- Nothing under
src/writessbom.catalogorsbom.component_lookups; both derived views are populated out-of-band. A first-class producer (or an explicit removal of the views) is still owed. - Register a production signer adapter for SBOM lineage export and add an authorized download broker for
cas://sbom-lineage-export/...references. Until then, deployments can store signed exports only when they explicitly provide filesystem storage plus anISigner; direct HTTP downloads are not implemented. - Normalize the shared contract surface further if the persistence assembly keeps absorbing host-owned types.
- Dead MVC controllers — RESOLVED (BL-3,
SPRINT_20260713_002).LineageController,LineageStreamController,RegistrySourceController, andRegistryWebhookControllerwere removed (they were never mapped — the host has noAddControllers()/MapControllers()— carried unregistered policy names, andRegistrySourceControllerduplicated the live minimal-APIRegistrySourceEndpointExtensions; the live webhook receiver lives in the Policy Gateway). The controller-scoped tests were removed with them:LineageTenantBindingTests.cs(host test project) plus the two orphan files under__Tests/StellaOps.SbomService.Tests/(LineageStreamControllerTests.cs,LineageDeterminismTests.cs) that no.csprojcompiled. The host’s now-unusedStellaOps.SbomService.Lineageproject reference was dropped fromStellaOps.SbomService.csproj. - Duplicate Lineage migration authority — RESOLVED (W3-07 / CM-2, 2026-08-24). Platform’s orphaned plugin, Lineage implementation reference, constraint-namespace preflight, and index-collision repair resource are removed. The recorded live estate never contained the library tables, so no migration/data cleanup was performed. The deployed host’s embedded persistence chain is the sole production authority for
sbom. - Duplicate Lineage implementation remains source-only. The retained library still contains a full runtime stack (
LineageDbContext/LineageDataSource/LineageGraphService/repositories), but no production project references it andAddLineageServiceshas no caller. Its focused test is the only consumer. Moving or deleting that auditable source is a separate §2.9A disposition; it is not a runtime or migration blocker now that Platform no longer compiles it.
