Plan — the vz macOS local backend (lima)

This plan delivers vz, ZTD’s fourth backend and its first for macOS operators: a disposable, isolated Linux guest running locally on Apple’s Virtualization.framework, driven by lima. It is the headline item of Phase 3 (roadmap).

It is delivered as a sequence of phases, each executed as one Opus-orchestrated red/green/verify cycle — see the red-green-verify skill. Do not attempt the whole backend in one pass; lock one behavioral contract per phase.

Why this is a fresh bring-up, not “another Terraform module”

The three existing backends (kvm, proxmox, ec2) are Terraform modules selected by var.target and driven by Terraform inside the Docker toolbox. vz cannot follow that shape, for two hard reasons the SPEC already called out (§10, “vz/macOS is a fresh bring-up”):

  1. lima has no usable Terraform provider, and it drives Virtualization.framework, which only exists on the macOS host — not inside a Linux container. So vz is a host-side, limactl-driven backend, the way the kvm backend’s IP resolution and health-gate are host-side virsh.
  2. The virtiofs operator-owned-files win does not port. lima shares the host tree over its own mount stack (virtiofs on the vz VM type, or reverse-sshfs), with different uid-mapping semantics. Repo-mount ownership is re-solved here.

The lever that makes this tractable: the Go CLI (cli/) already models the host-lane vs container-lane split (host_verbs.go / container_verbs.go, hostLane() / lane.go, readiness.DefaultChecks(target)), and every agent verb is already host-side and backend-agnosticztd auth, skills, commands, run, attach, mount, fetch, audit all reduce to ssh <opts> dev@<ip> … over the ephemeral key. So vz slots in as a new target whose up / down / ip / status / health-gate run host-side via limactl, and everything downstream is reused unchanged iff we can hand those scripts a plain dev@<ip> SSH endpoint on the ephemeral key.

Phase-0 spike findings (validated 2026-07-28, lima 2.2.0, Apple Silicon)

A throwaway spike booted a vmType: vz Debian arm64 guest and settled every open unknown. The results simplified the design — read these before the phases.

  • Guest reachability → lima’s forwarded SSH port, NOT socket_vmnet. lima’s default user-mode networking already forwards a host port (127.0.0.1:<port>) straight to the guest’s real sshd, which honors any authorized_key we install. The spike proved ssh -p <port> -i .ztd/keys/id_ed25519 dev@127.0.0.1 logs in as dev, and a foreign key is rejected — §3 holds — with no socket_vmnet and no sudo. So the privileged install is dropped from the baseline: the vz host footprint is Docker + lima only, and ztd setup is a no-op for vz (like the remote backends). socket_vmnet survives only as an optional alternative for operators who want a real bridged IP. Cost of the port model: the guest endpoint is 127.0.0.1:<port>, not <ip>:22, so the ssh layer must thread a port through — this is the one genuine new integration surface (Phase 1/4).
  • Guest identity → the ztd ephemeral key via a lima provision script. lima takes no raw cloud-init user-data blob — it generates cloud-init from its own user/ssh/mounts fields plus provision: scripts. The spike confirmed a mode: system provision script cleanly creates the dev user (uid 1000, coexisting with lima’s own management user), writes the ephemeral pubkey as dev’s sole authorized_key, and installs packages via apt. So ZTD reuses the content of cloud-init/user-data.yaml.tftpl (the dev user + install steps) but adapts the delivery to lima’s provision/user/mounts model — it does not feed the template verbatim (revises the earlier assumption).
  • Repo mount ownership → works out of the box. Files created in the guest — as root and as the dev user — both land on the host owned by the operator’s uid. lima’s vz virtiofs mount squashes to the host-operator uid, so the kvm virtiofs ownership guarantee effectively ports to macOS with no ACL/shim. Phase 3 is de-risked to “configure + assert,” not “solve.”
  • Image → the genericcloud arm64 image is fine on vz. Unlike the kvm backend (which needs generic because libvirt virtio-fs wants the fuller driver set), lima’s vz virtiofs mount worked on the slim genericcloud image (lima’s default). No need to force generic.
  • Boot speed: clean boot + provision to READY in ~40s.

Hard constraints / non-goals

