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 completesdocs/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.
probeandverifyImageexist 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.classifyImageRefwarns 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.registryAuthis 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:
- Namespace —
imageRepoFor(tenant)=<sanitized>-<sha256:8>(the same collision-proof rule asfsBucketFor, 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.tokenwithrealmpointing 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
| Adapter | Backing | Role |
|---|---|---|
ManagedImageStore | bundled CNCF distribution (registry:2) + our token server | The 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. |
ByoImageStore | the 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. |
InMemoryImageStore | in-process map | dev/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
- Onboarding drops to zero — a workspace has a place to publish images the moment it exists, exactly like its file tree.
- 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.registryAuthbecomesregistryAuths[]. - Cross-tenant pull becomes real sharing (see below).
- Provenance is minted, not scraped — the digest comes from the registry's own response to our
push grant, so
everdict image push --register-environmentstops parsingdocker image inspectRepoDigestsand the capability record'simageis authoritative by construction. probe/verifyImagebecome 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.- Quota, GC and lifecycle are ours —
usage(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: RegistryAuth→registryAuths: RegistryAuth[]— the wire codec and every consumer (DockerDriver pre-pull, self-hosted runner pre-pull, NomadConfig.auth, the K8s dockerconfigjson Secret, both topology builders) render all matching entries instead of the first. Each consumer already filters byimageUsesRegistryHost; 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 throughregistryAuthsOf, which prefers the plural, so the compatibility lives in one function and the field can be deleted once runners have rolled.classifyImageRefgains amanagedclass ahead ofworkspace: 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 likerepoToken— never persisted, never logged, stripped from allocation env.WorkspaceSettings.imageRegistries[]is untouched; it is now theByoImageStoreadapter's config.
Surfaces
| Surface | What |
|---|---|
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. |
| CLI | everdict image push <ref> mints a push grant instead of push credentials; --register-environment <id> registers with the registry-reported digest in the same call |
| Web | Settings › 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 |
| Agent | unchanged tool names; the system prompt's authoring recipe loses the "register a registry first" precondition |
Slices
- M1 — port + contracts.
WorkspaceImagesport,ImageGrant/ImageRepowire types,imageRepoFor+classifyImageRefmanagedclass in@everdict/domain,registryAuths[]onCaseJobwith every consumer fanned out. No behavior change yet (managed store absent = today). - M2 —
ManagedImageStore. ✅ Newpackages/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 theRegistryReaderport, which is the BYO guest path),ManagedImageStoreandInMemoryImageStore. 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.narrowAccessmakes 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/tokeninapps/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:2behind theimagescompose profile with the S3 driver on the existing MinIO, key/cert generated byfull.shinto a gitignoredcerts/and mounted as files — never env values, because a PEM in the environment leaks into every child process. The servedimageClassescan now saymanaged, so the web's zod mirrors and labels landed with it. Verified live (scripts/live/managed-image-store.mjs): a real distribution registry accepts ourx5ctokens, 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 reasonIMAGE_STORE_ENDPOINT/IMAGE_STORE_REALMare surfaced infull.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 withlookup minio: no such host.REGISTRY_STORAGE_REDIRECT_DISABLEmakes 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-bucketone-shot in the compose profile seeds it withmc mb --ignore-existing, and the registry gates on it withservice_completed_successfully. ./certsis 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, andfull.shcan then never write the key pair into it.full.shtherefore creates it unconditionally as the invoking user, and refuses with achowninstruction when it finds one it cannot write.- The exchange logic lives in
@everdict/images(ImageTokenService), not inapps/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.
- 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
- M4 — dispatch. ✅
buildImagePullAuthsis 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;executeCaseand theRuntimeDispatcherboth 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_SECONDSdefaults 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.
- 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:
- M5 — publish. ✅
everdict image pushnow 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 fromPOST /workspace/images/push-grantis 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, sopushImagecannot tell which target it got.GET /workspace/images/manifestgives--register-environmentthe digest the registry stored rather than the local daemon's record of what it sent; the dockerRepoDigestsscrape stays as the BYO path's only option. Verified live — the live script's step 6 publishes through the CLI's ownpushImageagainst 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:
ManagedImageStoretakes acrossTenantPull(tenant, ref)predicate and authorizes a scope outside the caller's namespace only when that predicate says yes. The predicate isadoptedImageReach(application-control), which answers from the consumer's adopted-environment inventory —EnvironmentAdoptionService.listrecomputesavailablethroughcanConsumeCapabilityon 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.
canConsumeCapabilitywants{tenant, subject}, but the only tiers that cross a workspace boundary arepublicandsubset, and neither readscreatedBy— 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/reverifyverification is a policy answer for managed refs (EnvironmentAdoptionService.verify): a ref under our own endpoint ispullable: truebecause 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 reportauthfor 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.tsbinds the predicate through a holder AFTER the adoption service exists; until then it denies, which is the same answer as running without M6.
- The reach question carries no subject.
- 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 withimages:push(added to the web'sWebActionmirror). 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 amanagedimage;externaldeliberately stays unbadged, since it is the default and badging it would bury the distinction that matters. - M8 — docs + skills. ✅
workspace-image-registry.mdopens with a scope note making it the BYO adapter chapter (and says whyverifyImagestays 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 checkslist_workspace_imagesFIRST and only falls back to "an admin must register a registry" when no managed store is present.
Non-goals
- Building images. Unchanged:
docker buildstays 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
endpointtenant-resolved rather than global. - Does the managed store subsume
_everdictfirst-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?