ADR-035 — Digest-centric content broker (no standing edge secret; content-addressed authorization)
- Status: Accepted (binding for the registry-access content-broker stream)
- Date: 2026-06-24
- Owners: Lead architect / ReleaseOrchestrator + Agent + Cryptography
- Supersedes / Amends: none
- Relates to: ADR-034 (Stella-native deployment-scoped secret-access broker — the capability + Seal/Open chain this decision reuses), ADR-033 (unified registry access — digest is identity; loopback agent-registry pull-through), ADR-032 (URL secret references), ADR-031 (unified secret provider)
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
- The edge agent’s secret surface on the deploy path is zero: no upstream credential, no L2 key — only a self-expiring, digest-scoped capability and its mTLS identity. Its blast radius is “this deployment’s pinned digest, until the deployment ends or the short TTL elapses.”
- Authorization and storage share the digest coordinate, so an agent can request exactly (and only) the content its capability names, and every such request hits a content-addressed object (global dedup).
- The orchestrator sees upstream credentials and L2 content transiently while serving — the same accepted trade-off as ADR-034 §D5 (on-demand, audited, never bulk-persisted to the edge).
- No new crypto and no schema change: the capability, Seal/Open chain, and the digest-keyed cache all pre-exist; this decision composes them.
Rejected alternatives
- Give the edge agent the upstream credential (standalone pull-through, §1–§8). Retained for the non-deploy standalone cache, but rejected for the deploy path: it puts a credential on the edge that the digest-pinned deploy does not need.
- Give the edge agent the L2 store key. Rejected: a stolen agent could then read/write the whole fleet-wide content store; the orchestrator must remain the sole L2 key-holder.
- Authorize by tag / repository instead of digest. Rejected: tags are mutable and not the release’s pin; the digest is already identity (ADR-033 §D1) and the cache key, so anything coarser breaks content-addressed dedup and the fail-closed scope check.
- Invent a new content-authorization token. Rejected: the digest is already an allowed-reference shape
DeploymentSecretCapabilityenforces — reuse it.