Constraints

  • Host footprint stays minimal: macOS host needs only Docker + lima (lima pulls in Virtualization.framework, which ships with macOS). No Terraform on the host — it stays in the toolbox and is not used by vz at all.
  • Reuse, don’t fork, the shared surfaces: the content of cloud-init/user-data.yaml.tftpl (dev user + Docker/Node/Claude install steps), re-expressed as a lima provision script since lima takes no raw cloud-init blob (spike finding); the existing ephemeral-key lifecycle (.ztd/keys/, generated host-side, discarded on down); the existing host-side agent verbs (auth/skills/commands/run/attach/mount/fetch/audit) against the vz endpoint — unchanged except that the ssh layer must accept a port (the vz endpoint is 127.0.0.1:<lima-port>, not <ip>:22).
  • Same UX: TARGET=vz ./ztd up|ssh|down|status|test behaves like the other backends. vz is selectable via [vm] backend = "vz" in .ztd/.ztd.toml or TARGET=vz.
  • Apple Silicon first. Primary target is arm64 (Debian arm64 guest); Intel Macs are best-effort and not gated by the acceptance suite.
  • Security property §3 holds: per-VM ephemeral key only; the operator’s personal key is never injected; the guest holds no forge credentials.
  • All new host-side tooling is shell in scripts/ + a lima template, wired into the Go CLI’s host lane — matching the repo’s existing host-side pattern (mount-remote.sh, run-agent.sh, …). Nothing vz-specific goes in the toolbox Taskfile.yml (it runs in the container, which has no lima).

Non-goals (this plan)

  • The rest of Phase 3 (egress allowlist / Technitium DNS, standalone audit sink, auto-destroy/TTL, more clouds) — tracked separately.
  • Intel-Mac certification, nested virt, or tart/other macOS VM drivers — lima only.
  • Any change to the kvm/proxmox/ec2 backends beyond additive dispatch seams.
  • Multi-tenant guest isolation or guest-from-host protection (unchanged from v1).

Repository layout

Phases populate these paths (⊕ = new, ✎ = edited):

lima/
  ztd.yaml.tmpl          ⊕ lima instance template (the "vz module"): vmType vz,
                            arch, cpus/memory/disk, the host repo mount, the dev
                            user + ephemeral key, and a provision: script carrying
                            the shared cloud-init's install steps.
scripts/
  vz.sh                  ⊕ host-side vz dispatcher: up|down|endpoint|status
                            subcommands (limactl-driven; the virsh-analog for vz).
                            endpoint resolves 127.0.0.1 + the lima forwarded SSH
                            port (limactl list --format '{{.SSHLocalPort}}').
  check-requirements.sh  ✎ add a vz branch: macOS + Virtualization.framework, lima
                            installed, vz VM type available. (No socket_vmnet in the
                            baseline — see the spike findings.)
  setup-host.sh          ✎ vz is a NO-OP (the baseline needs no privileged setup);
                            leave a stub that says so, matching remote backends.
  smoke-test-vz.sh       ⊕ TARGET=vz ./ztd test — the vz acceptance suite, mirroring
                            smoke-test.sh's numbered assertions.
cli/
  cmd/lifecycle.go       ✎ route up/down/ip/status to the HOST lane (scripts/vz.sh)
                            when cfg.Target == "vz" (else the container lane).
  cmd/smoketest.go       ✎ smokeTestScript("vz") → scripts/smoke-test-vz.sh.
  cmd/check.go / setup   ✎ host preflight/setup already host-lane; ensure vz env.
  readiness/readiness.go ✎ treat "vz" like "local" for the repo-mounted check.
cloud-init/
  user-data.yaml.tftpl   ✎ only if lima cloud-init needs a documented tweak
                            (e.g. guest-agent package differs); prefer no change.
docs/content/
  plans/vz-macos-backend.md   ⊕ this document.
  how-to/vz-backend.md        ⊕ operator how-to (install lima, setup, run).
  reference/backends.md       ✎ add the vz row.
  explanation/roadmap.md      ✎ tick vz done under Phase 3.

Execution model — phased red/green/verify

Each phase is one Opus-orchestrated red/green/verify cycle (red-green-verify skill). This plan is the what; the skill is the how — it is not restated here.

