Skip to main content

The managed image store

Status: DESIGN — direction confirmed with the maintainer (2026-07-29): Everdict should OWN the image interface the way it owns the workspace filesystem, so self-hosters plug an adapter instead of bringing a registry. Supersedes the BYO-only model of docs/architecture/workspace-image-registry.md (which becomes ONE adapter under this port) and completes docs/architecture/environment-image-store.md (the entity this serves).

Why

An environment image is already a composite entity: bytes plus the agent-facing context that makes it usable (contents, preset, instructions, benchmark, provenance). Today those two halves have two different owners — the bytes live in a registry the tenant brought, the context lives in our capability record — and we suture them after the fact with ImageRegistryService.verifyImage. Every consequence of that split is a symptom, not a design:

  • Onboarding asymmetry. A workspace gets a filesystem the moment it exists; it gets an image registry only after an admin registers a host and two SecretStore refs. With no registry there is no publish path at all — the authoring half of the environment store is gated behind BYO infrastructure.
  • We can only ask, never guarantee. probe and verifyImage exist because the registry is someone else's: we have to interrogate it to find out whether the thing we just told the user about actually works. classifyImageRef warns instead of knowing.
  • Sharing stops at the asset. The store shares the ref, not the bytes — a public OfficeQA environment is unusable by a consumer who cannot authenticate to the publisher's private registry (verify.reason === "auth" is exactly that dead end). Cross-tenant credential brokering is impossible against a registry we do not control.
  • One credential per job. CaseJob.registryAuth is singular because BYO credentials are per-host and unmergeable; a topology pulling from two BYO registries authenticates only the first match.

Owning the interface removes the suture. The same argument that made the filesystem ours applies with more force here, because the entity does not exist in the OCI world at all — no registry stores a topology preset or agent instructions, so a registry can never be the SSOT for what we are actually publishing.

Decision — a WorkspaceImages port, managed by default

packages/application-control/src/ports/workspace-images.ts, in the exact idiom of WorkspaceFs: tenant FIRST on every method, isolation enforced INSIDE the adapter, never by caller discipline.

listRepositories(tenant) → ImageRepo[]
listTags(tenant, repository) → string[]
inspect(tenant, repository, reference) → ImageManifestInfo
mintPushGrant(tenant, repository) → ImageGrant // short-lived, scoped to that one repo
mintPullGrant(tenant, refs[]) → ImageGrant[] // MANY repo scopes, one grant per endpoint
remove(tenant, repository, reference?) → number
usage(tenant) → { bytes, repositories }

The existing RegistryReader port is not retired — it becomes the read adapter underneath this port (the BYO adapter and the managed adapter both speak Docker Registry v2 over it).

Isolation — the boundary moves from the bucket to the token

The filesystem's boundary is the bucket: fsBucketFor(prefix, tenant) = <prefix>-<sanitized>-<sha256:8>, one per tenant, created lazily. A registry cannot copy that, because a registry process must serve every repository it hosts — a bucket per tenant would mean a registry per tenant, which is absurd operationally. So the boundary moves one layer up, and we become the authorization server:

  • NamespaceimageRepoFor(tenant) = <sanitized>-<sha256:8> (the same collision-proof rule as fsBucketFor, for the same reason: a sanitization collision between "Acme" and "acme" would be cross-tenant leakage). A tenant's images live at <endpoint>/<namespace>/<name>:<tag>.
  • Token — the registry is configured for Docker Registry v2 token auth (auth.token with realm pointing at the control plane). It holds no user database; it validates an RS256 JWT signed by us and honors exactly the scopes that token carries. A token scoped to another tenant's namespace cannot be minted, so it cannot exist.

The invariant that matters is preserved verbatim — isolation lives inside the adapter, not in caller discipline — only its enforcement point changes from storage to signature. This asymmetry with the filesystem is deliberate and must stay documented: nobody should later "fix" it by reaching for a bucket per tenant.

Adapters

AdapterBackingRole
ManagedImageStorebundled CNCF distribution (registry:2) + our token serverThe default. Storage driver s3 against the MinIO that already backs the filesystem and the artifact store (EVERDICT_S3_*), or driver filesystem for a self-hoster with their own volume — the same "swap the adapter to fit your infra" story as S3WorkspaceFs vs InMemoryWorkspaceFs.
ByoImageStorethe tenant's own registry (WorkspaceSettings.imageRegistries[])Today's model, demoted from the model to an adapter. Kept because an enterprise with a mandated Harbor/ECR must not be forced to duplicate images into ours. probe / verifyImage / push-credential minting stay here — they are BYO concerns.
InMemoryImageStorein-process mapdev/test, mirrors the semantics exactly.

