Registry topology — the one page of truth

Binding records: ADR-041 (owner ruling 2026-08-03), which supersedes the retention/boundary framing of ADR-040 while carrying its inventory and D2/D5/D6 rulings forward. Every registry-shaped thing in the estate is listed here with its single role, auth posture, and who talks to it. If reality drifts from this page, one of them is a defect — fix the drift or fix this page (AGENTS.md §2.10), never let them disagree silently. Program state: docs/implplan/SPRINT_20260803_004_Registry_stella_registry_service_program.md.

The rule of roles

Every registry-shaped thing

SurfaceRole (exactly one)Auth postureWho talks to it
registry.stella-ops.orgVendor distribution — release/CLI images (separate vendor infrastructure: nginx + registry:3.0.0)anonymous pull; htpasswd pushcustomer estates pulling release images by digest; vendor CI publishing
mirrors.stella-ops.orgVendor mirror — compact offline vuln-dbanonymous HTTPSCLI / offline-kit downloads
Stella Registry (src/Registry/, deployables registry-web + registry-token; LIVE AND HOLDING BOTH NAMES since the SR-6 cutover, 2026-08-21: stella-registry.stella-ops.local (its own identity, and the bearer service it challenges with) AND registry.stella-ops.local (inherited from the retired zot), both on 127.1.0.49 — see the build-state note below)The estate’s product registry: Stella artifact store (stella-ops/plugins/*, stella-ops/enterprise/*, stella-ops/public/**) + meta-masking facade over per-installation integrated registries (registries/<slug>/… pull-through with referrers overlaid from the meta SoR) + backing of stella+oci://registrybearer tokens from its own token service (plans); anonymous pull only on stella-ops/public/** and only where the operator opts in (anonymousPlan); all pushes authenticateddeploy agents and estates pulling masked/plugin content; stella plugin sign-release publish; CLI; the release bundle
Registry token service (src/Registry/StellaOps.Registry.TokenService, container stellaops-registry-token)Auth plane for the Stella Registry (interim: for the stella-infra zot). Mints 5-min plan-scoped registry JWTs; plan admin API; plan_rules is the live grant storecallers authenticate via Authority (registry.token.issue); admin via registry.admindocker/oras clients following the 401 realm; operators/CI via the gateway route ^/api/registry/token
Meta referrer plane (Scanner WebService read-only /v2/{repo}/referrers|manifests|blobs/{digest} over scanner.oci_referrers + object store)System of Record for SBOM/reachability/decision referrers (stella+oci://meta). Not a layer registry, not a registry daemonservice identity via gateway/owner APIscan pipeline (ingest), ReleaseOrchestrator decision ingest, the Stella Registry facade (read), UI/evidence surfaces
StellaOps.Agent.Registry (in-tree C#, loopback on deploy targets, default OFF)Agent-side pull-through / air-gap masker (stella+oci://bundle, ADR-033 D6); seed of the shared OCI core library (ADR-041 R1)agent trust; loopback onlyDocker daemons on no-direct-registry deploy targets
Lab zot registry.stella-ops.local = 127.1.1.5 (stella-infra, container stellaops-registry) RETIRED 2026-08-21 at the SR-6 cutoverINTERIM Stella-artifact host — the container is removed, its compose key is gone from docker-compose.stella-infra.yml, and the registry.stella-ops.local alias moved to registry-web. Content migration was a proven no-op: volume compose_registry-data held exactly one file, cache.db, with zero repository directories and zero blobs, measured twice. The volume is retained on disk (OD-4) as the rollbacknothing; the lab’s only zot is now the demo-integration simulator
Demo-integration zot customer-registry.stella-ops.local = 127.1.2.9 (integrations family, container stellaops-customer-registry)THE demo integration — the simulated customer registry, peer of jenkins/nexus/gitlab; scan-target and fixture host; referrers-capable (proven 7/7). The lab’s only zot after R6none, deliberately — it plays “a customer’s registry”; open push/pull is the fixture pointfixture/e2e/CI lanes; Scanner scan-target plane; registries/<slug> integration registrations; EF5/QA provisioning
docker-registry.stella-ops.local / oci-registry.stella-ops.local = 127.1.2.5 (registry:2.8)Connector-surface test fixture (ADR-040 D6). No Referrers API — measured, which is why it is not the simulatornone / per-fixture htpasswd variantsIntegrations connector tests; auto-SBOM CI lane
CI pull-through proxy cache (registry:2.8.3 proxy)Upstream image cache for CI — nothing Stella-owned lives herenone.gitea workflows resolving upstream images
Release bundle registryToday: zot as the evaluator on-ramp (drifted framing). End state (ADR-041 R7): the Stella Registry, plus an optional demo-overlay zot registered as a demo integration (owner decision OD-1)Stella Registry: bearer (as above). Demo overlay: open, off by defaultevaluators (“push a test image, scan it” — through the demo integration, exercising the product plane)

Stella Registry build state (measured 2026-08-11)

The product registry now has a data plane that exists and boots, and it is not serving anything in the lab. Both halves matter: the row above describes the end state, this note describes today.

Plan binding RATIFIED and proven end-to-end — 2026-08-24

The owner ratified option (a) — Authority-issued plan claims — after a live end-to-end measurement (register item 10 was ruled to (a) on 2026-08-19; SR-10 implemented it; INT-5 closed on today’s proof). The measured chain, reproducible with stellaops-plugin-publisher credentials:

  1. Mint: client_credentials at Authority → token carries stellaops:plan: plugin-publisher, aud [registry, stellaops] (both audiences are load-bearing — see the standard.yaml note).
  2. Exchange: front-door /api/registry/token?service=stella-registry.stella-ops.local&scope=… → registry bearer granting pull,push on stella-ops/plugins/*. The service= identity MUST be stella-registry.stella-ops.local; the retired zot identity 401s in a way that reads as a scope failure (the KEY-2R gotcha, third sighting).
  3. Pull: manifest GET → 200, body re-hashes byte-exact to the requested digest; anonymous → 401 with the correct realm.

plan_rules.allowlist disposition (SR-10’s no-third-state mandate): reserved-unused, recorded in migration 002_reserve_plan_allowlist.sql as a COMMENT ON COLUMN and in src/Registry/AGENTS.md; no admin endpoint, validator, or importer writes it. The unknown-claim refusal is pinned by PlanRegistryTests with a positive control (community WOULD grant the probe request; an unknown explicit claim must refuse plan_unknown, never fall through to defaultPlan).

The public name is served again (fixed 2026-08-24). registry.stella-ops.local (hosts → 127.1.1.5) had NO listener from the zot retirement until today — SR-6 moved the name but the IP publish never followed. registry-web now publishes 127.1.1.5:80 beside 127.1.0.49:80 (base compose), verified healthy with the 401 challenge and a byte-exact pull through the name. A probe of the name during the outage hit the Console SPA fallback, whose 200-with-HTML reads as “anonymous pull allowed” — check content-type before believing a 200 here.

SR-6 CUTOVER EXECUTED — 2026-08-21

The Stella Registry now holds registry.stella-ops.local. The interim zot is gone. Both halves were proven, not assumed.

The forcing function, on the real client path. Authority mint → exchange at the ADVERTISED realm (https://stella-ops.local/api/registry/token) → signed publish → digest-pinned pull:

access = [{"type":"repository","name":"stella-ops/plugins/sr6-forcing-function",
           "actions":["pull","push"]}]
published @sha256:591d56ac1d06647d8d0bffd6d727fe0322d37c3c2b6fe42c7056586cedb2a384
digest-pinned GET  with a plan-scoped token -> 200 (body re-hashes to the requested digest)
digest-pinned GET  with no token            -> 401

The push grant is what makes this proof of BINDING rather than of a 200: the live community default plan grants pull-only on stella-ops/public/* and registries/* and nothing on the plugin namespace, so a defaultPlan fallback cannot produce that claim.

The alias flip, re-proven through the INHERITED name after the flip — in-network against registry.stella-ops.local:8080, the name that served zot an hour earlier: 200 with a plan-scoped token, 401 without, body sha256 = 591d56ac1d06647d8d0bffd6d727fe0322d37c3c2b6fe42c7056586cedb2a384, exactly the requested digest.

Two traps worth keeping for anyone operating this:

Rollback, unused and still intact: compose_registry-data retained on disk; stellaops/registry-web:rollback-d212aa07 and registry-token:rollback-aa329e4b resolve; Authority’s previous tag is named in docker-compose.authority-image-pin.override.yml (note its 43 → 44 migration makes that one non-symmetric).

Stella Registry deployment state (measured 2026-08-16, SR-6 window)

registry-web is up, healthy and enforcing in the live lab — and the registry.stella-ops.local alias did not move. Both halves are the record.

FactMeasured value
Container / imagestellaops-registry-web / stellaops/registry-web:dev, built from d212aa07
Bind / alias127.1.0.49:80 → 8080; network alias stella-registry.stella-ops.local only
Databasestellaops_registry, role registry, 3 migrations applied on first boot
Object storeSeaweedFS bucket registry-content (created for this window)
IsolationREVOKE CONNECT … FROM PUBLIC applied; sibling role policy refused with permission denied for database "stellaops_registry"
GET /v2/ anonymous401 + WWW-Authenticate: Bearer realm="https://stella-ops.local/api/registry/token",service="stella-registry.stella-ops.local"
anonymous read/write on stella-ops/**401 on both — including stella-ops/public/**, since no anonymousPlan is configured

The SR-6 flip was REFUSED at its gate. The live forcing function (publish a plugin bundle, then pull it by digest) cannot run in this estate, and the reasons are identity-plane, not deployment-plane. Four independent blockers, each measured:

  1. registry.token.issue is granted to no client. It is in the scope catalog, but a password grant for it answers 400 invalid_scope — “Scope ‘registry.token.issue’ is not allowed for this client” (stellaops-cli; a control request for registry:read returns 200, so the grant chain itself is healthy). No identity in the lab can reach the realm authenticated.
  2. The shipped plan catalogue has never loaded, so Plans binds empty and DefaultPlan is null. CLOSED later the same day — a code defect, not a mount; see the fix note below.
  3. registry_token.plan_rules and plan_audit are still 0 rows. CLOSED — plan_rules now holds the 4 shipped plans, imported on first boot once (2) was fixed. The underlying rule is unchanged and still worth knowing: with RegistryTokenService:Postgres:ConnectionString set there is no YAML fallback, so an empty durable store means every token request answers plan_unknown → 403.
  4. Nothing can bind a caller to plugin-publisher. The plan is read only from the stellaops:plan claim (PlanRegistry.cs:69, RegistryTokenIssuer.cs:64) with DefaultPlan as the sole fallback, and Authority has no mechanism to emit that claim — it appears in test code only, and client properties feed hard-coded serviceIdentity checks, not general claim projection. A plan’s Allowlist field is persisted but never consulted at authorize time. Widening defaultPlan to plugin-publisher would hand push-on-stella-ops/plugins/* to every authenticated caller, which is exactly the grant-widening the catalogue forbids.

Blockers 2 and 3 were closed the same day (the config fix below). 1 and 4 remain: (1) is operator work — granting registry.token.issue to a publisher identity — and (4) is a missing product capability, which is the one that keeps the window shut.

Currency update — 2026-08-19 (SR-10): blocker 4 is closed in the tree, not yet in the live estate. Authority now has a committed-config per-client plan property and emits stellaops:plan on assigned client-credentials tokens; Registry pins an explicit unknown claim to plan_unknown with no DefaultPlan downgrade. The retained allowlist column is reserved/read-only compatibility state and is never an assignment input. No publisher entry was added to standard.yaml, Authority was not restarted, and no real elevated mint ran in SR-10. Those live steps remain SR-6’s forcing-function window, so the historical gate result above is preserved rather than rewritten as a live success.

Currency update — 2026-08-21: the tree-closed/estate-open gap above has a MEASURED cause, and it is not configuration. The RUNNING IMAGES PREDATE SR-10. Read with MSYS_NO_PATHCONV=1 docker exec <c> sh -lc 'cat /app/buildinfo.json' and corroborated against each image’s own Created metadata: stellaops-authority is gitSha 12a11b3055 (2026-08-10), stellaops-registry-web d212aa07 and stellaops-registry-token aa329e4b (both 2026-08-16). SR-10 landed in dca48aec4a on 2026-08-19, and git merge-base --is-ancestor dca48aec4a 12a11b3055 is FALSE while the reverse is true. So the live Authority physically cannot emit stellaops:plan, and the SR-6 window begins with rebuilds of all three, not with adding a client and restarting.

Two ways to misread this, both encountered on 2026-08-21 and worth stating:

  • A recreate is not a rebuild, and docker ps cannot distinguish them. The Authority signing-key rotation recreated the container at 2026-08-21T06:10:07Z, so StartedAt is today and the container is freshly Up (healthy) — while the image is still the 2026-08-10 one. “The estate is free and Authority was just restarted” is a true statement that does not clear this gate.
  • docker exec <c> cat /app/buildinfo.json without the MSYS guard returns a shell error, not a value (the path is rewritten to C:/Program Files/Git/app/…). An error must never be read as evidence either way.

What IS ready in the estate, measured the same day: registry_token.plan_rules holds the four shipped plans, all enabled, all at version 2, with plugin-publisher granting pull+push on stella-ops/plugins/*and every plan granting registries/* pull. The plan half of the gate is done; only the identity binding is missing. The Authority rebuild is an owner-level decision (pin at dca48aec4a for the smallest delta, or take today’s HEAD with eleven days of other lanes’ work) and is open at the time of writing.

One prerequisite the SR-8 list did not carry, now fixed in compose. registry-web challenges with service="stella-registry.stella-ops.local", and the token service refuses any service not in AllowedServices (Program.cs:265). Until 2026-08-16 that list held only registry.stella-ops.local, so every registry-web token request would have answered “The requested registry service is not permitted for this installation” — a 403 that reads like a client bug. RegistryTokenService__Registry__AllowedServices__1 now carries the Stella Registry’s identity beside the zot’s. Both entries coexist deliberately while both hosts run: a token minted for one cannot be replayed against the other. Verified live — an unknown service still answers 403, while both allowed services reach the credential check (401 anonymous_disabled).

Lab zot enforcement state (measured 2026-08-11)

Bearer auth on the lab zot went live on 2026-08-10, as a side effect of a registry outage repair rather than the planned 024 S7 flip: zot had crash-looped 316 times on a missing registry-token-signing-public.crt, the operator minted the keypair into git-ignored devops/etc/secrets/, and registry + registry-token were recreated with their mount declarations for the first time (SPRINT_20260722_008 execution log, 2026-08-10).

Verified read-only against 127.1.1.5:80 (the host binding; container port 5000):

ProbeResult
GET /v2/ anonymous401, WWW-Authenticate: Bearer realm="https://stella-ops.local/api/registry/token",service="registry.stella-ops.local"
GET /v2/_catalog anonymous401
POST /v2/<repo>/blobs/uploads/ anonymous401 — the anonymous-push exposure is closed

The flip is only half-done, and the missing half is fail-closed. registry_token.plan_rules in stellaops_platform holds 0 rows (SELECT count(*), 2026-08-11). With RegistryTokenService:Postgres:ConnectionString set — it is — PlanRegistry.ResolvePlanAsync resolves plans from that table with no fallback to the YAML catalogue, so every token request answers plan_unknown → 403. Net effect: the lab registry currently serves nobody — anonymous callers get 401 and authenticated callers get a token denial. That is safe (no exposure) but it is not “enabled”; provisioning plan rows via POST /api/admin/plans is the remaining step, and it is the pre-flip gate SPRINT_20260803_002 raised.

Re-measured 2026-08-15 (SR-6 premise check) — everything above still holds, plus two additions. registry_token.plan_rules and registry_token.plan_audit in stellaops_platform both still read 0 rows, four days on. Two further facts a cutover operator needs: (1) stellaops/registry-web does not exist as an image on the lab hostdocker image ls has only registry-token and packsregistry-web, so SR-6 begins with a build (devops/docker/build-service-publish.sh registry-web, which layers over the registry-token base), not with a repoint; and (2) the estate’s compose project was renamed composestellaopson 2026-08-15 (SPRINT_20260810_002 PTC-7), so any cutover command must pass -p stellaops. registry.stella-ops.local still resolves to stellaops-registry running ghcr.io/project-zot/zot-linux-amd64:v2.1.3, declared in docker-compose.stella-infra.yml; docker-compose.registry.yml remains outside every live chain — both re-verified against the running estate, not inherited from this document.

Why the catalogue never landed — FIXED 2026-08-16 (root cause + the trap in fixing it)

Current state: the catalogue LOADS and registry_token.plan_rules holds the 4 shipped plans (community, enterprise, plugin-publisher, plugin-consumer), imported on first boot with plan_audit actored system:plan-catalogue-import. The section below is kept because the mechanism is subtle, the failure was silent, and the obvious fix is wrong on its own.

The 0-row plan_rules was previously explained as “nobody has run the provisioning step”. That was true but not the cause, and the real one defeated the step itself: the token service had never loaded devops/etc/registry-token.yaml at all.

Program.cs:38 registers the file with a RELATIVE path:

configurationBuilder.AddYamlFile("../etc/registry-token.yaml", optional: true, reloadOnChange: true);

AddStellaOpsDefaults has already called SetBasePath(ContentRootPath) = /app, so that path is resolved by a PhysicalFileProvider rooted at /app — and that provider refuses .. traversal, returning NotFound, which optional: true swallows silently. Authority avoids this by combining first (Program.cs:103-110: Path.Combine(contentRoot, "../etc/authority.yaml")), which yields an absolute path and loads. The mount was correct the whole time (/etc/registry-token.yaml, 7017 bytes, verified in the container), which is exactly why this reads as a missing mount and is not one.

Consequences, all confirmed live: Plans binds empty, so SR-5’s PlanCatalogueImporter logs “No shipped plan catalogue to import” and writes nothing; DefaultPlan is null; and the four shipped plans (community, enterprise, plugin-publisher, plugin-consumer) authorize nothing. RZB-2’s defect is therefore still live in the lab for a different reason than the one SR-5 fixed.

The one-line Path.Combine is NOT the fix on its own — it crash-loops the service (exit 139), measured. AddStellaOpsDefaults appends the YAML after the unprefixed environment provider that WebApplication.CreateBuilder installs, and its own AddEnvironmentVariables uses the prefix REGISTRY_TOKEN_, which the compose keys (RegistryTokenService__*) do not carry. So once the file loads, YAML wins over operator env — and the YAML’s relative signing.keyPath: "etc/secrets/registry-token-signing.pem" overrides the absolute compose value, resolving to /app/etc/secrets/…, which does not exist; the service fails closed exactly as designed.

The shipped fix does both halves, in one named seam, StellaOps.Registry.TokenService.RegistryTokenConfigurationSources:

  1. the catalogue resolves absolutePath.GetFullPath(Path.Combine(contentRoot, "../etc/registry-token.yaml")), the Authority pattern — so the base-path provider’s .. refusal no longer applies; and
  2. AddEnvironmentVariables() is re-appended after it, so a deployment-specific source beats a shipped file default by construction rather than by the file not loading.

reloadOnChange was dropped deliberately: the old call asked for it and never got it, watching a real path would put a FileSystemWatcher on the container’s /etc, and the catalogue is imported once when the durable store is empty — after which plan_rules is authoritative and configuration changes deliberately do not overwrite operator edits.

Both halves are pinned by RegistryTokenConfigurationSourcesTests, each red-proofed against the form it replaces (restore the relative path → 2 fail; drop the env re-add → the precedence test fails reporting the catalogue’s relative etc/secrets/… path, i.e. the exact value that crash-looped the estate). The precedence rule then proved itself live: the YAML declares one entry in registry.allowedServices, and the running service still resolves both identities because compose’s AllowedServices__0/__1 outrank it.

The general question is deliberately still open. Only this service was changed; the shared bootstrapper’s file-after-unprefixed-env ordering affects every service that rides it and is recorded as an architecture observation in SPRINT_20260803_004’s Decisions & Risks.

The one consumer wired to push here in a running service is the Scanner worker’s referrer publish, and it is not affected today: SbomOciPublishStageExecutor:197, ReachabilityPublishStageExecutor:189 and BuildContextReachabilityAttachHandler:100 all initialise publishExternally = false and raise it only from an explicit per-integration publish policy, so nothing is currently attempting a zot push.

A guard class this page keeps meeting: named for a category, bound to one path

Recorded here as a CLASS rather than as three anecdotes, because it has now caused three separate false greens in this program and the next one will not look like the last three.

The shape: a guard is named after a category, asserts in the language of that category, and is bound to a single path or a single string. It then reports on its stated scope while inspecting a fraction of it — and because it is green, nobody re-derives the scope. The name is the claim; the binding is the truth; nothing reconciles them.

InstanceNamed forActually bound toWhat slipped through
Every_plan_can_pull_through_the_facade_and_none_can_push_to_itevery shipped planShippedConfigPath = "devops/etc/registry-token.yaml"the release bundle’s sibling catalogue, which had zero facade grants for two days (2026-08-21)
NoReleaseSurface_MayReferenceAVendoredConformanceSuiteevery release surfacea string match on tools/conformancea wholesale directory copy of third-party-licenses/, which never names that path, so the text shipped in the offline kit and a runtime image (2026-08-20)
guard-commit.sh “is this commit -a?”the -a flaggrep -qE 'commit[[:space:]].*-[a-zA-Z]*a'any hyphenated path containing an a, which silently unioned the whole shared worktree into the checked set (2026-08-21)

What to do about it, in order of strength:

  1. Enumerate the category at run time, not at authoring time. Iterate the catalogue, the file set, the plan list — never a constant naming one member. The facade pin already iterated options.Plans; it just iterated the plans of one file.
  2. Bind to the property, not to a path. The absolute-path guard added in the same window asserts on RAW TEXT for exactly this reason: the next leak arrives through whichever key nobody thought to map, so a key-enumerating assertion would be green by construction.
  3. When a guard names a plural, ask what the plural actually resolves togit grep the binding constant and count the members. If the count is 1 and the name says “every”, that is the defect, whether or not anything has slipped through yet.
  4. Sibling files never propagate. Where two artefacts are deliberate siblings rather than copies (here: the flagship and bundle plan catalogues, stage-config.py:81), a guard over one says nothing about the other, and a record claiming “both were updated” must be measured.

Countermeasure 3 was then APPLIED to this program’s other plural-named guards, and found nothing — recorded so nobody repeats the sweep. The two strongest candidates both hold, for two different structural reasons that are worth copying:

The generalisable test: a constant is safe when something independently reconciles it to reality, and an enumeration is unnecessary when the property is structural. The three failures in the table above had neither — no reconciliation, and no structural reason the single binding covered the named category.

What is deliberately NOT a registry role