Registry topology — the one page of truth
Verified against commit
942a18727c(2026-09-26, DOC-2 of SPRINT_20260926_001) for the Stella Registry, token service, demo-integration and release-bundle rows (compose and bundle files named in architecture.md → Evidence). Vendor-infrastructure rows (registry.stella-ops.org,mirrors.stella-ops.org) are not verifiable from this repository. Dated sections below are measurements recorded on their date.
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 (
registry-web+registry-token; the interim stella-infra zot retired at the SR-6 cutover, 2026-08-21). - 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), published on 127.1.0.49 and 127.1.1.5 in the dev compose estate) | 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. 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 |
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 | The Stella Registry (registry-web + registry-token in devops/release/bundle/docker-compose.yml), plus the optional demo-overlay zot demo-registry (docker-compose.demo-registry.yml, owner decision OD-1) registered as a demo integration through registries/<slug>; the bundle ships no zot by default | Stella Registry: bearer (as above). Demo overlay: open, bound to 127.0.0.1, off by default | evaluators (“push a test image, scan it” — through the demo integration, exercising the product plane) |
History
The interim lab zot (stellaops-registry, 127.1.1.5) retired at the SR-6 cutover on 2026-08-21; its compose key is gone and its volume compose_registry-data is retained on disk as the rollback. The measured build and deployment state from before and during the SR-6 cutover (the interim zot, the alias split, the plan-catalogue loading defect and its fix) is archived in registry-topology-history.md.
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).
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).
