ADR-0011 — Machine backends behind one libpod-socket contract: podman, lima, native¶
- Status: Accepted (2026-06; native backend + lima spike on main via #25) — implemented;
lima mock-validated + first real multi-project Mac run 2026-07-05. Amended by
ADR-0027 (2026-09-17): the
nativebackend is retired — the socket contract and the two VM backends stand. - Sources: docs/lima-backend-scope.md (which now also holds the podman#26281 note), src/foldyard/machine_backend.py
Context¶
On macOS, podman machine with the applehv/libkrun providers allows one VM at a time — the
providers gate on RequireExclusiveActive (upstream podman#26281). For foldyard that means two
projects are mutually exclusive: the second project's machine can't start while the first runs,
so there is no "open box shell in project A while working in project B". Starting a second
machine fails with a cryptic upstream error.
Separately, Linux/WSL2 and CI hosts already have a rootless podman socket and sometimes cannot (or need not) run a VM at all — but the VM boundary is what makes "socket = repo + containers only" true (ADR-0001), so a VM-less mode is a weaker profile, not a convenience default.
The risk was forking the codebase per VM technology. Everything above the machine — compose, worktrees, the box, the proxy — only ever needs one thing from it: a socket to talk to.
Decision¶
Wrap VM lifecycle behind a small backend ABC whose entire downstream contract is a libpod
socket (src/foldyard/machine_backend.py). machine.socket() returns a podman unix:// URI
whichever backend is active; stack.py's CONTAINER_HOST/DOCKER_HOST export (ADR-0010),
compose, box, and worktrees are backend-blind — no --connection juggling anywhere.
The Backend ABC carries the lifecycle verbs (exists/state/create/start/stop/
remove/mounts/socket/guest_socket/list_running) plus one policy bit,
supports_concurrent(). Three implementations:
PodmanBackend(the default at decision time — see the 2026-08-29 amendment below) — wrapspodman machine;supports_concurrent()→False. The one-VM policy lives inmachine._start(), which checks for another running machine and prints guidance (stop it yourself, or switch backends) instead of surfacing podman's error. This is the portable floor and unchanged behaviour for existing users. If upstream ever lifts #26281, flippingsupports_concurrent()is the single switch.LimaBackend([machine].backend = "lima"— opt-in at decision time, the default since the 2026-08-29 amendment below) — wrapslimactl+ Lima's podman template (a real podman service in each VM, libpod socket forwarded to<instanceDir>/sock/podman.sock);supports_concurrent()→True, so per-project machines coexist.create()uses a--setexpression that pins sizing and replaces the template's mounts with only foldyard's isolation set (repo + worktrees root) — the propertyfy verifyasserts. Memory is emitted in MiB verbatim: a rounded-GiB string like"3.91GiB"is hard-rejected by macOS Virtualization.framework at every boot, bricking the VM (a real-Mac finding, fixed in_memory_mib).NativeBackend(explicit opt-in,[machine].backend = "native") — no VM at all; foldyard targets the host's rootless podman socket directly. Useful for Linux/WSL2 dev and cheap CI coverage, but it drops the VM boundary (containers share the host kernel, and the engine can see the whole filesystem), hence it is never auto-selected — a consumer opts in knowingly.
Selection is MACHINE_BACKEND env → [machine].backend toml → a default, which at decision time
was "podman" and is "lima" since the 2026-08-29 amendment below; an unknown name falls back to
podman with a warning rather than crashing the CLI.
Consequences¶
- Concurrent multi-project work is a config line, not an architecture change. Lima status is
honest: unit-tested against mocked CLIs (
test_machine_backend.py— selection, sockets, JSON parsing, the--setoverride), and the first real multi-project Mac run (2026-07-05) validated VM download/create/start end-to-end — while exposing a cross-project port collision fixed by per-project daemon port bands (src/foldyard/ports.py; see docs/lima-wall-machine-integration.md). The scope doc's "trickiest bits" (socket-forward stability across Lima versions, mount-replacement drift, sustained two-VM concurrency) remain the live validation checklist. - Lima bought more than concurrency: owning VM provisioning (root
provision:scripts) is what makes the fail-closed nftables wall possible ([machine].wall, ADR-0009) — a consequence of this decision, not a separate backend. - Backend differences that do leak are contained in the backend: podman machine exposes a
docker-compat guest socket at
/run/docker.sock, Lima's template forwards a rootless/run/user/<uid>/podman/podman.sock, native returns the host socket —guest_socket()absorbs all three, sobox.pymounts the right thing without knowing why. - The native backend gives CI a real-engine e2e tier without KVM (ADR-0017), at the documented
cost of the weaker isolation profile —
verify's VM-boundary claims do not hold there and must not be advertised as if they do. fy machine stop|rmverbs (plus a hiddendeletealias for limactl muscle memory) round out the lifecycle so a Lima user never has to drop tolimactlfor routine teardown. They also stop the project-scoped host supervisor: without a VM no box can use its proxy, and retaining it could leave generated checkout state behind.fy upandfy box upstart one before a box comes up.
Rejected alternatives¶
- Lima (or anything) replacing podman machine as the default: the podman backend is zero-dependency, battle-tested here, and behaviour-preserving; Lima stays opt-in until its real-Mac checklist is green. (Reversed 2026-08-29 — the checklist went green. See below.)
- VM-per-worktree instead of concurrent per-project VMs: worktrees are already isolated as namespaced compose projects on one machine (ADR-0004); the scarce resource was cross-project concurrency, which is exactly what #26281 blocks.
- Auto-selecting native when no VM tech is available: silently trading away the isolation boundary that is the product's core claim; the weaker profile must be an explicit choice.
- Abstracting at the CLI-verb level (a "run this podman-machine-ish command" shim): the
verbs differ too much (
limactl create --setvspodman machine init --volume); the socket is the stable seam, so that is the contract.
Amendment (2026-08-29) — lima becomes the DEFAULT backend¶
config.machine_backend() now falls back to lima, not podman. This reverses the rejected
alternative above, on the grounds it named: Lima stayed opt-in "until its real-Mac checklist is
green", and it has been green since the multi-project Mac run — meanwhile foldyard init has
scaffolded backend = "lima" + wall = true since it shipped, so every consumer created by the
supported path was already on Lima. The fallback was reaching only hand-written configs, and it
was quietly giving them the weaker of the two backends.
What tipped it beyond parity is that the gap is no longer just convenience. Two properties are Lima-only, and both are load-bearing:
- Concurrent per-project VMs. podman machine on macOS allows one at a time (#26281), so a
second project can't come up without stopping the first — and stopping it kills any open
box shell/codesession. - The in-VM fail-closed egress wall (
[machine].wall). podman machine's CoreOS appliance can't be provisioned with it. Without the wall, egress routing is cooperative — proxy env vars, which well-behaved software honours and malware need not. The wall is what makes the proxy the only way out, so defaulting to podman meant defaulting to the cooperative story on the product's central claim (ADR-0009).
This ADDS a dependency rather than swapping one, and that's the honest cost. The backend's
cli is the VM-lifecycle binary only; the engine CLI (podman, or docker where podman is
absent) is separate and required regardless, because it drives the socket the backend hands out.
So Lima is limactl plus podman, where the podman backend needed only what you already had.
That is the price of the two properties above, and it is why backend = "podman" remains fully
supported rather than deprecated.
Two mechanical consequences, both fixed here rather than left implicit:
- "Default" is now a property, not a name.
machine.ensure()andpreflightboth used to special-case the literal"podman"to skip quietly when its CLI was missing — the intent being "don't abort over a backend the consumer never chose" (a Linux/CI host has no VM tooling and needs none). With the default flipped, that literal would have inverted the rule. Both now key onconfig.machine_backend_explicit(): silent skip when nobody named a backend, hard error when somebody did. brew install limactldoes not exist. The install hint was interpolated frombackend.cli, which is right for podman and wrong for Lima (the formula islima). Now a per-backendinstall_hint, because the first thing a new default sends people to must not 404.
Existing consumers are unaffected in practice — fy init wrote the backend explicitly, and an
explicit value still wins. A repo that relied on the old fallback should pin
backend = "podman" rather than migrate implicitly: switching backend means a new VM, so the old
machine's box, named volumes and warm caches are orphaned rather than carried over.