Deployment constraints worth stating up front, because they bite in self-hosted compose:

  • The registry endpoint must be reachable from every execution node — including a self-hosted runner on a user's laptop. Same class of problem as CONTROL_PLANE_WS_URL; the managed endpoint is an operator-configured public URL, not a container-network name.
  • Docker refuses plain HTTP for anything but loopback. The bundled compose stack terminates TLS in front of the registry, or the operator adds it to insecure-registries (documented, not silent).

What the ownership buys

  1. Onboarding drops to zero — a workspace has a place to publish images the moment it exists, exactly like its file tree.
  2. The singular-credential limitation dissolves — one grant can carry many repository scopes, so a multi-service topology pulling three managed images authenticates all three. CaseJob.registryAuth becomes registryAuths[].
  3. Cross-tenant pull becomes real sharing (see below).
  4. Provenance is minted, not scraped — the digest comes from the registry's own response to our push grant, so everdict image push --register-environment stops parsing docker image inspect RepoDigests and the capability record's image is authoritative by construction.
  5. probe / verifyImage become BYO-only — for a managed image, "can this workspace pull it" is answered by policy, not by an HTTP round trip that might lie a second later.
  6. Quota, GC and lifecycle are oursusage(tenant) feeds the existing usage metering the same way filesystem usage does, and retention becomes a policy we can state instead of a registry we hope was cleaned up.

Cross-tenant pull — bytes, not just the asset

The store's four reach tiers already decide who may consume a capability; the pure kernel canConsumeCapability is the judge. With the registry under our authorization server, that decision extends from metadata to bytes with no new policy surface:

mintPullGrant(consumerTenant, ["<publisher-ns>/officeqa-env@sha256:…"])
→ resolve ref → owning namespace → owning tenant
→ the environment capability that declares this ref
→ canConsumeCapability(capability, consumerTenant) ? add scope "repository:<publisher-ns>/officeqa-env:pull" : omit

A consumer that adopted a public environment pulls it directly — no publisher credential is ever handed over, no bytes are copied between tenants, and revoking reach revokes pull on the next grant (grants are short-lived by construction). This closes the environment-image-store.md non-goal "cross-tenant pull-credential brokering" and makes adopt mean what users already read it as: this environment is usable here.

Boundary: the scope is granted only for a ref a consumable capability declares. Pointing a pull grant at an arbitrary repository in someone else's namespace is not a request we can satisfy, ever.

Contract changes

  • CaseJob.registryAuth: RegistryAuthregistryAuths: RegistryAuth[] — the wire codec and every consumer (DockerDriver pre-pull, self-hosted runner pre-pull, Nomad Config.auth, the K8s dockerconfigjson Secret, both topology builders) render all matching entries instead of the first. Each consumer already filters by imageUsesRegistryHost; the change is fan-out, not new logic. The singular field is kept and dual-written, not deleted: a self-hosted runner is user-installed and can lag the control plane, it reads only that field, and dropping it would silently un-authenticate an older runner's pulls (a failure that looks like a broken registry, not like a version skew). Every consumer reads through registryAuthsOf, which prefers the plural, so the compatibility lives in one function and the field can be deleted once runners have rolled.
  • classifyImageRef gains a managed class ahead of workspace: a ref inside the tenant's own managed namespace is not merely "a registry you registered", it is ours — the web renders it as the provenance-clean case and harness validation stops warning about it.
  • ImageGrant (new wire type): { endpoint, repositories[], token, expiresAt }. Transient like repoToken — never persisted, never logged, stripped from allocation env.
  • WorkspaceSettings.imageRegistries[] is untouched; it is now the ByoImageStore adapter's config.

Surfaces