Roles, per phase. An Opus orchestrator drives the phase: fires the workers, then reviews the result adversarially and restarts until it holds. Red = Opus (failing tests/assertions), Green = Sonnet (implementation), Verify = Haiku (independent re-run + cheat check).

Two test characters in this backend (split phases accordingly):

  • Unit-testable (Go). The CLI dispatch seams — smokeTestScript, target→lane routing, readiness check selection — are pure Go with existing test files (cli/cmd/smoketest_test.go, host_verbs_test.go, readiness/readiness_test.go). These phases are true red/green: a failing go test, then code.
  • Host-acceptance (real Mac). Booting a lima guest, cloud-init parity, the repo mount, and the agent lifecycle can only be proven on a real macOS host — exactly like kvm acceptance, which the SPEC notes needs self-hosted runners. For these, “red” = a new numbered assertion in smoke-test-vz.sh that fails against the not-yet-built backend, and “green” = implement lima/cloud-init/ scripts until that assertion passes. Verify = re-run the suite on the Mac and confirm the assertion (and all prior ones) pass. This mirrors how smoke-test.sh already gates the kvm backend with explicit asserts.

Shared commands (host-side dev tasks; the toolbox has no lima):

  • Go unit gate: task go:test · task go:vet · task go:fmt -- -l
  • vz acceptance: TARGET=vz ./ztd test (→ bash scripts/smoke-test-vz.sh)
  • boot a guest by hand for inspection: TARGET=vz ./ztd up then TARGET=vz ./ztd ssh
  • docs: cd docs && task build:docs (hermetic Docker; refLinksErrorLevel=ERROR fails the build on a broken {{< relref >}})

Global rules for every phase (check the parent box only once every child box is checked, including the docs update):

  • Red fails at runtime, never at compile — Go stubs that compile with no logic are expected; a smoke-test-vz.sh assertion fails because the behavior is absent, not because the script errors out.
  • Off-limits to every green agent: every *_test.go, the smoke-test-vz.sh assertions themselves, the ephemeral-key material under .ztd/keys/, and the shared cloud-init/user-data.yaml.tftpl unless the phase explicitly edits it (it is a cross-backend contract — a vz-only need must not regress the others).
  • Verify, every phase: re-run the phase’s test command; git diff the tests / assertions to prove green edited neither; for host-acceptance phases, boot a real guest and observe the behavior, don’t trust a green log alone.
  • A phase is not done at green. After verify passes, the orchestrator runs the skill’s assume-bad review (fresh reviewer: hunt regressions in the other three backends, missed edge cases, half-done cloud-init). The per-phase Done when is the green target; the phase closes only when that review finds nothing.
  • Restart test-first: every review finding becomes a new failing test / assertion, then green, then verify. Two non-converging restarts → stop and surface the sticking point (esp. any lima/socket_vmnet behavior that fights the design — that is a decision to escalate, not to hack around).
  • A phase’s red tests/assertions must stay green in all later phases — later phases only add.
  • Close each phase by updating docs, rebuilt with cd docs && task build:docs.

Phase 0 — Bootstrap (setup, not a red/green cycle)

Stand up the harness so later red has something to fail against, and de-risk the two unknowns before committing to phases 1–3.

  • Spike the unknowns on this Mac (done 2026-07-28) — see Phase-0 spike findings above. Outcome: reachability via lima’s forwarded SSH port (no socket_vmnet/sudo); guest setup via a lima provision script (no raw cloud-init); mount ownership squashes to the operator uid out of the box; genericcloud arm64 works. Design updated accordingly.

  • Phase 0 complete

    • Create lima/ztd.yaml.tmpl (placeholders for arch/cpus/memory/disk/ image/repo-source/mount-point/dev-user/ephemeral-pubkey + provision) — rendered by scripts/vz.sh via a pure-sed pass.
    • Create scripts/vz.sh with an up|down|endpoint|status|ssh subcommand dispatch (skeleton first — errored “not implemented” — then filled in Phase 1).
    • Create scripts/smoke-test-vz.sh: the same preamble/assert helper and cleanup trap as smoke-test.sh, driving vz.sh directly.
    • Add a Plans docs section (docs/content/plans/_index.md, weight 8) and this document; cd docs && task build:docs green.

    Tooling gotchas to record here: lima runs host-side only (never in the toolbox); Apple Silicon guest arch is aarch64 in lima’s vocabulary (Debian calls it arm64); use a short LIMA_HOME — a deep path overruns the UNIX_PATH_MAX=104 ssh-socket limit (ztd invokes lima from the repo root, so point LIMA_HOME at a short dir, e.g. ~/.lima, not .ztd/lima).

