ADR-024: Internal OCI metadata registry, opt-in upstream attachment, and attachment-state
Status: Accepted — implemented. Worker-path internal-first store + dual-write + OCI read API + attachment-state shipped (sprints 20260604_013–018); CLI/CI ingest, retention sweep, and gate internal-first-then-external sourcing shipped 2026-06-22 (sprint 20260622_001). See Implementation status below. Date: 2026-06-04 Sprint: SPRINT_20260604_014_Scanner_internal_oci_metadata_registry.md (with 013, 015–018) Related ADRs: ADR-004 (forward-only migrations), ADR-006 (EPF), ADR-009 (per-env registry override), ADR-013 (.NET callgraph extractor)
Read this before changing how SBOM/reachability artifacts are attached to images, adding a registry connector, or touching the SBOM readiness gate.
Context
Stella generates SBOMs (static analysis of image layers) and computes call-reachability, and it can attach an SBOM to a container image as an OCI referrer (SbomOciPublisher → OciArtifactPusher, subject = image digest). In practice the path is silently lossy, and the loss is invisible:
- The registry “connection test” is read-only.
TestConnectionAsync/CheckHealthAsyncprobeGET /v2/(+ token handshake) and at mostGET /v2/_catalog. They never verify the credential can pull the target repo, let alone push. “Connection test passed” therefore says nothing about attach capability. (src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/DockerRegistryConnectorPlugin.cs) - OCI publish failure is swallowed. On 401/403/network,
SbomOciPublishStageExecutorlogs a warning and returns; the scan still completes. (src/Scanner/StellaOps.Scanner.Worker/Processing/SbomOciPublishStageExecutor.cs) - Readiness conflates “generated” with “attached.”
SbomReadinessServicereturnsreadythe moment an internalArtifactBomRowexists — it cannot distinguish “SBOM exists internally” from “SBOM attached to the registry image,” so the ReleaseOrchestrator gate passes even when nothing was attached. - No retry, no alert, no UI signal, no drift detection. Integration health is one-shot at create; revoked write access is never noticed.
This is exactly how the 2026-06 customer predeploy reported green while attaching nothing: registry.example.com returned 401, the slice fell back to partner-registry, and a push (which needs write) was never validated or surfaced.
The platform is offline / air-gap-first. Customer registries are frequently pull-only mirrors; provenance must not depend on the ability to write back to them.
Decision
- The internal OCI metadata registry is the system of record. Every SBOM/reachability referrer is written to a Stella-owned, content-addressed store (
scanner.oci_referrers+oci_blobsover the existingIArtifactObjectStore), keyed by(tenant, registry host, repo path, subject image digest). It is written first and always; metadata is never lost. Stella serves it through an OCI-Referrers-compatible read API (GET /v2/{repo}/referrers/{digest}, plus manifest/blob GET and a native by-image lookup). - Upstream attachment to an external registry is per-registry opt-in and fails hard. A registry integration carries
publishOciMetadata(default false). When on, Stella verifies write capability at add/test time (non-destructivePOST blobs/uploads/+DELETE) and, at publish time, any persistent push failure is a surfaced hard error (the scan stage fails) — never a silent skip. When off, Stella never pushes externally; internal-only is the normal, healthy state. - Attachment is a first-class state distinct from SBOM generation:
not_attempted | attached_internal | attached_external | publish_failed, propagated to the readiness probe (additive field) and optionally to the RO gate (RequireExternalAttachment, default false). - Failures are durable and visible. Failed external pushes retry with backoff, dead-letter on exhaustion, drain back to
attached_externalwhen write access returns, and raise Notify events (sbom.publish.failed,registry.write-access-lost,registry.write-access-restored) plus UI chips/banners. - Co-produce at build time. When Stella builds and publishes the image itself (EPF
build.docker), it has the source and push rights — so SBOM and reachability are co-produced and attached as bundled referrers on the same image subject (hybrid/phased; reachability from the live build context yields the genuine non-stub graph). - Signing is opt-in and local. Referrers may carry a DSSE envelope signed via Attestor
FileKeyProvider(local ES256/PS256 keys, no cloud KMS); off by default so no key provisioning is forced.
Consequences
- New persistence:
scanner.oci_referrers+oci_blobs(forward-only migration per ADR-004). No on-disk OCI layout — content-addressed blobs in the object store + a DB index providing the image-digest→referrer reverse map that does not exist today. - Behavior change (scoped): a scan against an opted-in registry that cannot attach now fails instead of passing silently. Default-off preserves today’s behavior for every existing integration. Release-note this.
- Determinism requirement: the internal and remote manifests must serialize and hash identically (same
JsonSerializerOptions, annotation ordering, andICryptoHash/HashPurpose.Interop) so “served by digest” and dedupe hold. SBOM/reachability referrers are therefore digest-addressed (OciArtifactPushRequest.DigestAddressed): pushed by manifest digest (untagged, with the wall-clockorg.opencontainers.image.createdannotation omitted for a stable digest), so re-attach is idempotent andtags/listis not polluted withsbom-cdx-*/reach-cg-*entries that look like images. Verdict attestations keep averdict-<idempotencyKey>tag (caller-controlled key). See the scanner dossier — “How a referrer manifest is addressed”. - Additions: media type
application/vnd.stellaops.reachability.callgraph.v1+json+org.stellaops.reachability.*annotations;IntegrationResponse.publishOciMetadata+ capability fields; additive readiness/gate fields; FE chips/banners/notifications. - Reuse:
OciArtifactPusher,SliceOciManifestBuilder(referrer-with-DSSE template),IArtifactObjectStore, theScmInfoAvailableScanTriggerSCM path,EpssIngestJob(retry pattern),HttpReleaseEvidencePublisher(Notify pattern), AttestorIAttestationSigningService/FileKeyProvider. - Air-gap: internal sink writes regardless of connectivity; external push + notifier are opt-in/disable-able; all signing local. No cloud-managed services introduced.
- Adjacent work: complements
SPRINT_20260602_002_SbomService_registry_source_cdx_referrers.md; this ADR generalizes referrer publishing to an internal-first, fail-hard, attachment-stateful model and adds reachability + build-time co-production.
Implementation status
Worker path (sprints 20260604_013–018, shipped): internal store scanner.oci_referrers + oci_blobs over IArtifactObjectStore; LocalOciReferrerSink (always-first) + RemoteOciReferrerSink + DualWriteOciReferrerCoordinator (fail-hard external); OCI-Distribution read API OciReferrersReadEndpoints (GET /v2/{repo}/referrers|manifests|blobs/{digest}); attachment-state machine + SbomReadinessService; retry/dead-letter (OciReferrerRetryJob); per-registry publishOciMetadata opt-in + write-probe; reachability referrers + opt-in DSSE.
CLI/CI + retention + gate sourcing (sprint 20260622_001, shipped 2026-06-22): this closed the gaps where evidence produced outside the worker (the CLI in CI) could not feed the internal store and the gate could not see metadata that lived only on the deployment registry.
- Referrer-ingest endpoint —
POST /api/v1/oci-referrers(Scanner.WebService,ScansWrite, tenant from context) maps the body ontoOciReferrerWriteRequestand drivesIOciReferrerCoordinator.WriteAsync(publishExternally: false). The HTTP write counterpart to the OCI read API. (OciReferrerIngestEndpoints/OciReferrerIngestService.) - CLI internal upload —
stella sbom attach --stella-uri <scanner>(envSTELLAOPS_STELLA_URI→STELLAOPS_BACKEND_URL): when a backend token is resolvable, POSTs the composed SBOM + reachability referrers to the ingest endpoint, making the internal store the system of record even for GitLab-CR-hosted images. Additive — the existing externalSTELLA_OCI_REGISTRY_*push is unchanged; an internal-upload failure surfaces (non-zero) but never blocks a successful external push. - Retention / auto-clean —
OciReferrerRetentionJob(configScanner:OciReferrerRetention) prunes age-expired, not-in-use referrers + orphaned manifest blobs. Conservative by construction:Enabled=falseandDryRun=trueby default; deletion requires age-expiry on BOTH timestamps AND anIReferrerInUseProbepositiveNo(defaultAlwaysUnknownReferrerInUseProbe⇒ Unknown ⇒ keep) AND dry-run off — so nothing is deleted until an operator explicitly enables, clears dry-run, and wires a real in-use probe. This is the Stella-controlled, referrer-aware alternative to GitLab CR’s tag-based cleanup/GC, which can orphan referrer metadata. - Gate sourcing — internal-first, then deployment registry —
SbomReadinessGateEvaluatorreads the internal store first; only for theabsent/no-attachment case (optionExternalRegistryFallback, default on) it reads the component’s deployment registry referrers viaOciReferrerReadClient(IExternalRegistryReferrerProbe). Fail-safe: never invoked for afailed/DENY state, any probe error/timeout keeps the internal verdict, and it can only upgrade WAITING→PASS — never DENY→PASS. Promote-on-read is intentionally skipped (external bytes are not trusted for any verdict; presence of the SBOM referrer satisfies readiness). - Release Orchestrator deployment-decision referrers (sprint 20260623_001, live-proven 2026-06-24) — connected Release Orchestrator profiles write signed deployment-decision referrers to Scanner’s
POST /api/v1/oci-referrersendpoint and read them back through Scanner’s/v2/{repo}/referrers/{digest}OCI surface. The runtime path uses the canonicalX-StellaOps-TenantIdtenant header plus a tenant-scopedstellaops-release-dispatchservice token carryingscanner:read scanner:scan scanner:write; local bypass networks remain a dev convenience, not the proof boundary. Evidence:docs/qa/feature-checks/runs/2026-06-24-connected-verdict-travel-live/.
Still design-only: build-time co-production bundling (sprint 20260604_017) and optional SBOM-referrer DSSE signing (reuses the reachability signer). Layer/config blob GC in the retention sweep is deferred (only orphan manifest blobs are reclaimed).
