Registry — Architecture

Verified against commit 942a18727c (2026-09-26, DOC-2 of SPRINT_20260926_001). Re-verify by reading the evidence paths listed in the Evidence section.

Audience: operators deploying the Stella Registry and integrators wiring Docker/OCI clients or the plan-administration API. Source: src/Registry/. Related: token-service operations runbook, registry topology.

Overview

src/Registry/ is a two-role service family:

The two roles share database stellaops_registry (separate schemas and migration chains) and deliberately share no source edge. The contract between them is a signed token verified against a certificate, which is also what lets them deploy and restart independently. Both are services in the release bundle; registry-web answers on registry.stella-ops.local and stella-registry.stella-ops.local, and the shipped compose files set its bearer service to stella-registry.stella-ops.local (Registry__Bearer__Service).

The token service issues short-lived Docker registry bearer tokens after enforcing plan and licence constraints, and exposes an authenticated Plan Administration API (CRUD, dry-run validation, audit history) for the plan-rule catalogue that drives those decisions.

The token service surface:

Authorization decisions are based on (a) Authority-issued token claims and (b) plan rules read from the durable PostgreSQL store (or, in test hosts, an explicitly registered in-memory store).

Migration resource ownership

The token role loads its startup SQL from StellaOps.Registry.Token.Persistence (Migrations/001_initial_schema.sql, 002_reserve_plan_allowlist.sql, 003_registry_token_retention_classes.sql) under module name Registry.TokenService. The data-plane StellaOps.Registry.Persistence chain (module schema registry) is separate.

Primary responsibilities

Runtime components

Minimal API host

Cross-cutting integrations

Auth integration

Plan registry (token authorization rules)

Plan administration API (/api/admin/plans)

Plan administration storage

Token issuer

Observability

Request flow

  1. Docker/OCI client receives a 401 from the registry with a WWW-Authenticate: Bearer realm=...,service=...,scope=repository:... challenge.
  2. Client obtains an Authority token with the registry.token.issue scope. (Without an Authorization header the request takes the anonymous branch, which answers 401 unless AnonymousPlan is configured.)
  3. Client calls GET /token?service=<service>&scope=repository:<repo>:<actions> on Registry Token Service. The scope query may repeat or be space-delimited (OAuth2 style); each scope is repository:<name>:<comma-separated-actions>.
  4. Service validates:
    • service is present (and is allow-listed if Registry:AllowedServices is configured)
    • requested scopes parse correctly (type must be repository; at most 3 colon-separated segments; missing actions default to pull)
    • at least one scope is requested
    • caller plan/licence claims authorize all requested repository actions
  5. Service returns a JSON response containing the signed registry token.

Denial paths:

Token shape (Docker registry compatible)

The JWT header carries alg = RS256 and, when a key id is resolved (see Token issuer), a kid.

The issued JWT payload includes registered claims iss, aud, nbf, iat, exp, plus:

The GET /token HTTP response is a JSON envelope:

{
  "token": "<jwt>",
  "expires_in": 300,
  "issued_at": "<ISO-8601 UTC>",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

Configuration

Configuration is loaded from:

Key sections are defined by RegistryTokenServiceOptions (root section RegistryTokenService):

Durable plan-rule persistence is configured separately under RegistryTokenService:Postgres (at minimum ConnectionString; optional SchemaName, defaulting to registry_token). When Postgres persistence is configured, the host may start without any statically configured Plans; persisted plan rules become the canonical source for admin CRUD and token issuance. Without a connection string, the host requires at least one statically configured plan and refuses to start in non-test environments.

The data plane (registry-web)

Source: src/Registry/StellaOps.Registry.WebService. Family database stellaops_registry (its own schema registry and migration chain, fail-closed on STELLAOPS_POSTGRES_REGISTRY_CONNECTION), own object-store bucket. It validates the bearer tokens registry-token mints — issuer, service audience, access claim, token lifetime — against the token service’s public X.509 certificate, and never references that project in source. The certificate’s own validity period is enforced: a lapsed signing certificate stops token acceptance. Health endpoints are GET /healthz and GET /readyz (readiness round-trips the registry schema).

Configuration (section Registry, RegistryWebServiceOptions; each value is validated at startup):

KeyMeaning
Registry:Bearer:RealmToken endpoint advertised in the 401 challenge (absolute URI). The shipped compose files point it at the gateway route https://stella-ops.local/api/registry/token.
Registry:Bearer:ServiceThis registry’s bearer service identity (token audience). Compose default stella-registry.stella-ops.local; it must be listed in the token service’s Registry:AllowedServices.
Registry:Bearer:IssuerMust equal the token service’s Signing:Issuer.
Registry:Bearer:SigningCertificatePathPublic X.509 certificate of the token-signing key. Missing or unreadable fails startup.
Registry:Bearer:ClockSkewDefault 30 s, at most 5 minutes.
Registry:Storage:{Bucket,Prefix,ServiceUrl,Region,AccessKeyId,SecretAccessKey}Registry-owned S3-compatible bucket (path-style addressing). Prefix defaults to registry, Region to us-east-1.
Registry:Push:MaxBlobBytesDefault 1 GiB.
Registry:Push:SessionTimeout, SessionSweepEnabled, SessionSweepInterval, SessionSweepBatchSizeUpload-session expiry and reclamation (defaults 24 h, true, 1 h, 500).
Registry:Push:DefaultTagPageSize, MaxTagPageSizeTag listing page sizes (defaults 100, 1000).
Authority:ResourceServer:Authority, Authority:ResourceServer:AudiencesAuthority authentication for the Doctor route only.

The operational GET /doctor/registry-web/checks surface uses Authority ops.health authorization (policy registry.ops.health) and reports Registry’s own database and configured content-bucket size. It does not change the distribution token contract. See the content measurement procedure for configuration, verification status and measurement limits.

Distribution-spec surface, named by the spec’s own endpoint identifiers (which is also how the official conformance suite groups its cases):

EndpointRouteStatus
end-1GET /v2/served
end-2 / end-3GET/HEAD blobs, manifestsserved
end-4aPOST /v2/<name>/blobs/uploads/202, session start
end-4bPOST /v2/<name>/blobs/uploads/?digest=201, monolithic single POST
end-5PATCH /v2/<name>/blobs/uploads/<ref>202, or 416 on an out-of-order chunk
end-6PUT /v2/<name>/blobs/uploads/<ref>?digest=201, optional final chunk in the body
end-7PUT /v2/<name>/manifests/<ref>201, OCI-Subject echoed when the manifest has a subject
end-8a / end-8bGET /v2/<name>/tags/list[?n=&last=]200, lexical order, Link: rel="next"
end-9 / end-10DELETE manifest / blob405 UNSUPPORTED— deletion is GC policy
end-11POST /v2/<name>/blobs/uploads/?mount=&from=201, or 202 fallback when the mount cannot be satisfied
end-12a / end-12bGET /v2/<name>/referrers/<digest>200 with an image index, always — never 404
end-13GET /v2/<name>/blobs/uploads/<ref>204 with the resumable cursor

Load-bearing behaviours an integrator should not have to discover:

Conformance suite. The OCI distribution-spec conformance suite is vendored at pinned v1.1.1 under tools/conformance/oci-distribution-spec/ (dev/test only — never distributed; the non-release guard is VendoredConformanceSuiteIsolationConformanceTests). The Pull, Push and Referrers categories are the target; Content Management is disabled rather than failing, because end-9/end-10 answer the spec’s 405 UNSUPPORTED (deletion is GC policy, below, not a client-callable route). Re-run the suite for a current result; a recorded pass count is not maintained here.

Referrers (end-12a / end-12b)

Deletion is GC policy, not a route

end-9/end-10 answer 405 permanently. Reclamation runs as a sweep (Gc/RegistryGarbageCollector.cs), and the shape is deliberate:

The meta-masking facade (registries/<slug>/…)

Deployment status. The facade is implemented and conformance-tested. It is off unless Registry:Facade:IntegrationsBaseAddress and Registry:Facade:Tenant are set; the base release bundle and the dev compose estate set neither, and only the optional bundle overlay devops/release/bundle/docker-compose.demo-registry.yml enables it. An unconfigured host answers registries/<slug> with the “meta-masking facade is not configured” refusal.

ADR-041 D3 gives registries/<slug>/<repo> to a pull-through of a tenant’s own registry, with Stella’s metadata overlaid on top. Two rules make it more than a naming convention:

It relays; it does not cache. The upstream leg is OciDistributionClient, the read half of StellaOps.Oci.Core — deliberately NOT OciPullThrough, which is a store primitive right for the agent’s loopback cache and wrong here: caching would write customer bytes into Stella’s own bucket under the very namespace reserved to prevent that, arriving through the server instead of a client PUT. A caching tier, if ever wanted, is an explicit custody decision.

ConcernBehaviour
Slug → registryGET /api/v1/integrations/registries/by-slug/{slug} on Integrations (owner API, not a compiled reference). Returns a credential REFERENCE, never material.
Unknown vs disabled slugIndistinguishable from outside — both 404 NAME_UNKNOWN, or the response becomes an oracle for which tenants have which registries connected.
Integrations unreachableRefusal, never a fallback to the own store: an outage must not become namespace confusion.
CredentialsauthRefUri de-referenced in-process via ISecretProvider under the Registry’s own identity.
Tags400 DIGEST_INVALID — digest-pinned references only.
ManifestsVerified BEFORE a byte is sent; upstream mismatch is 502.
BlobsStreamed with an incremental hash; a mismatch ABORTS the response mid-flight.
ReferrersFrom the meta system of record via Scanner’s owner API, never a DB read.
Tag listing501 UNSUPPORTED — not part of a digest-pinned pull path.

Three behaviours worth stating explicitly, because each has a plausible-looking wrong alternative:

Trap for whoever next touches Scanner’s referrers producer. Scanner accepts ?artifactType= and filters silently — it emits no OCI-Filters-Applied. The facade therefore fetches UNFILTERED and filters locally, because a filtered body without that header is wrong in the one way the conformance suite checks. If the producer ever starts emitting the header, revisit RegistryFacadePullThrough.ServeReferrersAsync rather than assuming pass-through became safe.

Configuration (all off by default; the facade and the meta overlay are separate opt-ins):

KeyMeaning
Registry:Facade:IntegrationsBaseAddressEmpty = facade off. No default — a default would point this registry at a host nobody chose.
Registry:Facade:TenantTenant whose integration records resolve. Required when the facade is on; a blank tenant with a set address is rejected at startup because it looks configured and resolves nothing.
Registry:Facade:ScannerBaseAddressEmpty = meta overlay off (referrers answer 501).
Registry:Facade:ScannerScopeDefault scanner.scans.read.
Registry:Facade:Authority:{Issuer,ClientId,ClientSecret}Service-token identity for the meta overlay. The Registry talks to Scanner as ITSELF — the caller’s registry bearer is a different issuer’s token whose repository-shaped grants mean nothing there.
Crypto:SecretProvider:Backendvault / openbao for facade upstream credentials (see the builtin:// note above).

Known precision limit. Scanner’s referrers row is keyed on (tenant, registry_host, repo_path, subject_digest) but its HTTP contract exposes only repo path and digest, so two integrated registries in one tenant sharing a repository path and a subject digest see the same metadata. That is defensible — the metadata is about content, and an identical digest is the identical image — but it is a precision limit, not a designed guarantee. Making it exact needs a registryHost filter on the producer route.

Not yet implemented

References

Evidence

ClaimSource
Token service host, /token (anonymous branch, 400/401/403/503), policies registry.token.issue / registry.admin, /healthz, OpenAPI, build-info, router service name registry-tokensrc/Registry/StellaOps.Registry.TokenService/Program.cs
Options, defaults and validation (Authority, Signing, Registry, Plans, DefaultPlan, AnonymousPlan, AnonymousActions, no default actions)src/Registry/StellaOps.Registry.TokenService/RegistryTokenServiceOptions.cs
Configuration source order (absolute catalogue path, environment re-applied)src/Registry/StellaOps.Registry.TokenService/RegistryTokenConfigurationSources.cs
Plan resolution, plan_unknown / plan_disabled / scope_not_permitted / anonymous_disabled, anonymous narrowingsrc/Registry/StellaOps.Registry.TokenService/PlanRegistry.cs
Scope parsing (repository type, at most 3 segments, default pull)src/Registry/StellaOps.Registry.TokenService/RegistryScopeParser.cs
Token claims (sub, jti, service, access, stellaops:license, stellaops:anonymous)src/Registry/StellaOps.Registry.TokenService/RegistryTokenIssuer.cs
Signing key loading (PEM/PFX, kid), fail-closed guidancesrc/Registry/StellaOps.Registry.TokenService/Security/SigningKeyLoader.cs, Security/SigningKeyGuidance.cs
Plan admin routessrc/Registry/StellaOps.Registry.TokenService/Admin/PlanAdminEndpoints.cs, Admin/PlanValidator.cs
Plan store, schema registry_token, startup migrations, first-boot importsrc/Registry/StellaOps.Registry.TokenService/Admin/Persistence/RegistryTokenPersistenceExtensions.cs, Admin/PlanCatalogueImporter.cs, src/Registry/__Libraries/StellaOps.Registry.Token.Persistence/Migrations/
Metricssrc/Registry/StellaOps.Registry.TokenService/Observability/RegistryTokenMetrics.cs
Scopes registry.token.issue, registry.adminsrc/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs
Data-plane host, routes, Doctor route, build-infosrc/Registry/StellaOps.Registry.WebService/Program.cs, Endpoints/RegistryReadEndpoints.cs, Endpoints/RegistryPushEndpoints.cs, Endpoints/RegistryHealthEndpoints.cs, Doctor/RegistryDoctorHosting.cs
Data-plane options (Registry:Bearer, Storage, Push, Facade)src/Registry/StellaOps.Registry.WebService/RegistryWebServiceOptions.cs
Bearer validation against the public certificatesrc/Registry/StellaOps.Registry.WebService/Security/RegistryBearerValidator.cs
Push admission pipelinesrc/Registry/StellaOps.Registry.WebService/Security/IRegistryPushAdmission.cs
Upload-session sweep and Doctor backlog checksrc/Registry/StellaOps.Registry.WebService/Storage/RegistryUploadSessionSweeper.cs, Doctor/RegistryUploadSessionBacklogCheck.cs
GC policysrc/Registry/StellaOps.Registry.WebService/Gc/RegistryGarbageCollector.cs
Facadesrc/Registry/StellaOps.Registry.WebService/Facade/, Endpoints/RegistryFacadeNamespace.cs
Schema registry, STELLAOPS_POSTGRES_REGISTRY_CONNECTIONsrc/Registry/__Libraries/StellaOps.Registry.Persistence/Extensions/RegistryPersistenceExtensions.cs, src/Registry/__Libraries/StellaOps.Registry.Persistence/Migrations/
Deployment wiring (registry-token, registry-web, aliases, env)devops/release/bundle/docker-compose.yml, devops/compose/docker-compose.stella-services.yml, devops/release/bundle/docker-compose.demo-registry.yml
Gateway realm route ^/api/registry/token(.*)devops/compose/router-gateway-local.json, devops/release/bundle/config/router-gateway.json