Phase map

#DeliversSpec refs
0Bootstrap: spike, skeletons, Plans docs section§0
1Boot a lima guest + endpoint (forwarded port) + ephemeral-key SSH + port threading§1, §2, §3
2Guest setup parity via lima provision (Docker + Node + Claude); readiness green§4
3Repo live-mount with operator-owned host files§5
4Go CLI: route vz up/down/endpoint/status to the host lane; check/setup/smoke dispatch§6
5vz acceptance suite + full agent lifecycle (auth/skills/run/attach/audit/fetch)§7
6Docs, SPEC + CLAUDE.md gotchas, roadmap tick (non-test)§8

Phase 1 — Boot a guest + endpoint resolution + ephemeral-key SSH

Decomposition note: Phase 1 is self-contained shellscripts/vz.sh is drivable directly (TARGET=vz bash scripts/vz.sh up|endpoint|ssh|down), carries its own port-aware ssh subcommand, and ensures the ephemeral key itself (an idempotent no-op once the Go EnsureKey runs in Phase 4). This keeps Phase 1 verifiable on a Mac with zero Go churn — the Go lane-routing + global SSH port-threading (Taskfile SSH_OPTS, the Go ssh helper) is Phase 4, so it can’t collide with the parallel Phase-2 CLI work.

  • Phase 1 complete
    • Redscripts/vz.sh skeleton (subcommands error “not implemented”) + scripts/smoke-test-vz.sh driving bash scripts/vz.sh directly. Confirmed RED: the suite failed at step 1 (vz.sh up not implemented).
    • Green — implemented lima/ztd.yaml.tmpl (vmType vz, arch, resources; ssh.loadDotSSHPubKeys: false; a mounts: entry; a provision: mode: system script creating the dev user + the ephemeral .pub as its sole authorized_key) and scripts/vz.sh up/down/endpoint/status/ssh (pure-sed render, ephemeral-key ensure, short LIMA_HOME, forwarded-port endpoint, limactl delete -f on down). Smoke test green on a real boot: 9/9 passed.
    • Verify + review — real boot verified (9/9). Independent adversarial review done; fixed test-first and re-verified 9/9: portable POSIX [[:space:]] in the leak-check assertions (was non-portable \s — silent false-pass risk), up keyed on instance existence (recovers Broken/Installing, not just Stopped), BatchMode=yes on the ssh probe (no interactive hang), sed-replacement escaping for arbitrary repo paths, and a step-3b endpoint guard.
    • Done when: TARGET=vz bash scripts/vz.sh up yields an ssh-reachable dev@127.0.0.1:<port> on the ephemeral key (foreign key rejected), and down fully removes the instance. ✅
    • Update docs: how-to/vz-backend.md (boot/ssh/endpoint/down via vz.sh, forwarded-port model); linked from the how-to index; docs build green.

