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
- Stella’s artifacts live on Stella’s registry. Plugin bundles, public/enterprise distribution namespaces → the Stella Registry (interim: the stella-infra zot, until the ADR-041 R6 cutover).
- Customer images live on customer registries. In the lab and demos, “a customer registry” is played by the demo-integration zot (the simulator), registered via
registries/<slug>like every other fixture — never by Stella’s registry. - Metadata’s System of Record is ours (the
metaplane); pushing referrers into an external registry is per-integration opt-in interop, never the source of truth. - No production code dials a registry host. Targets arrive via config, required arguments, or integration records (ADR-040 D2, carried forward by ADR-041).
Every registry-shaped thing
| Surface | Role (exactly one) | Auth posture | Who talks to it |
|---|---|---|---|
registry.stella-ops.org | Vendor distribution — release/CLI images (separate vendor infrastructure: nginx + registry:3.0.0) | anonymous pull; htpasswd push | customer estates pulling release images by digest; vendor CI publishing |
mirrors.stella-ops.org | Vendor mirror — compact offline vuln-db | anonymous HTTPS | CLI / 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://registry | bearer tokens from its own token service (plans); anonymous pull only on stella-ops/public/** and only where the operator opts in (anonymousPlan); all pushes authenticated | deploy 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 store | callers authenticate via Authority (registry.token.issue); admin via registry.admin | docker/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 daemon | service identity via gateway/owner API | scan 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 only | Docker daemons on no-direct-registry deploy targets |
registry.stella-ops.local = 127.1.1.5 (stella-infra, container stellaops-registry) | 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 rollback | — | nothing; 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 R6 | none, deliberately — it plays “a customer’s registry”; open push/pull is the fixture point | fixture/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 simulator | none / per-fixture htpasswd variants | Integrations 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 here | none | .gitea workflows resolving upstream images |
| Release bundle registry | Today: 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 default | evaluators (“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.
- Built (SR-2, ADR-041 D1/D2/D4):
registry-webserves the distribution-spec READ surface (manifest/blob GET+HEAD), repository-scoped, enforcing the same bearer contract the interim zot enforces — same issuer, sameserviceaudience, sameaccessclaim, validated against the sameregistry-token-signing-public.crt. It owns databasestellaops_registry(schemaregistry) and an object-store bucket, and converges its schema on startup. - Also built and merged since (SR-3, SR-4, SR-5, SR-8), superseding the “not built yet” list that stood here until 2026-08-11: the whole distribution-spec WRITE surface (end-4a/4b, end-5 with 416, end-6, end-7 with
OCI-Subject, end-11 cross-repo mount, end-13) and tag listing withLinkpagination; the Referrers API (end-12a/12b, native, always 200); deletion as GC POLICY (end-9/end-10 answer 405 permanently — retention pins, dry-run, per-candidate audit, ref-counted reachability); plan provisioning with first-boot catalogue import and the anonymous-namespace posture; and the meta-masking facade (registries/<slug>digest-verified pull-through with referrers from the meta SoR). The official OCI conformance suite is vendored and runs 65 passed / 0 failed / 14 skipped. Contracts and rationale: architecture.md → The data plane. Still “not serving anything in the lab”, and that has not changed.SUPERSEDED 2026-08-16 — see the deployment note below. What still holds from the original statement: “served” above means implemented and conformance-tested, and the hostnameregistry.stella-ops.localis still the interim zot.Not deployed— DEPLOYED 2026-08-16, still outside the default chain.devops/compose/docker-compose.registry.ymlremains outside the defaultconfig_fileslist; it is now composed in explicitly (-f docker-compose.registry.yml -f docker-compose.existing-networks.override.yml,-p stellaops) rather than only in scratch projects. Aregistry-webimage built before a given stage does not carry it, and the symptom is indistinguishable from a configuration mistake — a pre-SR-8 image answersregistries/**with “the facade is not configured”. Checkbuildinfo.jsonbefore debugging config at cutover.- RE-VERIFIED 2026-08-18 (SR-8 forcing function), and the two claims above still hold.
docker psshowsstellaops-registry-webandstellaops-registry-tokenboth running (stellaops/registry-web:dev,stellaops/registry-token:dev), andregistry.stella-ops.localis still the interimstellaops-registryzot — deployed, alias unmoved, exactly as recorded. Added because this page is the truth doc and a re-measurement is worth more than an unre-checked date. SR-6 isBLOCKED, so the cutover is not merely pending. Known drift OUTSIDE this page, flagged not fixed:CLAUDE.md§1.4 still describes the family as “BUILT AND MERGED, NOT YET LIVE”. Half of that is now false — both services run — and half still holds (the alias has not moved, so “served” still means implemented and conformance-tested rather than reachable on the old name).CLAUDE.mdis owner-gated, so the correction is routed rather than applied here. - The D-SR8-1 unreachability is CLOSED — tree AND estate (owner ruling 2026-08-19, decision register 20260816 item 20; live window executed the same day). Every shipped plan grants
registries/**pull-only (both catalogues:devops/etc/registry-token.yamland the release bundle’s), the facade forcing function runs on a token minted by the real issuer over the shipped catalogue, andShippedRegistryTokenConfigTestspins the grant (pull allowed on every plan, push refused on every plan). CORRECTION 2026-08-21: the “both catalogues” clause above was FALSE when written, and stayed false for two days. Onlydevops/etc/registry-token.yamlwas updated on 2026-08-19.devops/etc/registry-token.bundle.yaml— the SIBLING catalogue staged into the release bundle asconfig/etc/registry-token.yaml— contained the stringregistriesexactly once, in a comment, and none of its four plans carried the grant. So for those two days every bundle-installed estate shipped precisely the D-SR8-1 condition this bullet declares closed: a facade that is complete and unreachable by any legitimate token. The pin named in the same sentence could not catch it —ShippedRegistryTokenConfigTestsbindsShippedConfigPath = "devops/etc/registry-token.yaml"and never reads the bundle’s. Closed for real in94c135fcb7(all four bundle plans granted, red-proved: removing them fails withscope_not_permitted) and now pinned on the bundle side too byBundleRegistryTokenConfigTests.Bundle_plan_catalogue_grants_the_facade_to_every_plan_pull_only. Left as a correction rather than an edit because the sentence being wrong is the more useful record: the two files are deliberate siblings, not copies, so a change to one never propagates — “both were updated” always has to be measured, never inferred. Because the first-boot import is once-ever (audit-marker gated), the file change alone could not update this estate — the grant reached the four liveplan_rulesrows through the audited admin API on 2026-08-19: four version-checkedPUT /api/admin/plans/{id}calls (v1 → v2,plan_auditrows actored by the admin principal). Verified end-to-end live:GET /api/registry/tokenthrough the gateway realm now answers 200 with an access claim forregistries/acme/payments-api:pull(the identical request answeredscope_not_permittedbefore), and registry-web answers that token with404 NAME_UNKNOWN "the meta-masking facade is not configured"— the honest unconfigured- integration refusal, past the grant gate. Two client-grant prerequisites landed in the same window via the committedbootstrapClientsroute (+ one deliberate authority restart):stellaops-cligained audienceregistry(the SGN-5 class — its registry scopes were unusable by construction without it) and scoperegistry.token.issue(SR-6 blocker 1’s named operator work; the owner’s B3 manual-mint identity). - The alias is
stella-registry.stella-ops.local, notregistry.stella-ops.local. The latter stays on the interim zot until the SR-6 cutover; it is also the bearer service identity, so keeping the two distinct is what stops a token minted for one being replayed against the other while both run. - One recorded behaviour difference from the interim zot: registry-web validates the signing certificate’s own
NotAfter; zot uses the certificate purely as a carrier for the public key. A lapsed certificate stops registry-web. Verify certificate validity as part of the R6 cutover. Re-verified 2026-08-16: the lab certificate isCN=stellaops-registry-token, valid2026-08-10 → 2036-08-07, so it is not a constraint in this estate.
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:
- Mint: client_credentials at Authority → token carries
stellaops:plan: plugin-publisher,aud [registry, stellaops](both audiences are load-bearing — see the standard.yaml note). - Exchange: front-door
/api/registry/token?service=stella-registry.stella-ops.local&scope=…→ registry bearer grantingpull,pushonstella-ops/plugins/*. Theservice=identity MUST bestella-registry.stella-ops.local; the retired zot identity 401s in a way that reads as a scope failure (the KEY-2R gotcha, third sighting). - 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:
- The alias and the bearer
serviceare different things and must stay different. registry-web ANSWERS on both names but CHALLENGES withservice="stella-registry.stella-ops.local". Passing the inherited alias as--servicereturns a 403 whose detail is “registry service is not permitted” — the right status for the wrong reason, which reads as a grant failure. Take the service from the 401 challenge, never from the URL. - Host port ≠ container port.
127.1.0.49:80is the host bind; in-network the container listens on 8080. An in-network probe against:80fails as a connection error (HTTP 000) that looks like a broken alias.
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.
| Fact | Measured value |
|---|---|
| Container / image | stellaops-registry-web / stellaops/registry-web:dev, built from d212aa07 |
| Bind / alias | 127.1.0.49:80 → 8080; network alias stella-registry.stella-ops.local only |
| Database | stellaops_registry, role registry, 3 migrations applied on first boot |
| Object store | SeaweedFS bucket registry-content (created for this window) |
| Isolation | REVOKE CONNECT … FROM PUBLIC applied; sibling role policy refused with permission denied for database "stellaops_registry" |
GET /v2/ anonymous | 401 + 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:
registry.token.issueis granted to no client. It is in the scope catalog, but a password grant for it answers400 invalid_scope— “Scope ‘registry.token.issue’ is not allowed for this client” (stellaops-cli; a control request forregistry:readreturns200, so the grant chain itself is healthy). No identity in the lab can reach the realm authenticated.The shipped plan catalogue has never loaded, soCLOSED later the same day — a code defect, not a mount; see the fix note below.Plansbinds empty andDefaultPlanis null.CLOSED —registry_token.plan_rulesandplan_auditare still 0 rows.plan_rulesnow holds the 4 shipped plans, imported on first boot once (2) was fixed. The underlying rule is unchanged and still worth knowing: withRegistryTokenService:Postgres:ConnectionStringset there is no YAML fallback, so an empty durable store means every token request answersplan_unknown→ 403.- Nothing can bind a caller to
plugin-publisher. The plan is read only from thestellaops:planclaim (PlanRegistry.cs:69,RegistryTokenIssuer.cs:64) withDefaultPlanas the sole fallback, and Authority has no mechanism to emit that claim — it appears in test code only, and clientpropertiesfeed hard-codedserviceIdentitychecks, not general claim projection. A plan’sAllowlistfield is persisted but never consulted at authorize time. WideningdefaultPlantoplugin-publisherwould 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
planproperty and emitsstellaops:planon assigned client-credentials tokens; Registry pins an explicit unknown claim toplan_unknownwith noDefaultPlandowngrade. The retainedallowlistcolumn is reserved/read-only compatibility state and is never an assignment input. No publisher entry was added tostandard.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 ownCreatedmetadata:stellaops-authorityisgitSha 12a11b3055(2026-08-10),stellaops-registry-webd212aa07andstellaops-registry-tokenaa329e4b(both 2026-08-16). SR-10 landed indca48aec4aon 2026-08-19, andgit merge-base --is-ancestor dca48aec4a 12a11b3055is FALSE while the reverse is true. So the live Authority physically cannot emitstellaops: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 pscannot distinguish them. The Authority signing-key rotation recreated the container at2026-08-21T06:10:07Z, soStartedAtis today and the container is freshlyUp (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.jsonwithout the MSYS guard returns a shell error, not a value (the path is rewritten toC:/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_rulesholds the four shipped plans, all enabled, all atversion2, withplugin-publishergranting pull+push onstella-ops/plugins/*and every plan grantingregistries/*pull. The plan half of the gate is done; only the identity binding is missing. The Authority rebuild is an owner-level decision (pin atdca48aec4afor 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):
| Probe | Result |
|---|---|
GET /v2/ anonymous | 401, WWW-Authenticate: Bearer realm="https://stella-ops.local/api/registry/token",service="registry.stella-ops.local" |
GET /v2/_catalog anonymous | 401 |
POST /v2/<repo>/blobs/uploads/ anonymous | 401 — 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_rulesandregistry_token.plan_auditinstellaops_platformboth still read 0 rows, four days on. Two further facts a cutover operator needs: (1)stellaops/registry-webdoes not exist as an image on the lab host —docker image lshas onlyregistry-tokenandpacksregistry-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 renamedcompose→stellaopson 2026-08-15 (SPRINT_20260810_002 PTC-7), so any cutover command must pass-p stellaops.registry.stella-ops.localstill resolves tostellaops-registryrunningghcr.io/project-zot/zot-linux-amd64:v2.1.3, declared indocker-compose.stella-infra.yml;docker-compose.registry.ymlremains 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_rulesholds the 4 shipped plans (community,enterprise,plugin-publisher,plugin-consumer), imported on first boot withplan_auditactoredsystem: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:
- the catalogue resolves absolute —
Path.GetFullPath(Path.Combine(contentRoot, "../etc/registry-token.yaml")), the Authority pattern — so the base-path provider’s..refusal no longer applies; and 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.
| Instance | Named for | Actually bound to | What slipped through |
|---|---|---|---|
Every_plan_can_pull_through_the_facade_and_none_can_push_to_it | every shipped plan | ShippedConfigPath = "devops/etc/registry-token.yaml" | the release bundle’s sibling catalogue, which had zero facade grants for two days (2026-08-21) |
NoReleaseSurface_MayReferenceAVendoredConformanceSuite | every release surface | a string match on tools/conformance | a 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 flag | grep -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:
- 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. - 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.
- When a guard names a plural, ask what the plural actually resolves to —
git grepthe 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. - 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:
RegistrySchemaTests.EveryTable_CarriesARetentionClassCommentiterates a hard-codedExpectedTablesconstant, which is the suspicious shape — but a sibling test,RegistrySchema_HoldsTheBaselineTablesAndTheLedgerOnly, reads the LIVE schema and assertsAssert.Equal(ExpectedTables, domainTables)— exact, not a lower bound. So a new table fails that test until the constant is updated, which then extends the retention-comment check automatically. The constant is legitimate because something reconciles it against reality. (Cross-checked anyway: the constant’s 8 entries match theCREATE TABLEstatements in the migrations exactly.)RegistryAnonymousPostureTests.EveryWriteRouteRefusesAnAnonymousTokenis a[Theory]over two[InlineData]paths, which looks like an under-bound route list — but the write surface is a SINGLE catch-all mapping (RegistryPushEndpoints.cs:27,endpoints.MapMethods("/v2/{**path}", WriteMethods, …)) behind one push gate. The two paths are samples through one gate, not an enumeration, so “every write route” is true architecturally rather than by enumeration and a future write endpoint cannot land outside it.
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
- The gateway never fronts registry blob traffic (rejected: rounds 92/ADR-040/ADR-041 — throughput through the front door for zero authorization gain; grants live in the token).
- The Stella Registry does not host customer/tenant private artifacts (owner ruling B1/4b: DENY) — that is what integrated registries and the masking facade are for.
- No registry host is ever an identity:
stella+oci://masked refs + sha256 digests are identity; hosts are routing (ADR-033 D1, unchanged).
