Skip to content

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 field image_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 as host/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 bare host/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) reads debian: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-Authenticate points at must itself be https. Refs must be fully qualified: the docker.io library/ namespace may be omitted (docker.io/debian normalizes to docker.io/library/debian, index.docker.io to docker.io), but a ref whose first segment is not a registry host — a bare, podman-style debian:bookworm short 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 own image_ref and 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-image is 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, one podman 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/bin first 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_ref column persists whatever pinned string it is handed. Qualification, the tag→digest resolution, and the pin are business rules, so they live in internal/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_ref with 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 run pull the image implicitly. Rejected: podman will pull a missing image on run, 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 explicit podman image exists + podman pull before 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-image is now optional. A per-repo-only deployment (every container repo sets its own image_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 exists and 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/store writes the already-pinned image_ref string and reads it back verbatim. Host panes and every other part of the runner are byte-for-byte unchanged.