Phase 2 — Guest-setup parity (via lima provision)

  • Phase 2 complete
    • Red — added smoke-test-vz.sh step-4 assertions: cloud-init status done; sudo docker run --rm hello-world (guest’s OWN daemon); node --version (v20); claude --version (install-only, offline); tmux present. Confirmed RED: docker/node/claude/tmux all failed against the Phase-1 guest. (The readiness.DefaultChecks("vz") Go unit test moved to Phase 4 with the rest of the Go changes — same shell-first rationale as Phase 1.)
    • Green — extracted the canonical cloud-init/install-agent-stack.sh (Docker daemon + Node 20 + Claude Code + git/rsync/tmux; no qemu-guest-agent — vz doesn’t need it) and embedded it verbatim into a 2nd lima provision: mode: system entry (rendered by vz.sh). Also granted dev passwordless sudo in the template (parity with the other backends). Smoke test green on a real boot: 14/14. limactl start blocks until provision finishes, so the stack is ready when up returns.
      • Deferred (noted): the Terraform tftpl was NOT refactored to consume install-agent-stack.sh this phase — kvm/proxmox/ec2 can’t be boot-tested on this Mac, and the TF root is shared with the parallel Phase-2 CLI work. The two carry the same steps with a cross-referencing sync-comment; true unification is now tracked as Phase 1 of the Backend cohesion & parity plan (best done on a Linux host).
    • Verify + review — real boot verified (14/14). Independent adversarial review done; fixed test-first and re-verified 14/14: added set -o pipefail (a failed curl|sh download no longer masks as success), made the install idempotent + network-free on lima’s every-boot re-provision, and added a USERNAME validation guard (protects the provision export line + the guest sudoers file/filename). Drift vs the tftpl confirmed clean (only the intended qemu-guest-agent omission).
    • Done when: a vz guest finishes cloud-init done with a working guest Docker daemon, Node 20, and Claude Code installed. ✅ (14/14)
    • Update docs: how-to §“what the guest comes with” (+ status note); docs build green. (Full reference/backends.md vz row lands in Phase 6.)

Phase 3 — Repo live-mount with operator-owned host files

De-risked by the spike (ownership squashes to the operator uid out of the box) — this phase is mostly “wire the real repo-source mount + assert,” not “solve.”

  • Phase 3 complete
    • Red/Green — added smoke-test-vz.sh step-5 assertions: guest work dir /home/dev/<dirname> exists; host→guest live view; and a file created in the guest as dev is operator-owned on the host (BSD stat -f %Su). No red: the mount was already configured in the Phase-1 template and the spike proved the uid-squash, so the assertions passed on first run — this phase is the coverage, not new implementation (as the note above anticipated: “wire + assert, not solve”). No mounts: change was needed. 17/17 green on a real boot — guest-created file owned by fanderson on the host.
    • Verify + review — real boot verified (17/17), both directions checked; the guarantee (guest-created → operator-owned) holds. Self-review only (this phase added no production code — just test assertions over Phase-1 mount code that was already independently reviewed): confirmed the probe files match the .ztd-smoke-* gitignore + are cleaned by the EXIT trap, BSD stat -f %Su is correct for the macOS host (vs the kvm suite’s GNU stat -c %U on Linux), and the checks can’t false-pass (a missing mount fails test -d / the reads).
    • Done when: the working tree is a live bidirectional share and guest-created files are operator-owned on the host. ✅ (17/17)
    • Update docs: how-to §“repo live-share” — how vz file-sharing works (lima vz virtiofs uid-squash) and the ownership guarantee on macOS.

Phase 4 — Go CLI: route vz to the host lane

  • Phase 4 complete
    • Red/Green — new cli/cmd/vz.go (isVZ, vzSubverb, dispatchLane): the vz backend routes up/down/reset/status/ssh/logs/tunnel (+ ip→endpoint) to the host lane (bash scripts/vz.sh <sub>); Terraform-only verbs (plan/init/validate) error with guidance. Wired through lifecycle.go (up/reset/down — EnsureKey/RemoveKey unchanged), container_verbs.go, status.go (non-watch + --watch via captureVZEndpoint + SSHProbePort), tunnel.go; smokeTestScript("vz"); readiness.DefaultChecks treats vz like local; readiness gains a port-aware SSHProbePort. check/setup host scripts got vz branches (lima preflight; setup no-op). Tests: cli/cmd/vz_test.go
      • cli/readiness/vz_test.go + the smoketest vz case. The SSH-port threading lives entirely in the vz host lane (vz.sh owns the forwarded port) — the global Taskfile SSH_OPTS was NOT touched, so kvm/proxmox/ec2 stay byte-for- byte identical (cleaner than the planned global thread).
    • Verify + reviewtask go:test/go:vet/go:fmt -l all clean; full Go suite green (vz routing + no regression to the other backends). End-to-end on the Mac through the binary: TARGET=vz ./ztd check (lima preflight) → up (EnsureKey + boot, endpoint printed) → ssh (dev, ephemeral key) → status (Running) → down (instance deleted + key removed); and TARGET=vz ./ztd test → 17/17. Independent adversarial review: clean across all six priority categories (no regression, no actionable bug).
    • Done when: TARGET=vz ./ztd up|down|ip|status|ssh|test dispatch to scripts/vz.sh / smoke-test-vz.sh via the host lane, other three backends unchanged. ✅
    • Update docs: regenerated the CLI reference (task go:docs — no surface change); how-to updated to note TARGET=vz ./ztd … now works.
    • Flagged for Phase 5 (review’s one substantive note): ztd ip returns two tokens (127.0.0.1 <port>) for vz, so the agent-lifecycle host scripts (auth-sync.sh/mount-remote.sh/agent-sync.sh/run-agent.sh/audit.sh/ fetch-remote.sh), which all do IP="$(./ztd ip)"; ssh dev@$IP, need port-awareness before they work on vz — that IS Phase 5.

