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:
registry-token(src/Registry/StellaOps.Registry.TokenService) issues the registry bearer tokens and hosts the plan-administration API. It is described in the first half of this file.registry-web(src/Registry/StellaOps.Registry.WebService) is the Stella Registry data plane, described under The data plane (registry-web).
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:
- Token exchange:
GET /token(AllowAnonymous; theregistry.token.issuepolicy is evaluated for callers that present credentials). - Plan administration:
/api/admin/plans/*(gated by theregistry.adminpolicy/scope). - Operational endpoints:
GET /healthz(liveness),GET /openapi/v1.json(anonymous OpenAPI document), and a build-info endpoint.
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
- Validate caller identity using Authority-issued access tokens (
AddStellaOpsResourceServerAuthentication). - Authorize requested registry scopes against the configured/persisted plan catalogue.
- Deny issuance for revoked licences and for disabled or unknown plans.
- Optionally mint pull-only tokens for callers presenting no credentials, when
AnonymousPlanis set (off by default). - Mint a Docker-registry-compatible JWT with an
accessclaim covering the permitted repository actions. - Provide an admin API for plan CRUD, dry-run validation, and audit history (authorized via the
registry.adminscope). - Emit deterministic observability signals (metrics, traces, structured logs) and unified audit events for audits and ops.
Runtime components
Minimal API host
- Project:
src/Registry/StellaOps.Registry.TokenService - Router service name:
registry-token(registered with the Stella Router microservice integration). - Endpoints:
GET /token(authorized viaregistry.token.issue)GET /api/admin/plansand related plan-admin routes (authorized viaregistry.admin)GET /healthz(unauthenticated liveness)GET /openapi/v1.json(anonymous OpenAPI discovery document for the gateway aggregator)- Build-info endpoint (image provenance for the operator verify aggregator)
Cross-cutting integrations
- Air-gap egress policy (
AddAirGapEgressPolicy) consistent with the offline-first posture. - Tenant services + tenant middleware (
AddStellaOpsTenantServices/UseStellaOpsTenantMiddleware). - Localization + embedded translation bundles (
AddStellaOpsLocalization/AddTranslationBundle); admin error messages are localized via translation keys (e.g.registry.error.plan_not_found). - CORS (
AddStellaOpsCors). - Unified audit emission to the Timeline service (
AddAuditEmission). - OpenTelemetry metrics, runtime instrumentation, and ASP.NET Core / HttpClient tracing (
AddStellaOpsTelemetry).
Auth integration
- Resource server validation is configured from
RegistryTokenService:Authority(issuer, optionalMetadataAddress,RequireHttpsMetadata, audiences). The Authority issuer must be an absolute URI; HTTPS is required unless the issuer is loopback orRequireHttpsMetadatais disabled. - Authorization policies:
registry.token.issue— required to callGET /token. Required scopes default toregistry.token.issue(configurable viaRegistryTokenService:Authority:RequiredScopes).registry.admin— required to call the plan-admin endpoints. Backed by theStellaOpsScopes.RegistryAdmin(registry.admin) scope.
- Both scopes are defined in
StellaOps.Auth.Abstractions/StellaOpsScopes.cs:StellaOpsScopes.RegistryTokenIssueandStellaOpsScopes.RegistryAdmin.
Plan registry (token authorization rules)
- Authority assigns a client plan through the optional client property and emits it as
stellaops:planon client-credentials tokens. The configuredDefaultPlanapplies only when that claim is absent or blank. - A non-blank claimed plan is authoritative: if the named enabled plan cannot be resolved, authorization returns
plan_unknownand never retries throughDefaultPlan. This pins assignment/enforcement drift as a loud refusal. - Licence revocation uses the
stellaops:licenseclaim and configuredRevokedLicenses(normalized lower-case). - The plan registry resolves the plan either from the durable
IPlanRuleStore(canonical in production) or from the statically configuredPlanslist when no store is wired (test/in-memory hosts only). - A resolved plan that is
enabled = falseis rejected (plan_disabled); an unknown plan name is rejected (plan_unknown). Theenabledflag only exists on durable (PostgresPlanRuleStore) plan rules — the statically configuredPlanRulemodel (RegistryTokenServiceOptions.PlanRule) has noenabledfield, soplan_disabledcan only arise on the durable path. - Plan rules match repositories by wildcard pattern (
*→.*, anchored, case-insensitive) and authorize a request only if every requested action is a subset of the matched repository rule’s allowed actions. - Statically configured repository rules have no default actions: a rule without
Actionsfails validation at startup, so a grant never widens implicitly. - Anonymous issuance (
AnonymousPlan, unset by default): a caller with noAuthorizationheader receives a token carrying only the anonymous plan’s grants, narrowed topull(RegistryTokenServiceOptions.AnonymousActions). Requests outside the plan are dropped from theaccessclaim rather than refused. Startup fails if a static anonymous plan grants any action other thanpull; narrowing is applied again at mint time for durable plans. A caller that presents an invalidAuthorizationheader is not treated as anonymous and receives401.
Plan administration API (/api/admin/plans)
- All routes require the
registry.adminpolicy. Routes:GET /api/admin/plans— list plan rules (ordered by name).GET /api/admin/plans/{planId}— get a plan by ID (404if missing).POST /api/admin/plans— create a plan (201;400invalid;409name conflict).PUT /api/admin/plans/{planId}— update a plan; requiresversionfor optimistic concurrency (200;400;404;409version/name conflict).DELETE /api/admin/plans/{planId}— delete a plan (204;404).POST /api/admin/plans/validate— dry-run validation; optionally evaluatestestScopesagainst the rules without persisting.GET /api/admin/plans/audit— paginated audit history (optionalplanIdfilter;page,pageSizeclamped to 1…100).
- The actor recorded in audit entries is derived from the caller’s
nameidentifier/sub/nameclaims. PlanValidatorenforces: required plan name (≤128 chars), valid repository patterns (rejects**, control chars, non-compiling regex), valid actions (pull,push,delete,*), and positive rate-limit values; it surfaces warnings for empty repository lists and overlapping patterns.
Plan administration storage
- The admin
IPlanRuleStoreis backed by PostgreSQL (PostgresPlanRuleStore) whenRegistryTokenService:Postgres:ConnectionStringis configured. - Default schema is
registry_token(override viaRegistryTokenService:Postgres:SchemaName). Tables:plan_rules— id, name, description, enabled,repositories/allowlist/rate_limit(jsonb), timestamps, and an integerversionfor optimistic concurrency.allowlistis a reserved, unused physical compatibility column: create/import converge it to[], update does not write it, response models expose it read-only, and authorization never reads it. A case-insensitive unique index enforces unique plan names.plan_audit— append-only change history (action, actor, timestamp, summary, previous/new version).
- Startup migrations run automatically via
AddStartupMigrationsagainst theregistry_tokenschema. Embedded migrations001_initial_schema.sql,002_reserve_plan_allowlist.sqland003_registry_token_retention_classes.sqlconverge both fresh and upgraded databases;002records the reserved-column contract without rewriting the applied baseline, and003(comment-only, unqualified like the rest of the chain) classes both tablessource-of-truth. - The production TokenService assembly does not provide an in-memory
IPlanRuleStore; tests that need one register the test-local store explicitly throughConfigureTestServices. With no Postgres connection string and outside theTestingenvironment, the host fails fast at startup (durable persistence is mandatory in live runtime). - 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.
Token issuer
- Tokens are signed with an RSA private key (algorithm
RS256/SecurityAlgorithms.RsaSha256) loaded fromRegistryTokenService:Signing:KeyPath. The loader (SigningKeyLoader) picks PFX when the path extension is.pfx(optionalKeyPassword; requires an RSA private key) and PEM otherwise (RSA.ImportFromPem). issisSigning:Issuer.auddefaults to the requested registryservicevalue unlessSigning:Audienceis set.kidresolution: an explicitSigning:KeyIdalways wins; otherwise the PFX path defaultskidto the certificate thumbprint, while the PEM path emits nokid.- Token lifetime (
Signing:Lifetime) must be greater than zero and at most 1h (default 5m); values outside this range fail validation at startup.
Observability
- OpenTelemetry metrics (meter
StellaOps.Registry.TokenService):registry_token_issued_total{plan=...}registry_token_rejected_total{reason=...}
- OpenTelemetry tracing for ASP.NET Core and outbound HTTP, plus runtime instrumentation.
- Structured logs via Serilog request logging.
- Unified audit events emitted to the Timeline service.
Request flow
- Docker/OCI client receives a
401from the registry with aWWW-Authenticate: Bearer realm=...,service=...,scope=repository:...challenge. - Client obtains an Authority token with the
registry.token.issuescope. (Without anAuthorizationheader the request takes the anonymous branch, which answers401unlessAnonymousPlanis configured.) - Client calls
GET /token?service=<service>&scope=repository:<repo>:<actions>on Registry Token Service. Thescopequery may repeat or be space-delimited (OAuth2 style); each scope isrepository:<name>:<comma-separated-actions>. - Service validates:
serviceis present (and is allow-listed ifRegistry:AllowedServicesis configured)- requested scopes parse correctly (type must be
repository; at most 3 colon-separated segments; missing actions default topull) - at least one scope is requested
- caller plan/licence claims authorize all requested repository actions
- Service returns a JSON response containing the signed registry token.
Denial paths:
400for malformed requests (servicemissing, invalidscopequery, no scopes requested).401for presented-but-invalid credentials, and for an anonymous caller that cannot be served.503when the issuance policy or the issuer is not provisioned on the host.403for authorization failures — the requested service not allow-listed, orIssueTokenAsyncdenial with reasonlicense_revoked,plan_unknown,plan_disabled, orscope_not_permitted.- Note:
PlanRegistry.AuthorizeAsyncalso defines ano_scopes_requestedreason, but the/tokenendpoint short-circuits empty scope lists with a400before the issuer runs, so that reason is unreachable over HTTP (it can surface only via direct library use ofIssueTokenAsync/AuthorizeAsync).
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:
sub: subject derived fromnameidentifier/client_id/subclaims (falls back toanonymous)jti: per-token unique identifierservice: the requested registry serviceaccess: array of{ type, name, actions[] }entries (type isrepository)- Optional:
stellaops:licensepassthrough claim (for downstream correlation), present only when the caller carries it - Optional:
stellaops:anonymous: trueon anonymously issued tokens (subjectanonymous)
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:
etc/registry-token.yaml(optional; resolved to an absolute path from the content root as../etc/registry-token.yaml, no reload-on-change)- environment variables: the unprefixed provider is re-added after the YAML file, so deployment
RegistryTokenService__*values win over the shipped catalogue (RegistryTokenConfigurationSources);REGISTRY_TOKEN_-prefixed variables are also read
Key sections are defined by RegistryTokenServiceOptions (root section RegistryTokenService):
Authority(Issuer, optionalMetadataAddress,RequireHttpsMetadata,Audiences,RequiredScopes)Signing(Issuer, optionalAudience,KeyPath, optionalKeyPassword, optionalKeyId,Lifetime)Registry(Realm, allow-listedAllowedServices)Plans,DefaultPlan,AnonymousPlan,RevokedLicenses
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):
| Key | Meaning |
|---|---|
Registry:Bearer:Realm | Token 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:Service | This 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:Issuer | Must equal the token service’s Signing:Issuer. |
Registry:Bearer:SigningCertificatePath | Public X.509 certificate of the token-signing key. Missing or unreadable fails startup. |
Registry:Bearer:ClockSkew | Default 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:MaxBlobBytes | Default 1 GiB. |
Registry:Push:SessionTimeout, SessionSweepEnabled, SessionSweepInterval, SessionSweepBatchSize | Upload-session expiry and reclamation (defaults 24 h, true, 1 h, 500). |
Registry:Push:DefaultTagPageSize, MaxTagPageSize | Tag listing page sizes (defaults 100, 1000). |
Authority:ResourceServer:Authority, Authority:ResourceServer:Audiences | Authority 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):
| Endpoint | Route | Status |
|---|---|---|
| end-1 | GET /v2/ | served |
| end-2 / end-3 | GET/HEAD blobs, manifests | served |
| end-4a | POST /v2/<name>/blobs/uploads/ | 202, session start |
| end-4b | POST /v2/<name>/blobs/uploads/?digest= | 201, monolithic single POST |
| end-5 | PATCH /v2/<name>/blobs/uploads/<ref> | 202, or 416 on an out-of-order chunk |
| end-6 | PUT /v2/<name>/blobs/uploads/<ref>?digest= | 201, optional final chunk in the body |
| end-7 | PUT /v2/<name>/manifests/<ref> | 201, OCI-Subject echoed when the manifest has a subject |
| end-8a / end-8b | GET /v2/<name>/tags/list[?n=&last=] | 200, lexical order, Link: rel="next" |
| end-9 / end-10 | DELETE manifest / blob | 405 UNSUPPORTED— deletion is GC policy |
| end-11 | POST /v2/<name>/blobs/uploads/?mount=&from= | 201, or 202 fallback when the mount cannot be satisfied |
| end-12a / end-12b | GET /v2/<name>/referrers/<digest> | 200 with an image index, always — never 404 |
| end-13 | GET /v2/<name>/blobs/uploads/<ref> | 204 with the resumable cursor |
Load-bearing behaviours an integrator should not have to discover:
- Reachability is per repository, not per digest.
registry.blobs/registry.manifestsrecord which digests a given repository may serve, andregistry.tagscarries a composite foreign key on(repository, manifest_digest). One content-addressed bucket backs several trust domains, so a digest-only lookup would let a token scoped to one namespace read another’s bytes. - Cross-repo mount requires
pullon the SOURCE, not onlypushon the target — otherwise end-11 is a cross-namespace read primitive. A mount the caller is not entitled to falls back to an ordinary upload session (the spec’s own fallback) rather than answering 403, so the response is not an existence oracle for the source repository. GETon an upload session is authorized as a push. An in-flight upload is unpublished content.- Upload sessions live in the database (
registry.blob_uploads,registry.blob_upload_chunks), so they survive across replicas; the out-of-order rule is a conditionalUPDATEon the cursor. Staged chunks are verified against their own recorded sha256 before assembly. Idle sessions expire perRegistry:Push:SessionTimeout(default 24 h) and then answerBLOB_UPLOAD_UNKNOWN. - Expired upload sessions are reclaimed by
RegistryUploadSessionSweeper(a hosted service): everyRegistry:Push:SessionSweepInterval(default 1 h) it deletes the sessions whose LAST ACTIVITY is older thanSessionTimeout, their chunk manifests, and then their staged bytes, in batches ofRegistry:Push:SessionSweepBatchSize(default 500).SessionSweepEnabled=falseturns it off. A session still receiving chunks is never reclaimed, however long ago it started. Both tables areoperational-state(P13,005_registry_upload_session_retention.sql); the Doctor checkdoctor.registry.retention.upload-session-backlogreports sessions idle longer than the timeout plus one sweep interval. The repository, manifest, tag, blob and manifest-reference tables aresource-of-truth: only the policy GC removes manifest and blob rows, and nothing schedules GC. - Plan enforcement plugs in at
IRegistryPushAdmission(Security/IRegistryPushAdmission.cs). The token-grant rule is the first registered admission; registration order is evaluation order and the first refusal wins. The pipeline refuses when the admission list is empty and when an admission throws (503) — losing the registrations yields a registry that accepts nothing. Registry:Push:MaxBlobBytes(default 1 GiB) is the enforced blob ceiling; Kestrel’s own body cap is lifted so this is the limit clients actually meet, returned as 413 withSIZE_INVALID.
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)
- Native, not fallback-tag emulation. A registry may emulate referrers by publishing an ordinary tag named
sha256-<hex>; this one never creates that tag, and a test asserts it stays 404. That 404 is how a client tells a native implementation from an emulated one. - Always 200, never 404 — the most consequential detail in the endpoint. The response is an image index that may be empty. A registry that 404s an unknown subject makes every client read “nothing refers to this” as “this registry has no referrers API at all”, after which clients fall back to tag emulation or give up. The suite pins it from three directions: a subject never pushed, a subject whose manifest is missing, and a plain unknown digest.
- The subject is a digest, not a stored object. Nothing is looked up. A client may attach a signature to content this repository has never held, and may keep reading referrers after the subject is deleted; both are legal and both are tested.
OCI-Filters-Appliedis emitted only when a filter was actually applied. Its ABSENCE tells the suite “this registry does not filter”, after which the suite expects the UNFILTERED list — so emitting the header without filtering, or filtering without emitting it, fails in opposite directions.- A malformed digest is the one non-empty-index case: 400
DIGEST_INVALID, because the client asked a question the API cannot parse. An unknown but well-formed digest is a legitimate empty answer.
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:
- Dry-run by default (
RegistryGcOptions.DryRun = true), and a dry run writes the SAME audit rows a real one would — so the audit trail is reviewable before anything is erased. - Every candidate is audited with a reason, not only the collected ones.
registry.gc_auditis append-only. - Reachability is ref-counted over
registry.manifest_referencesedges: retained → tagged → referenced → collectable, in that order. A manifest with referrers is KEPT by default (CollectSubjectsWithReferrers = false), because collecting a subject silently orphans its signatures. - Bytes are erased only when NO repository anywhere still references the digest. One bucket backs several repositories, so a per-repository decision would delete another repository’s layer.
- An image manifest with unaccounted edges stops the sweep rather than proceeding on a partial view of what is reachable.
- Retention pins (
registry.manifests.retained_until) survive a re-push:PutManifestAsync’s upsert deliberately does not touch the column.
The meta-masking facade (registries/<slug>/…)
Deployment status. The facade is implemented and conformance-tested. It is off unless
Registry:Facade:IntegrationsBaseAddressandRegistry:Facade:Tenantare set; the base release bundle and the dev compose estate set neither, and only the optional bundle overlaydevops/release/bundle/docker-compose.demo-registry.ymlenables it. An unconfigured host answersregistries/<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:
- Writes are refused permanently (403
DENIED), not pending anything. “Stella metadata overlays customer images without being pushed into customer registries” cuts both ways — customer content is not pushed into Stella’s either, and the standing 4b DENY refuses customer artifact hosting on the product registry outright. - Reads never fall through to the own store. Content pushed under a facade name before the namespace was reserved is not served; the answer is 404
NAME_UNKNOWN. Both refusals run AFTER the grant check, so neither is an existence oracle for an unauthorized caller.
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.
| Concern | Behaviour |
|---|---|
| Slug → registry | GET /api/v1/integrations/registries/by-slug/{slug} on Integrations (owner API, not a compiled reference). Returns a credential REFERENCE, never material. |
| Unknown vs disabled slug | Indistinguishable from outside — both 404 NAME_UNKNOWN, or the response becomes an oracle for which tenants have which registries connected. |
| Integrations unreachable | Refusal, never a fallback to the own store: an outage must not become namespace confusion. |
| Credentials | authRefUri de-referenced in-process via ISecretProvider under the Registry’s own identity. |
| Tags | 400 DIGEST_INVALID — digest-pinned references only. |
| Manifests | Verified BEFORE a byte is sent; upstream mismatch is 502. |
| Blobs | Streamed with an incremental hash; a mismatch ABORTS the response mid-flight. |
| Referrers | From the meta system of record via Scanner’s owner API, never a DB read. |
| Tag listing | 501 UNSUPPORTED — not part of a digest-pinned pull path. |
Three behaviours worth stating explicitly, because each has a plausible-looking wrong alternative:
- Refusal is never anonymity. Any credential that cannot be resolved stops the fetch entirely. Falling back to an anonymous fetch fails silently in exactly the case that matters — an anonymous read of a PUBLIC upstream succeeds, so a broken credential configuration looks like a working facade until the day it points at a private registry.
- A
builtin://credential reference is UNRESOLVABLE ACROSS SERVICES, and is refused by name. A builtin secret is a row in the OWNING service’s own database (ADR-039 §8.2), so one minted by Integrations would be looked up instellaops_registryand reported as a missing secret — true, and it sends the operator to entirely the wrong place. Facade upstream credentials must live in an external backend (vault/openbao). - An empty referrers index and an unknown answer are different facts. Empty asserts “nothing is attached to this image”. Rendering a Scanner outage as empty would report an unscanned, unsigned image as merely an image with no metadata — silently, on the exact surface used to decide whether it was ever checked. Every transport failure answers 503, including an upstream 404, which on Scanner’s referrers plane means the route was missing rather than the subject.
Trap for whoever next touches Scanner’s referrers producer. Scanner accepts
?artifactType=and filters silently — it emits noOCI-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, revisitRegistryFacadePullThrough.ServeReferrersAsyncrather than assuming pass-through became safe.
Configuration (all off by default; the facade and the meta overlay are separate opt-ins):
| Key | Meaning |
|---|---|
Registry:Facade:IntegrationsBaseAddress | Empty = facade off. No default — a default would point this registry at a host nobody chose. |
Registry:Facade:Tenant | Tenant 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:ScannerBaseAddress | Empty = meta overlay off (referrers answer 501). |
Registry:Facade:ScannerScope | Default 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:Backend | vault / 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
RateLimitDto(maxRequests,windowSeconds) is part of the admin plan model and persisted inplan_rules, but it is not enforced at theGET /tokenissuance path. Treat it as advisory metadata.
References
- Operations/runbook: operations/token-service.md
- Registry topology: registry-topology.md
- Content measurement: operations/content-storage-measurement.md
Evidence
| Claim | Source |
|---|---|
Token service host, /token (anonymous branch, 400/401/403/503), policies registry.token.issue / registry.admin, /healthz, OpenAPI, build-info, router service name registry-token | src/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 narrowing | src/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 guidance | src/Registry/StellaOps.Registry.TokenService/Security/SigningKeyLoader.cs, Security/SigningKeyGuidance.cs |
| Plan admin routes | src/Registry/StellaOps.Registry.TokenService/Admin/PlanAdminEndpoints.cs, Admin/PlanValidator.cs |
Plan store, schema registry_token, startup migrations, first-boot import | src/Registry/StellaOps.Registry.TokenService/Admin/Persistence/RegistryTokenPersistenceExtensions.cs, Admin/PlanCatalogueImporter.cs, src/Registry/__Libraries/StellaOps.Registry.Token.Persistence/Migrations/ |
| Metrics | src/Registry/StellaOps.Registry.TokenService/Observability/RegistryTokenMetrics.cs |
Scopes registry.token.issue, registry.admin | src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs |
| Data-plane host, routes, Doctor route, build-info | src/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 certificate | src/Registry/StellaOps.Registry.WebService/Security/RegistryBearerValidator.cs |
| Push admission pipeline | src/Registry/StellaOps.Registry.WebService/Security/IRegistryPushAdmission.cs |
| Upload-session sweep and Doctor backlog check | src/Registry/StellaOps.Registry.WebService/Storage/RegistryUploadSessionSweeper.cs, Doctor/RegistryUploadSessionBacklogCheck.cs |
| GC policy | src/Registry/StellaOps.Registry.WebService/Gc/RegistryGarbageCollector.cs |
| Facade | src/Registry/StellaOps.Registry.WebService/Facade/, Endpoints/RegistryFacadeNamespace.cs |
Schema registry, STELLAOPS_POSTGRES_REGISTRY_CONNECTION | src/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 |
