Per-repo dev images: a nullable repos.image_ref resolved tag→digest and pinned on save, the global --container-image demoted to an optional default, and pull-if-missing moved to spawn ahead of the claim¶
ADR-0052 shipped the container runner on a single global --container-image for every container-runner repo and named per-repo dev images (repos.image_ref, #207) a deliberate follow-up. Issue #207 (2026-07-23) fills that seam: each container repo can name the OCI image its sessions run in, the operator saves it once, and lab pins it to a digest so what runs is exactly what was reviewed. Nothing about the runner changes — the mount inventory, --userns=keep-id/--network=pasta/--cgroups=split, and the /opt/lab injection contract (ADR-0051) are untouched. The move is only which image the pane's podman run names, and how that name is validated, pinned, and materialized on the host.
The pins, decided:
-
The seam is a nullable
repos.image_ref; NULL inherits the global--container-image, which becomes the default dev image, not the only one. Repo settings → Runner, the Dev image field (PATCH fieldimage_ref): the OCI reference this repo's container sessions run in. Blank/NULL falls through to the global--container-image/LAB_CONTAINER_IMAGE; a non-NULL ref pins the repo regardless of later global changes — the same NULL-inherits/non-NULL-pins rule the container resource overrides (container_memory/container_pids/container_nofile, ADR-0052) already follow. Host-runner repos ignore the field entirely. -
Digest-pinned on save, not at spawn. A non-digest ref (
docker.io/library/debian:bookworm) is resolved tag→digest against the registry's v2 API at save time and stored pinned ashost/path:tag@sha256:…; an already-@sha256-pinned ref is stored as-is. The digest is resolved once, when the operator saves — so what runs is exactly what the operator reviewed, a same-tag re-push upstream never silently swaps a repo's image out from under it, and a resolution failure surfaces in the settings form the operator is already looking at instead of as a spawn refusal at 3am. Updating a repo's image is therefore an explicit re-save of the ref. -
The tag is kept alongside the digest in the stored ref.
host/path:tag@sha256:…, not the barehost/path@sha256:…. podman ignores the tag once a digest is present, so it costs nothing at run time and buys human readability — an operator (and a settings form) readsdebian:bookworm@sha256:…and knows what the digest is without resolving it back. -
Anonymous, HTTPS-only resolution; fully-qualified refs only; registry auth out of scope. Tag→digest resolution is anonymous and HTTPS-only — no http fallback, and a token realm the registry's
WWW-Authenticatepoints at must itself be https. Refs must be fully qualified: the docker.iolibrary/namespace may be omitted (docker.io/debiannormalizes todocker.io/library/debian,index.docker.iotodocker.io), but a ref whose first segment is not a registry host — a bare, podman-styledebian:bookwormshort name — is rejected on save. Private registries that need credentials to pull are out of scope for this slice — public / anonymous-pull registries only; a ref whose resolution or pull needs auth fails the save with the registry's own error. -
Preflight relaxes: the global image is optional; the no-image refusal moves to spawn. The startup preflight no longer fails on an unset
--container-image— a global default is optional now that repos can bring their own ref, so a deployment where every container repo sets its ownimage_refand the global is unset is valid and passes preflight. Everything else preflight checks (podman, pasta, subuid/subgid, cgroup delegation, the runtime dir, the tools-image knob and its resolvable refs) is unchanged. The one case that used to be a preflight hard-fail — no dev image at all — becomes a spawn-time refusal, because only at spawn is the repo (and therefore its effective image) known: a container spawn whose repo ref is blank and whose global--container-imageis unset is refused with an actionable message naming both knobs. -
Pull-if-missing at spawn, synchronously, before the claim. Before a container run claims anything, lab runs
podman image exists <ref>and, on a miss, onepodman pull. A failed pull refuses the spawn with an actionable error surfaced in the UI; for AFK work the refusal lands before the issue is claimed, so an unpullable image never parks an issue. The image is digest-pinned, so a successful pull is the permanent fix — but the first spawn on a freshly-set image can block on the pull. -
The dev image contract is unchanged from ADR-0052 — now per-repo — and everything lab injects is read-only. The image still needs no lab-specific contents: it brings the session's userland (a shell, coreutils,
git, an ssh client for ssh-remoted repos), and lab injects the agent layer (provider CLI +labctl) read-only via the ADR-0051 agent-tools mount at/opt/lab, with/opt/lab/binfirst on PATH. What ADR-0052 made a single global contract, #207 makes a per-repo one; the contents contract itself does not move. And it generalizes: everything lab injects into a container arrives as a read-only mount (today/opt/lab), and future lab features will import additional content the same read-only way — so an image must treat lab's mount points as reserved, never bake content at them, and never depend on writing to them. The image owns its userland; what lab owns, it mounts. -
The store stays dumb; validation and pinning live in reposvc. The
repos.image_refcolumn persists whatever pinned string it is handed. Qualification, the tag→digest resolution, and the pin are business rules, so they live ininternal/reposvc(the documented home for repo-settings validation, alongside the tracker-binding and autoland rules the settings PATCH already enforces) — the store never talks to a registry.
Status¶
Accepted. Settled via issue #207 (2026-07-23). Builds on ADR-0052 (the container runner — this ADR replaces its single global --container-image with a per-repo pick and relaxes the preflight it introduced, leaving the runner, mount inventory, and refusal-before-claim behavior otherwise intact) and ADR-0051 (the agent-tools /opt/lab injection contract — unchanged; the read-only future-imports rule generalizes it). Does not revisit ADR-0002's nix-first packaging of lab itself: the dev image is the operator's, never one lab builds. The remaining ADR-0052 follow-up, containerized provider login (#206), is untouched. Superseded in part by ADR-0056 (issue #230): the NixOS module now defaults container.defaultImage to a digest-pinned buildpack-deps:stable-scm instead of null — the demotion itself (global optional, per-repo ref wins, null valid) stands.
Considered options¶
-
Pinning at spawn instead of on save. Rejected: resolving the tag→digest at spawn means what runs is whatever the tag points at now, so a same-tag re-push upstream silently changes a repo's image between one run and the next, and a bad or unreachable ref only ever fails as a spawn refusal — for AFK work, unattended and at any hour — instead of in the settings form the operator is editing. Pin-on-save makes the digest a reviewed, stable fact of the repo row; the cost is that updating the image is an explicit re-save, which is the correct amount of friction for changing what an agent runs inside.
-
Keeping the preflight hard-fail on an unset global image. Rejected: it would forbid the exact deployment this feature enables — every container repo carrying its own
image_refwith no global default set. The no-dev-image condition is real, but it is per-spawn (it depends on the repo), so it belongs at spawn where the repo is known, not at startup where it is not. -
Pre-pulling the image at save time. Rejected: save-time is the wrong host and the wrong moment. In a multi-host future the save happens wherever the operator's request lands, not necessarily on the host that will spawn the run, so a save-time pull warms the wrong image store; and even single-host, an image evicted between save and spawn (pruned, disk pressure) would still need a spawn-time existence check and pull — so the spawn-time story is unavoidable regardless, and a save-time pull is redundant work that lengthens every save.
podman image exists+ pull-on-miss at spawn is the one place that is always both necessary and correct. -
Letting
podman runpull the image implicitly. Rejected: podman will pull a missing image onrun, but its progress and its failure land inside the tmux pane — invisible to the UI and to the AFK claim gate. An implicit pull failure would surface as a dead pane after the issue was already claimed, exactly the "unready host parks an issue" failure ADR-0052's refuse-before-claim rule exists to prevent. An explicitpodman image exists+podman pullbefore the claim turns the miss into an actionable refusal the UI shows and the AFK loop respects. -
Supporting authenticated / private registries now. Rejected for this slice: anonymous public-pull registries cover the stock-distro dev images the contract targets (
debian:stable-slim,alpine, …), and credential handling (where the pull secret lives, how it reaches the spawn host, how it interacts with the vault) is a design of its own. Anonymous HTTPS-only keeps the resolver a pure function of the ref; a ref needing auth fails the save with the registry's error rather than half-working.
Consequences¶
- Updating a repo's image means re-saving the ref. The pin is captured at save, so a new upstream digest under the same tag is picked up only on an explicit re-save — deliberate, not a regression: it is what makes a repo's running image a stable, reviewed fact rather than a moving target.
- The global
--container-imageis now optional. A per-repo-only deployment (every container repo sets its ownimage_ref, global unset) is valid and passes preflight.docs/ops.md's flags table and Container-runner section carry the demotion; the flag's row now reads "global default", not "or spawns are refused". - The no-effective-image case is a spawn refusal, not a preflight failure. Repo ref blank and global unset is refused at spawn, naming both knobs — one more actionable spawn refusal on the ADR-0052 list, and (for AFK) still landing before the claim.
- First spawn on a freshly-set image can block on the pull. The pull is synchronous and ahead of the claim; a large image on a slow link makes the first container spawn wait. Subsequent spawns hit
podman image existsand skip it. - Private registries are unsupported for now. A ref whose tag→digest resolution or whose pull needs authentication fails — the save for resolution, the spawn for the pull. Authenticated-registry support is a later slice; nothing here forecloses it.
- reposvc owns the pin; the store persists it. The tag→digest resolution and qualification live with the other repo-settings business rules in
internal/reposvc;internal/storewrites the already-pinnedimage_refstring and reads it back verbatim. Host panes and every other part of the runner are byte-for-byte unchanged.