Phase 5 — Acceptance suite + full agent lifecycle on vz

  • Phase 5 complete
    • Red/Green — the port-threading enabler: a shared scripts/guest-endpoint.sh (resolve_guest_endpoint splits ./ztd ip into IP + PORT; bash-3.2-safe), sourced by the four ssh scripts (auth-sync/agent-sync/run-agent/audit), each appending [ -n "$PORT" ] && SSH_ID+=(-p "$PORT") (a no-op for the other backends, so their argv is byte-identical). mount/fetch gained vz to their local no-op guard (vz live-shares the tree). Two real bugs the boot surfaced + fixed: (a) the lima repo mount pre-creates /home/dev root-owned, so the agent user couldn’t write ~/.claude/~/.ztd — fixed with a NON-recursive chown dev:dev /home/dev in the provision; (b) name consistency — the smoke test now exports BOTH NAME (Go reads it) and ZTD_NAME (vz.sh reads it) so the Go-routed agent verbs and vz.sh target the same instance. Smoke test 20/20 on a real boot (step 6: mount/fetch no-op, skills reaches the guest over the forwarded port, auth conditional).
    • Verify + reviewTARGET=vz ./ztd test 20/20 on the Mac. run + audit manually verified against the vz guest (tmux session up + host manifest written + audit exported manifest/console/transcript over the port-threaded rsync) — kept OUT of the repeatable suite because their launcher does git checkout -b in the shared tree (switches your real branch — the Go binary forces ZTD_REPO_SOURCE to the repo root, so it can’t be redirected to a throwaway). This matches the kvm/proxmox/ec2 suites, which also omit run/audit. Independent adversarial review: regression/chown/sourcing/port-placement all clean; fixed its two findings test-first — a bash-4 ${ASSET^^} in agent-sync.sh (fatal on macOS bash 3.2 for ZTD_SKILLS=none; now tr-based, verified on 3.2) and an unexported TARGET in the smoke test (now exported for direct-run safety).
    • Done when: TARGET=vz ./ztd test passes the full suite — boot → SSH → guest Docker + Claude → repo share → mount/fetch no-op + skills/auth plumbing — on a real macOS host; run/audit verified manually. ✅ (20/20)
    • auth caveat: delivery needs a host credential FILE (~/.claude/.credentials.json); macOS keeps the Claude login in the Keychain, so ztd auth is a documented no-op there until Keychain export lands (pre-existing limitation, noted in CLAUDE.md). The suite info-skips it when the file is absent.
    • Update docs: how-to §“Run an agent” (lifecycle, mount/fetch no-op, the result-branch-in-your-tree local caveat, the auth/Keychain caveat); docs green.

Phase 6 — Docs, SPEC + CLAUDE.md, roadmap (non-test)

Not a red/green cycle — an implement-and-review pass.

  • Phase 6 complete
    • Wrote docs/content/how-to/vz-backend.md end-to-end (install lima, ztd check, boot, what-the-guest-has, connect, live-share, run-an-agent, teardown, how-it-differs); status note reconciled to “available”.
    • Added the vz row to reference/backends.md (+ reworked the intro so it no longer claims every backend is Terraform, and the macOS section); ticked vz under Phase 3 in explanation/roadmap.md; updated the _index.md status line.
    • Updated SPEC.md (§4 backends table vz ✅ + host-footprint/macOS note, §10 folded the bring-up findings, §11 closed open-question 1 as RESOLVED=lima) and CLAUDE.md (status header, How-to-run backend reqs, Layout, a full “vz gotchas” block, Roadmap Phase 3 tick).
    • Verify by review: the how-to carries the clean-Mac→green path (brew install limaztd checkup → … → test); re-read for coherence and fixed the stale “still to come” status note; task build:docs green (no broken relref).
    • Update docs: this is the docs phase — build green is the gate. ✅