SurfaceWhat
HTTP (apps/api api/images/)GET /workspace/images (repositories + usage) · GET /workspace/images/:repo/tags · GET /workspace/images/manifest?repository&reference (inspect: the pin digest plus, best-effort, the OCI config blob — build history, runtime config, size, os/arch; ImageInspectResponseSchema) · POST /workspace/images/push-grant (images:push) · DELETE /workspace/images/:repo · GET /v2/token (the registry's auth realm — unauthenticated by Fastify's normal chain, it authenticates the docker client's basic credentials itself)
MCP (parity)list_workspace_images · list_managed_image_tags · inspect_managed_image · push_image_grant · remove_workspace_image. Managed-specific names, not a managed-aware overload of the BYO list_image_tags/inspect_image: the two read different stores ("ours, we mint the grant" vs "a registry you told us about"), a tool name is unique across the server, and a single tool would have had to guess between them from a bare repository name. Distinct names let each description say which store it reads.
CLIeverdict image push <ref> mints a push grant instead of push credentials; --register-environment <id> registers with the registry-reported digest in the same call
WebSettings › Images — a registry UI in the JFrog grammar: the LIST is repositories (row name = drill-in, delete), and the routed DETAIL settings/images/[name] answers everything else — versions (tags, latest→semver-desc), the selected version's digest/size/platforms, the build recipe (OCI config history rendered as Dockerfile steps, metadata-only steps dimmed), the runtime contract (entrypoint/cmd/env/ports/labels), and the everdict context: the environment capabilities that declare this image (matched repository-wise, tag-insensitive), each with its agent instructions. BYO registries stay under Settings › Integrations
Agentunchanged tool names; the system prompt's authoring recipe loses the "register a registry first" precondition

Slices

  • M1 — port + contracts. WorkspaceImages port, ImageGrant/ImageRepo wire types, imageRepoFor + classifyImageRef managed class in @everdict/domain, registryAuths[] on CaseJob with every consumer fanned out. No behavior change yet (managed store absent = today).
  • M2 — ManagedImageStore. ✅ New packages/images (a registry client is not object storage, so it does not belong in @everdict/storage): RegistryTokenIssuer (the authorization server: grant → scoped registry token), ManagedRegistryApi (catalog/tags/manifest/delete as the namespace OWNER — distinct from the RegistryReader port, which is the BYO guest path), ManagedImageStore and InMemoryImageStore. Two token audiences, deliberately: a grant is what a client presents as its registry password, and only the token endpoint can exchange it for a registry token, so a grant cannot be replayed at the registry. narrowAccess makes the exchange able to narrow a grant and never widen it. Cross-tenant refs are omitted from a pull grant with a comment pointing at M6 — the safe default is the registry's own 401, never authorization we invented.
  • M3 — the token server.GET /v2/token in apps/api (outside the Principal chain: the caller is a docker client that has never heard of our Bearer tokens, and the grant it presents IS the permission), registry:2 behind the images compose profile with the S3 driver on the existing MinIO, key/cert generated by full.sh into a gitignored certs/ and mounted as files — never env values, because a PEM in the environment leaks into every child process. The served imageClasses can now say managed, so the web's zod mirrors and labels landed with it. Verified live (scripts/live/managed-image-store.mjs): a real distribution registry accepts our x5c tokens, a grant pushes and pulls, and a grant for one namespace is refused at another — the isolation claim checked against the actual enforcement point rather than our own assertion about it.
    • The realm is resolved by the docker CLIENT, not by the registry — the registry only advertises it in its 401 challenge. A container-network name there fails on every client; the live run proved it by failing that way first. Same class of setting as CONTROL_PLANE_WS_URL, and the reason IMAGE_STORE_ENDPOINT/IMAGE_STORE_REALM are surfaced in full.sh's output with a warning instead of buried in compose.
    • The S3 redirect is followed by the docker CLIENT too — the same trap one layer down, and it bites only after the token exchange has already succeeded, so it reads as a storage fault rather than a reachability one. distribution answers a blob with a 307 to a presigned URL, and the only address it can build is http://minio:9000, which resolves on the compose network and nowhere else; a push dies with lookup minio: no such host. REGISTRY_STORAGE_REDIRECT_DISABLE makes the registry proxy the bytes instead. Proxying costs it the blob traffic; a redirect no outside client can follow costs it every push.
    • The blob bucket is not created for us. Our own S3 users (workspace filesystem, artifact offload) create their buckets lazily in code; distribution is a third-party container that cannot, and reports the miss as a 500 on first push. The registry-bucket one-shot in the compose profile seeds it with mc mb --ignore-existing, and the registry gates on it with service_completed_successfully.
    • ./certs is mounted by the api on every profile, so the directory has to exist before compose runs at all — a bind mount with a missing source is created by the docker DAEMON, i.e. owned by root, and full.sh can then never write the key pair into it. full.sh therefore creates it unconditionally as the invoking user, and refuses with a chown instruction when it finds one it cannot write.
    • The exchange logic lives in @everdict/images (ImageTokenService), not in apps/api/src/core: nothing in it knows about HTTP frameworks, and keeping it beside the issuer is what lets the live check exercise the real code path instead of a re-implementation of it.
  • M4 — dispatch.buildImagePullAuths is the ONE answer to "what does this job need to pull its images" — managed grants first (consumers take the first host match, and ours is the credential we can vouch for), BYO second; executeCase and the RuntimeDispatcher both call it, so run, scorecard and topology authorize identically. The seam became image-scoped ((workspace, images)): a managed grant is minted for the repositories in flight, and a resolver that answered "here is every credential the tenant has" would defeat the point of scoping one. Both halves stay best-effort — an unreachable registry must not fail a job whose other images pull fine (the warn-only placement stance).
    • Grants outlive the queue. Credentials are minted BEFORE a job is scheduled, so the lifetime has to cover queue wait + pull, not just the token exchange: EVERDICT_IMAGE_STORE_GRANT_TTL_SECONDS defaults to an hour. Lower it to tighten how fast revoked reach stops working; a job queued past it fails at pull with the registry's own error, which is visible rather than silent.
  • M5 — publish.everdict image push now has two targets and one command: --registry <name> is an explicit BYO choice, otherwise the managed store is preferred when the deployment runs one and BYO is the automatic fallback (a 404 from POST /workspace/images/push-grant is a normal answer here, not a failure — it is how a pre-managed workspace keeps working). The grant is a bearer in the docker password field, so pushImage cannot tell which target it got. GET /workspace/images/manifest gives --register-environment the digest the registry stored rather than the local daemon's record of what it sent; the docker RepoDigests scrape stays as the BYO path's only option. Verified live — the live script's step 6 publishes through the CLI's own pushImage against the real registry, so the code path under test is the shipped one.
  • M6 — cross-tenant pull. ✅ Reach is injected into the store, not decided by it: ManagedImageStore takes a crossTenantPull(tenant, ref) predicate and authorizes a scope outside the caller's namespace only when that predicate says yes. The predicate is adoptedImageReach (application-control), which answers from the consumer's adopted-environment inventoryEnvironmentAdoptionService.list recomputes available through canConsumeCapability on every read, so revoked reach stops authorizing pulls with nothing to invalidate here. That inventory IS the design's "a ref a consumable capability declares"; an arbitrary repository in someone else's namespace stays unrequestable. Matching is by host+repository, so a digest pull of an adopted tag still reaches. No policy wired (BYO-only deployments, tests) → the pre-M6 omission, unchanged.
    • The reach question carries no subject. canConsumeCapability wants {tenant, subject}, but the only tiers that cross a workspace boundary are public and subset, and neither reads createdBy — so cross-tenant reach is a property of the WORKSPACE and a placeholder subject cannot widen it. That is what lets this run on the pull path, where only the tenant is known, instead of threading a subject through dispatch.
    • adopt/reverify verification is a policy answer for managed refs (EnvironmentAdoptionService.verify): a ref under our own endpoint is pullable: true because we only got there having resolved a consumable capability, and the grant that authorizes the real pull is minted from that same fact. The HTTP probe would ask the registry anonymously, be told 401, and report auth for an image the workspace can demonstrably pull. BYO refs keep the HTTP check — it is the only thing that can answer them.
    • The composition has a real cycle: the store needs reach, and reach needs the store's coordinates to classify an image. main.ts binds the predicate through a holder AFTER the adoption service exists; until then it denies, which is the same answer as running without M6.
  • M7 — web. ✅ Settings › Images (/[workspace]/settings/images) lists the workspace's managed repositories with the endpoint/namespace their refs are built from, expands a row to resolve its tags on demand, and retracts one with images:push (added to the web's WebAction mirror). A deployment with no managed store answers 404 on the route, and the panel says "not configured" rather than showing an empty list — nothing published and no store are different states. BYO is reframed as "external image registries" in Settings › Integrations, pointing here. The environment workbench now badges a managed image; external deliberately stays unbadged, since it is the default and badging it would bury the distinction that matters.
  • M8 — docs + skills.workspace-image-registry.md opens with a scope note making it the BYO adapter chapter (and says why verifyImage stays HTTP there); environment-image-store.md's "hosting images" non-goal is struck through and closed, leaving only "building"; the plugin skill's tool reference gains the managed family and frames the two stores; the agent system prompt's authoring recipe checks list_workspace_images FIRST and only falls back to "an admin must register a registry" when no managed store is present.

Non-goals

  • Building images. Unchanged: docker build stays the author's. We store, authorize, describe.
  • Being a general-purpose container registry. The managed store serves eval assets (environments, harness images). It is not a place to host a tenant's production images.
  • Pull-through caching of external base images. Attractive later (offline evals, rate limits), out of scope now.
  • AWS ECR SigV4 in the BYO adapter. Still out of scope, unchanged.
  • Image signing (cosign/notation). The trust story stops at our token server in v1.

Open questions

  • Retention policy. Untagged manifests accumulate; distribution's GC needs a read-only pause. Scheduled per-deployment maintenance, or per-tenant tag-count caps?
  • SaaS multi-region. One registry per deployment is fine self-hosted; a hosted Everdict eventually wants regional endpoints, which makes the grant's endpoint tenant-resolved rather than global.
  • Does the managed store subsume _everdict first-party environments? Seeding common benchmark images into our own namespace is now possible — is that a tier of the store or a separate operator workflow?