Registry Token Service architecture
Audience: Operators deploying the Registry Token Service and integrators wiring Docker/OCI clients or the plan-administration API. Source:
src/Registry/StellaOps.Registry.TokenService. Related: token-service operations runbook.
Currency note (2026-08-11). The Registry is a TWO-ROLE family.
registry-tokenissues the bearer tokens (this file’s first half);registry-webis the Stella Registry data plane (src/Registry/StellaOps.Registry.WebService, documented under The data plane (registry-web)). They share one database and one family, and deliberately share NO source edge — the contract between them crosses as a signed token verified against a certificate, which is also what lets them deploy and restart independently.LIVE SINCE THE SR-6 CUTOVER, 2026-08-21. The Stella Registry serves
registry.stella-ops.localin the lab estate and the interim zot is retired — container removed, compose key gone, its volume retained as the rollback. Proven, not assumed: a plugin bundle was signed and published through the ADVERTISED realm and pulled back by digest (@sha256:591d56ac…), answering 200 with a plan-scoped token and 401 without, with the manifest re-hashing to the requested digest; the alias was then re-verified THROUGH the inherited name after the flip.registry_token.plan_rulesholds the four shipped plans.Two things this banner used to warn about are now closed and one distinction survives:
the alias still points at the interim zot— moved;the plan catalogue has not been imported, so— imported;plan_rulesis empty- the meta-masking facade is still not configured in the lab (no
Registry__Facade__*), soregistries/<slug>answers the honest “facade is not configured” refusal there. Its mechanism IS proven — SR-8’s forcing function ran it end to end against a real customer registry with referrers from the meta SoR — but that ran in a test host, not in this estate.Read “served” as reachable in the lab today, EXCEPT for the facade, which is implemented and conformance-tested rather than configured. Sections whose truth depends on a cutover keep their own currency marker.
What changed about the sentence that used to be here. It read: “the OCI registry itself is not in this module —
stellaops-registryis the third-party zot registry, infrastructure, not Stella Ops source.” That is still true of the compose keystellaops-registryduring the interim window, but it is no longer true of the module: the product’s own OCI registry is Stella Ops source now, and zot’s end state is the demo-integration simulator.
Overview
Registry Token Service is the Stella Ops component that issues short-lived Docker registry bearer tokens for private or mirrored registries. It is designed for offline/self-hosted operation and enforces plan/licence constraints before minting any registry token.
In addition to token exchange, the service exposes an authenticated Plan Administration API for managing the plan-rule catalogue (CRUD, dry-run validation, and audit history) that drives those authorization decisions.
The service surface is small and focused:
- Token exchange:
GET /token(gated by theregistry.token.issuepolicy). - 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 identity token claims and (b) plan rules read from the durable PostgreSQL store (or, in non-durable test hosts, an explicitly registered in-memory store).
Primary responsibilities
- Validate caller identity using Authority-issued tokens (deployment profile may use bearer-only, DPoP, and/or mTLS).
- Authorize requested registry scopes against the configured/persisted plan catalogue.
- Deny issuance for revoked licences and for disabled plans.
- 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.
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.sqland002_reserve_plan_allowlist.sqlconverge both fresh and upgraded databases;002records the reserved-column contract without rewriting the applied baseline. - 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 (and any required sender constraints for the deployment). - 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).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
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 relative to the content root as../etc/registry-token.yaml)- environment variables prefixed with
REGISTRY_TOKEN_
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,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. Own database stellaops_registry (schema registry, fail-closed on STELLAOPS_POSTGRES_REGISTRY_CONNECTION), own object-store bucket. It validates the bearer tokens registry-token mints — same issuer, same service audience, same access claim — and never references that project in source; the contract between the two roles is a signed token verified against a certificate.
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 (SR-2) |
| 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, SR-4 |
| 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. - 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.
Proven by the official 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). Last measured result: 65 passed / 0 failed / 14 skipped — Pull 23/23, Push 27/27, Referrers 4/4. Content Management is disabled rather than failing: end-9/end-10 answer the spec’s 405 UNSUPPORTED because deletion is GC policy (below), not a client-callable route.
Reading the raw
junit.xmlreportstests="80"; that count includes a non-spechtml custom reporterentry. 65 is the spec-case number.
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>/…)
Currency (2026-08-21): the FORCING FUNCTION HAS RUN; this estate is still not configured for it. The ADR-041 D3 proof landed at SR-8 and is no longer “scheduled” — a real customer image was pulled through
registries/<slug>from a real zot, byte-for-byte and re-hashing to the requested digest, with its referrers ABSENT when read directly from the customer registry and present when read through the facade, fetched over real HTTP from a different origin. That is the contract demonstrated, not asserted.What remains unverified is deployment, and only that: the lab estate sets no
Registry__Facade__*keys, soregistries/<slug>there answers the honest “the meta-masking facade is not configured” refusal. Treat the behaviour as proven and any claim about how a particular estate is CONFIGURED as unverified — including this one, which is measured as of the date above and is exactly the kind of fact that changes without touching this file.
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.
Roadmap / not-yet-implemented
RateLimitDto(maxRequests,windowSeconds) is part of the admin plan model and persisted inplan_rules, but it is not yet enforced at theGET /tokenissuance path. Treat it as advisory metadata until enforcement lands.
References
- Operations/runbook:
docs/modules/registry/operations/token-service.md