Specification

§0 — Backend contract mapping (the six ops, for vz)

The SPEC (§4) defines a backend as six operations. For vz:

Opvz implementation
1 host-readiness (ztd check)macOS + Virtualization.framework, lima installed, vz VM type available — scripts/check-requirements.sh vz branch. (No socket_vmnet in the baseline.)
2 host-setup (ztd setup)no-op — the forwarded-port model needs no privileged setup (like the remote backends).
3 provisionlimactl start the rendered lima/ztd.yaml.tmpl (not Terraform).
4 health-gatelimactl start blocks until READY (its own boot-requirement gate); then confirm ssh-reachable on the ephemeral key.
5 endpoint resolution127.0.0.1 + lima’s forwarded SSH port (limactl list --format '{{.SSHLocalPort}}'), via scripts/vz.sh endpoint. Re-resolve each call — the port changes across stop/start.
6 orphan reconciliationlimactl list vs expected instance; a vz analog of list:orphans/purge (can be minimal in v1 — limactl delete -f).

§1 — lima/ztd.yaml.tmpl

Rendered host-side by scripts/vz.sh (envsubst or the Go CLI later). Fields: vmType: vz; arch: aarch64 (from var.arch); cpus/memory/disk from the same config keys as the other backends; default user-mode networking (no networks: block — reachability is lima’s forwarded SSH port); a mounts: entry sharing the host repo-source at the guest work dir with writable: true; and a provision: mode: system script that creates the dev user, installs the ephemeral .pub (from var.ssh_public_key_path) as its sole authorized_key, and runs the shared install steps (Docker/Node/Claude). lima takes no raw cloud-init blob — the provision script is the delivery vehicle.

§2 — Endpoint resolution (scripts/vz.sh endpoint)

Print 127.0.0.1 <port>, where <port> is lima’s forwarded SSH port (limactl list <name> --format '{{.SSHLocalPort}}'). Re-resolve on every call — the port is assigned per limactl start and changes across a stop/start; never cache it. Must tolerate the instance not being up yet (empty port → non-zero exit with a clear “is it up?” message), matching the ip task’s contract on the other backends. (Optional socket_vmnet mode, if ever enabled, would instead return a real IPv4 + port 22 — the <host> <port> shape covers both.)

§3 — Ephemeral key + §3 security property

The vz guest’s sole authorized_key is the per-VM ephemeral public key the ztd wrapper already generates into .ztd/keys/ before up and discards on down. lima’s own management user/key may coexist for limactl’s lifecycle control, but operator/agent access is exclusively dev@<ip> on the ephemeral key. The acceptance suite asserts a foreign key is rejected — same assertion the kvm/proxmox/ec2 suites make.

§4 — Guest-setup parity (via lima provision)

vz reaches the same guest end state as the other backends — dev user, Docker daemon, Node 20, Claude Code, repo dir — but delivered through a lima provision: mode: system script, not the cloud-init ISO (lima owns cloud-init and takes no raw user-data). Keep a single source of truth for the install steps so the tftpl runcmd and the lima provision script cannot drift. Drop qemu-guest-agent for vz (it was only the IP source on kvm/proxmox; vz resolves its endpoint from lima). Any refactor of the shared steps is guarded so it cannot regress the Terraform-driven backends (terraform validate stays clean).

§5 — Repo live-mount + ownership

lima mounts the host repo-source into the guest work dir. Ownership target: files created in the guest are operator-owned on the host, matching the kvm virtiofs guarantee. The mechanism (lima virtiofs uid-reflection, or a documented mapping/shim) is fixed by the Phase-0 spike and encoded in Phase 3’s assertion. If full reflection is impossible on macOS, the reviewed fallback is documented as a known vz caveat — not silently shipped.

