ADR-035 — Digest-centric content broker (no standing edge secret; content-addressed authorization)

Context

ADR-033 gives no-direct-registry / air-gap targets a loopback agent-registry that pulls by digest, and ADR-034 removed the standing backend token from the deployment agent (a sealed, deployment-scoped capability the agent relays back over mTLS, rather than a long-lived credential).

The standalone pull-through cache (see registry-bridge-cache runbook §1–§8) still lets the agent fetch upstream itself: it resolves an upstream credentialRef at pull time via the ADR-034 broker. On the agent-as-registry deploy path, that is more credential than the edge needs. The release is already digest-pinned (the release pins the image manifest digest), and the cache is already content-addressed on the digest (L1 index + L2 KeyFor are digest-only; the pull-through path refuses non-digest refs). So the open question is narrow: on the deploy path, can the edge agent hold no upstream credential and no L2 store key, and still obtain exactly the content the release pins?

Decision

D1 — The pinned digest is the unit of authorization

A content digest is just another allowed reference. The existing DeploymentSecretCapability (ADR-034 §D1) authorizes a set of reference-hashes via AllowedRefHashes with AllowsRef(rawRef) / AllowsRefHash(hash) / ComputeRefHash(raw). Scoping a capability on the pinned image digest adds the digest as an allowed reference at mint time (DeploymentSecretModeResolver.TryMintPullCapabilityAsync / DeploymentVarsBundleAssembler.ComputePullModeSecretReferencesAsync) — no schema change. The capability is sealed under the master KEK (Seal/Open, ADR-034 §D2), so the agent can neither forge nor widen the set of digests it may pull.

D2 — The edge agent holds no upstream credential and no L2 key

On the deploy path the edge agent holds only its mTLS identity and the opaque sealed capability (which it relays, never opens). It holds no upstream registry credential and no L2 (RustFs) store key. The orchestrator alone holds the per-upstream credentials (resolved server-side through the routing ISecretProvider, the same resolve path push mode uses) and the L2 store key.

D3 — Content is fetched by digest over the authorized mTLS channel

On a pull-through miss the agent fetches by digest:

GET /api/v1/release-orchestrator/deployments/{deploymentId}/registry/content/{kind}/{digest}
    {kind} = manifest | blob     {digest} = sha256:<64 lowercase hex>
    X-StellaOps-TenantId: <tenant GUID>
    X-StellaOps-Deployment-Capability: <opaque sealed capability>

The orchestrator processes the request fail-closed, mirroring the mTLS-only, tenant-checked, cross-tenant-404 DeploymentSecretBrokerEndpoints precedent: Open + validate the capability (AEAD tag, tenant + deployment AAD, ExpiresAt, active-deployment check = revocation) → scope-check the requested digest against AllowsRefHash(ComputeRefHash(digest)) (out of scope ⇒ 403; capability absent/invalid ⇒ 401) → serve from L2 (the digest-keyed RustFs store) → on miss resolve the orchestrator-held upstream credential, fetch by digest, digest-verify, write-through to L2, return (404 only when in neither L2 nor any reachable upstream).

D4 — The cache stays digest-keyed (content-addressed, global dedup)

Both tiers key on the digest and this is load-bearing: L1 (agent filesystem) and L2 (ObjectStoreOciContentStore over RustFsObjectStoreClient, key = <prefix>/{manifests|blobs}/sha256/<hex>) are content-addressed, so identical content dedups to one object fleet-wide regardless of which upstream/agent produced it. Authorization (the broker token, scoped on digest) and storage (the cache, keyed on digest) therefore share one coordinate end-to-end.

D5 — Digest-verify on both sides (fail-closed)

The orchestrator digest-verifies bytes before serving; the agent re-verifies on receipt (defence-in-depth — the digest is the only integrity anchor the edge has, holding no upstream trust). Unverified bytes are refused and never stored; a Docker-Content-Digest header mismatch is a server contract violation (fail-closed).

D6 — The L2 store key is a secret reference, never a value

The orchestrator’s L2 RustFs key is configured as an ADR-032 secret reference (builtin://, vault://, openbao://, authref://, secret://, file://, base64:, plain:), resolved through the same RoutingSecretProvider the service already wires — never a raw value in compose/env. (Deployment__Registry__L2__* keys; see the runbook §9.5.)

Consequences

Rejected alternatives