SBOM Service Architecture
Scope: canonical SBOM projections, ledger history, lineage, registry-source state, and durable SBOM event backlogs consumed by Console, Policy, Concelier, Graph, and Findings.
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
- 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 - Persistence:
src/SbomService/__Libraries/StellaOps.SbomService.Persistence - Lineage library:
src/SbomService/__Libraries/StellaOps.SbomService.Lineage— its migration runs (thesbom.sbom_lineage_edges/sbom.sbom_verdict_links/vex.vex_deltasbaseline is applied centrally byplatform-webviaSbomLineageMigrationModulePlugin), but its runtime services are not wired in this host:AddLineageServiceshas zero call sites, soLineageDbContext,LineageDataSource, and the library’sLineageGraphService/repositories never run here, and the MVC controllers that consume them are unmapped (see Section 3.2 note, Section 8a). The live lineage surface is the host’s own minimal-API endpoints over the host-ownedsbom.lineage_edges/sbom.verdict_linkstables and the host’sSbomLineageGraphService. Removing/consolidating the duplicate library implementation is tracked in Section 11. - 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
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_audit,sbom.analysis_jobs,sbom.watermarkssbom.dependency_graphs,sbom.dependency_nodes,sbom.dependency_edgessbom.lineage_edges,sbom.verdict_linkssbom.artifact_subjects,sbom.artifact_linkssbom.version_events,sbom.asset_events,sbom.inventory_events,sbom.resolver_candidatessbom.registry_sources,sbom.registry_source_runs
- The separate
StellaOps.SbomService.LineageEF Core context owns its own baseline migration (00001_InitialSchema.sql) which createssbom.sbom_lineage_edges,sbom.sbom_verdict_links, andvex.vex_deltas, each with row-level-security tenant-isolation policies. These are distinct from the host-ownedsbom.lineage_edges/sbom.verdict_linkstables above. - 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 in Program.cs unless explicitly noted as MVC controller routes. Minimal-API endpoints are gated by the Sbom.Read, Sbom.Write, Sbom.Attest, or Sbom.Internal 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.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 /sbom/versions?artifact=...returns 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 /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/exportexports a signed lineage evidence pack 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.
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.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 /console/sbomsandGET /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.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, Vault-backedauthref://secret resolution) remains DI-registered but 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 and build info
GET /healthz(liveness,status: ok) andGET /readyz(status: warming) are anonymous.- A build-info endpoint is mapped via
BuildInfoEndpointExtensions.MapBuildInfoEndpoint(...)for the operator verify aggregator.
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.
- 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
(tenant, artifact)keeps an independent append-only chain ofSbomLedgerVersionrecords. Migration005_ledger_versions_tenant_chain.sqlmakes sequence uniqueness tenant-local; parent version/digest and shared-build lookups never cross tenants. - Audit entries are written for ledger mutations.
- Analysis jobs are persisted immediately when upload-triggered work is queued.
- 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
- Durable event backlogs are stored in:
sbom.version_eventssbom.asset_eventssbom.inventory_eventssbom.resolver_candidates
sbom.version.createdandsbom.asset.updatedare canonical durable outputs today.- 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.
7) Registry source management
- Registry source definitions and run history are durable PostgreSQL state.
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.
- 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
SbomService:PostgreSQL:ConnectionStringis not configured; 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. - 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 /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 and Authority metadata endpoint and does not configure network bypasses. Health, readiness, OpenAPI, and build-info discovery remain anonymous.
- 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
SbomService:PostgreSQL:ConnectionStringenables the live durable runtime and is required outside explicit Development/Testing local harness mode.SbomService:PostgreSQL:SchemaNameoptionally overrides the default schemasbom.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).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:LearnOnUpload=trueforwards every successful SBOM upload to Concelier’s matcher (SbomService:Concelier:LearnUrl, defaulthttp://concelier.stella-ops.local/api/v1/learn/sbom;SbomService:Concelier:TimeoutSeconds). Forwarding is best-effort and never fails an upload.- 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_total
- 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:
StellaOps.SbomServicebuilds cleanly with PostgreSQL-backed canonical stores. - 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. Evidence:docs/qa/feature-checks/runs/sbomservice/sbom-lineage-edge-persistence/run-002/. - 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 library — SEAM DOCUMENTED, not consolidated (BL-3). The
StellaOps.SbomService.Lineagelibrary is kept because its baseline migration00001_InitialSchema.sqlstill runs centrally via PlatformSbomLineageMigrationModulePlugin(MigrationModulePlugins.cs,typeof(LineageDataSource).Assembly, schemasbom) — Platform.Database holds that reference. Only the library’s runtime services are dead (AddLineageServiceshas 0 call sites); the live lineage runtime is the host’s ownSbomLineageGraphServiceover the host-ownedsbom.lineage_edges/sbom.verdict_linkstables. Latent footgun: the two migration sources create parallel tables in the samesbomschema (library:sbom.sbom_lineage_edges,sbom.sbom_verdict_links; host baseline001:sbom.lineage_edges,sbom.verdict_links) but reuse identical index names —idx_lineage_edges_parent,idx_lineage_edges_child,idx_lineage_edges_created,idx_verdict_links_cve,idx_verdict_links_status. Postgres index names are unique per schema, and both migrations useCREATE INDEX IF NOT EXISTS, so whichever migration runs second silently skips its indexes (NOTICE, no error). Ifplatform-web’s central run precedes the host startup migration, the livesbom.lineage_edges/sbom.verdict_linkstables lose those traversal indexes and fall back to sequential scans. A safe fix is forward-only and cross-cutting (a NEW migration renaming the library’s index set, or dropping the unused library baseline table creation) and touches the central migration authority — deferred rather than editing the applied migration. - Duplicate Lineage implementation. The
StellaOps.SbomService.Lineagelibrary ships a full runtime stack (LineageDbContext/LineageDataSource/LineageGraphService/repositories) that is never DI-wired in the host — only its migration runs (viaplatform-web). The live path uses the host’s ownSbomLineageGraphServiceover the host-ownedsbom.lineage_edges/sbom.verdict_linkstables. Consolidate onto one implementation (either wire the library and retire the host tables, or delete the library’s dead runtime services and keep only its migration) so the build graph stops carrying an unpaid abstraction. Deferred from the 2026-07-11 audit closeout (same reason).