§6 — Go CLI dispatch

cfg.Target == "vz" routes up/down/endpoint/status to the host lane (scripts/vz.sh <verb>), and smokeTestScript("vz") → scripts/smoke-test-vz.sh. check/setup are already host-lane (setup is the vz no-op). The ssh helper accepts a port (default 22; the lima forwarded port for vz). readiness.DefaultChecks treats vz like local for the repo-mount check (a true mountpoint test). The other three backends’ dispatch is unchanged. Pure functions get Go unit tests; the routing is proven end-to-end from the built binary.

§7 — Acceptance suite (scripts/smoke-test-vz.sh)

Mirrors smoke-test.sh: a step-0 config-loader preflight, clean-slate downup, then numbered asserts covering IP + ssh (ephemeral key, foreign key rejected), cloud-init done, guest Docker, Node + Claude installed (offline), repo live-share + ownership, and the agent-lifecycle plumbing (auth/skills/ commands/run via ZTD_RUN_CMD/audit/fetch). Never runs the agent against Anthropic. Requires a real macOS host (self-hosted runner in CI).

§8 — Docs surface

How-to (vz-backend.md), reference backends row, roadmap tick, _index.md status line, plus SPEC.md (§4/§10/§11) and CLAUDE.md (status + vz gotchas). Docs build is hermetic Docker with refLinksErrorLevel = "ERROR".


Taskfile

vz adds no tasks to the toolbox Taskfile.yml — that file runs inside the container, which has no lima. All vz host-side work is scripts/vz.sh + scripts/smoke-test-vz.sh, dispatched by the Go CLI’s host lane (the same mechanism behind check/setup/test/mount/fetch/run/audit). The existing host-run Go dev tasks (go:test, go:build, go:vet, go:fmt, go:docs) are the unit-gate for the CLI phases and are unchanged. This is a deliberate, documented deviation from adding backend verbs to the container Taskfile — consistent with how every other host-side ZTD operation already works.


Definition of done

  • TARGET=vz ./ztd check passes on a host with only Docker + lima installed (./ztd setup is a documented no-op for vz).
  • TARGET=vz ./ztd up boots an Apple-Silicon Linux guest that is ssh-reachable at dev@127.0.0.1:<lima-port> on the ephemeral key (foreign key rejected), with its own Docker daemon, Node 20, and Claude Code — from the shared install steps delivered via lima provision.
  • The host working tree is a live bidirectional share and guest-created files are operator-owned on the host (lima vz virtiofs uid-squash).
  • The host-side agent lifecycle works against the vz endpoint over the port-threaded ssh: skills/commands/run/attach/audit ✅; mount/fetch are no-ops (local live-share); auth is a documented no-op on macOS (Keychain, not a cred file) — the one gap.
  • TARGET=vz ./ztd test is green (20/20) on a real macOS host; task go:test/ vet/fmt clean; kvm/proxmox/ec2 unregressed (dispatch byte-identical for the non-vz branch; adversarial-review-confirmed).
  • Docs build green (no broken relref); SPEC.md + CLAUDE.md updated; roadmap ticks vz.
  • Every testable phase (1–5) landed via a red/green/verify loop with an independent adversarial review, each closed by its docs-update box; Phase 6 was the implement-and-review docs pass.

Post-completion refinement — local is platform-native

Beyond the original plan: local now means “this machine’s own hypervisor” — it resolves to vz on macOS and stays libvirt on Linux, so the one cross-platform binary does the right local thing without the operator typing TARGET=vz. A pure config.LocalBackendFor(target, goos) (mapping bare localvz iff goos=="darwin"; explicit kvm/vz/proxmox/ec2 pass through) is applied at the cobra root keyed on runtime.GOOS (injectable via WithGOOS for deterministic tests). Verified: config.TestLocalBackendFor (pure), TestLocalTargetIsPlatformNative (root wiring: darwin→vz host lane, linux→container lane), and a bare ./ztd check/ status on a real Mac resolving to vz. Two Linux-assuming cmd tests were pinned to WithGOOS("linux"). Docs (how-to/reference/SPEC/CLAUDE.md) updated.