Wire a microvm guest into the host that starts it #39

Merged
vnprc merged 7 commits from agent/microvm-host-guest-join into master 2026-08-22 02:40:30 +01:00
Member

A hypervisor can now actually start a microVM guest. Until this change nothing connected the two halves: a guest declared which credentials it expects by name and supplied no values, the host asserted that both sides agreed, and no host ever composed a guest — so that agreement was checked against nothing. The host now hands each guest real file paths derived from its own credential root.

Nothing changes for any machine that exists today. No machine selects the microVM runtime, so all four example machines build to byte-identical derivations against current master, and a hypervisor whose fleet has no microVM machine composes none of the new modules at all — measured, not assumed.

What proves it: one check builds three hypervisor fixtures, forces each to a real buildable configuration, compares the two sides' actual values, then reads the resulting systemd drop-in and QEMU arguments at a non-default credential root. A second check walks every hypervisor this flake exports and requires each machine whose inventory entry selects the microVM runtime to be started by exactly one of them, so a deployment cannot silently un-wire one. The whole gate passes at 21 steps.

If this is wrong, revert the branch. Nothing here runs on a host, no deployed state or credential source is touched, and no machine starts a guest on the strength of it.

The owner review ruled on the five judgment calls below and sent two back: close the empty-guest-set seam, and stop overriding restartIfChanged. Both are applied in f39197a, and the branch is merged up to master with the flake.lock conflict resolved.


What this implements

Closes allod/archetypes#38, Refs allod/strategy#20 — contracts 1a, 8b and 17, all in allod/archetypes.

  • One crossing, in one direction. mkHypervisor calls extendModules on each selected guest configuration, adding one module whose microvm.credentialFiles is built from the host's evaluated nexus.microvm.hostPlaintextRoot and the guest's own declared credentialNames. allod/nexus is not modified, never imports allod/archetypes, and never restates the name set.
  • The host runs the extended guest. That extended result is assigned to microvm.vms.<name>.evaluatedConfig, and install-microvm-<name> installs its runner — a different store path from the standalone result's.
  • Which guests a host starts is pinned at the output. microvmGuests stays a free builder argument so checks can drive mkHypervisor with a guest set no inventory supplies; what a deployment consumes is constrained instead. checks/microvm-fleet-wiring.nix walks the exported hypervisors and requires every microVM machine to appear in exactly one host's guest set.
  • restartIfChanged is left at upstream's default, which is false for an evaluatedConfig-shaped VM. A microVM guest is its own deploy target the way this fleet's libvirt VMs are, so a host rebuild stages the new guest boot image and leaves the running guest alone. The check pins the rendered X-RestartIfChanged=false rather than the option, so an upstream default change goes red.
  • One revision set. nexus.inputs.vm.follows = "vm" collapses the lock from 28 nodes to 18, leaving exactly one allod/vm and one microvm.nix node. No revision moves; the two copies already happened to agree, which is precisely the coincidence this removes.
  • Values are strings, never Nix paths. A path literal would copy the host's credential into the world-readable store. The guest module already rejects a non-string, relative or store-prefixed value, so the extension is checked by the configuration it extends.

Owner review decisions

The five judgment calls this PR raised, with what the owner ruled and what changed.

1. The join also sets the guest's microvm.socket, which the issue does not mention. Kept as is. allod/nexus refuses upstream's default — a bare "<hostName>.sock", relative to the unit's working directory — and requires a normalized absolute path strictly below /run/allod, outside both the plaintext root and the namespace roots (nix/microvm/host.nix contract 8). Nothing in allod/archetypes or allod/vm sets it. So composing the host module produces a configuration that does not evaluate at all until someone does, and the issue's whole goal is a host that can start a guest. The join sets /run/allod/microvm-qmp/<machine>/control.sock, the layout nexus's own host fixtures already assume, as a literal rather than an option because no deployment has needed to move it. The owner's ruling: not configurable, because a fixed value cannot be misaimed and this PR's own overlap guard documents what settable roots cost; not in allod/vm, because a guest must not know host filesystem layout; and exporting it from allod/nexus beside the launcher that deletes at it is a defensible follow-up issue rather than a blocker, since contract 8 already prevents silent drift of the current split.

2. Each wired VM sets autostart = false. Accepted. Upstream defaults it to true and microvms.target then wants the unit, so wiring a guest without disarming it would make a host rebuild start it. allod/strategy#20's agent gates make "enable microvm autostart for a real machine" a human-only act, so the framework declares the VM and a deployment opts in. Measured: with the line removed, config.microvm.autostart comes back as [ "allod-dev" ].

3. The join asserts what allod/nexus cannot, and nothing more. Accepted. For every wired guest, its credentialFiles keys must equal its declared credentialNames — the one property nexus cannot state, because it must not learn the name set. The value rule stays in nexus, which already owns it; restating it here would duplicate a fact.

The rule is scoped to the evaluatedConfig shape, the only shape this builder produces. A deployment reaching past the join with upstream's config or flake shape is nexus's to judge, and it does: upstream injects its own microvm module into a config-shape VM, so nexus's reads succeed and produce named diagnostics. A guest that carries the microvm module but not this framework's option tree gets past nexus's reads and is reported here by name — the read is guarded rather than raw, because an unguarded dereference would throw while the merged assertion list was being built and mask every message nexus had already produced. That branch has no paired sabotage: constructing a microvm guest this framework did not build needs a second guest builder the check has no seam for, so it is carried the way modules/microvm-credentials.nix carries its name-length guard — documentation with teeth, said out loud rather than implied.

4. restartIfChanged was overridden to true. Sent back, and reversed. The original reasoning assumed a host rebuild is how a guest gets updated. In this fleet it is not: microVM guests are their own deploy targets the way the libvirt VMs are, rebuild-vm-from-host switches a guest in place, its services restart at service granularity, and allod/vm contract 6a requires the writable store overlay and the nix daemon precisely so that works. A host rebuild must therefore stage the new guest boot image and leave the running guest alone, which is exactly upstream's default for a pre-evaluated guest. The override is dropped, and the check now pins the rendered X-RestartIfChanged=false in the generated drop-in rather than the option that produced it — switch-to-configuration treats the key as true when absent, so an upstream default change, or an upstream that stopped emitting the key, goes red instead of quietly making every guest restartable on the next host rebuild. Its paired sabotage forces the option back to true and requires the same text to go true.

5. The join guards an overlap allod/nexus does not. Accepted. Nexus checks that a VM's QMP directory is not inside the host plaintext root, but not the reverse. Since this PR introduces the fixed QMP root, a hostPlaintextRoot set below it would evaluate clean and then have the launcher's rm -rf descend into the mounted credential tmpfs at the next VM start. The join refuses that at evaluation, with a paired sabotage.

6. The QEMU command reconstruction moved to checks/qemu-command.nix. Accepted. It was inline in runtime-module-selection, and this check is its second consumer — the credential -fw_cfg arguments are in the same rendered command as the network interface. A copy would witness its own reimplementation, the drift failure testing.md names. The extraction is a pure move, including the divergence guard the moved-root test depends on; runtime-module-selection still passes.

The empty-guest-set seam, and how it is closed

The implementation review found that profileData and machine.profile merge into builder arguments, so a deployment could pass microvmGuests = { } and silently un-wire a machine whose inventory entry declares the microVM runtime, with every check still green. That was originally recorded rather than fixed, on the grounds that constraining the argument would break the seam the checks depend on. The owner's ruling was that a footgun does not stay open because it makes testing easier, and named the shape: constrain the outputs, not the builder.

checks/microvm-fleet-wiring.nix does that. The builder argument stays free, so fixtures keep flowing through it. What the check pins is what deployments consume — for every hypervisor in nixosConfigurations, the guest set that host actually composed, read off config.microvm.vms and guarded because a libvirt-only host declares no microvm option tree at all. The union of those sets must be exactly the machines microvmMachineNames derives from inventory, each wired once.

Exactly once, not at least once, and that is a second thing the rule catches. mkHypervisor's default hands every hypervisor the whole fleet's microVM machines minus itself, because no inventory fact says which host runs which guest. With one hypervisor that is correct; with two it would wire each guest into both, and two hosts starting one VM share its state directory and its QMP socket. The check refuses that fleet by name and the diagnostic says which fact is missing, rather than letting it ship.

What it witnesses here, stated plainly. The public example fleet declares no microVM machine, so the fleet read is the empty set against the empty set; its live half is the guarded branch, since the example hypervisor composes no microvm option tree and an unguarded read would be a raw attribute error rather than an empty guest set. The rule itself is a function over data with three sabotages and two positive controls, so it can be shown to fail. What makes it load-bearing is the deploy flake, which reruns this suite against the real data repos — that is where a machine with the microVM runtime exists and where an un-wired one is refused. To show the fleet path itself reports rather than only the rule, the check's machines argument was temporarily doctored on this branch: adding a runtime = "microvm" machine turns it red with beta-dev selects the microvm runtime and no exported hypervisor starts it, and removing the hypervisor turns it red with the fleet exports no hypervisor, so this check walked nothing.

Nothing here forces a toplevel and no fixture system is built, so the new check adds a walk rather than a fleet — it is one more gate step, not one more machine's worth of memory.

What earlier review caught, and what it says about the check

The plan behind this change was reviewed before implementation, and the review found a blocker that the first working version of the check could not see: reading config.microvm.vms or a rendered unit does not force config.assertions. nixpkgs only enforces those through system.build.toplevel. So the check passed while its own fixtures were configurations no host could ever build — the socket problem in decision 1 was sitting there unreported.

The fix is finding 0 of the check: every positive fixture now forces toplevel.drvPath and reports the failed assertion messages rather than a bare "did not evaluate". With the socket line removed, it now says nexus microvm contract 8: allod-dev: microvm.socket must be one absolute path.

Two smaller gaps from the same review are also closed: the filter that decides which machines a hypervisor picks up is now a named function exercised with a machines-shaped fixture (no inventory declares the microVM runtime, so it was otherwise only ever witnessed returning nothing), and the lock-graph reader is a function over parsed lock data with a doctored-lock sabotage, since it is a new validator.

A second pass then reviewed the implementation diff, and its findings are commented on this PR with what each one changed. The substantive ones: the root-overlap guard in decision 5; the guest-name-to-configuration mapping is now a named function witnessed by a fixture; a hypervisor no longer appears in its own guest set, so a hypervisor that somehow declared the microVM runtime is a refusal rather than a stack overflow; and decision 3's stated reasoning was simply wrong and is rewritten.

Risk

R3 High. Cross-repo interface plus generated lifecycle behaviour — a systemd drop-in, a unit's restart semantics, and the argument list a hypervisor hands QEMU. Not R4: no machine selects the runtime, no deployed state is touched, no real credential path exists anywhere in the change, and rollback is a straight revert.

The worst credible failure is not a wrong credential path. That one is already eval-blocked by machinery this change does not touch: allod/nexus derives its expected value from the host option itself, so a wrong-root extension is refused at deployment evaluation regardless of any check here, and a path that survived would fail loudly at QEMU start on a missing file. The real residual is the class this slice structurally cannot witness — unit lifecycle at runtime. Whether switch-to-configuration leaves a changed guest running as intended, and how the launcher and generated unit interact on a real host, are settled by booting, which belongs to the parent arc's nested-boot slice. The restartIfChanged reversal moves which behaviour is the untested one but not how much is untested: the drop-in's rendered value is pinned either way, and what happens on a real rebuild is not.

The fleet-wiring rule's own residual is that its live half only runs where a microVM machine exists. On this repo it is empty-against-empty, and its teeth are in the deploy flake's rerun against the real data repos.

Most useful human scrutiny, in order: decision 1 and whether that socket path belongs to this repo; the fleet-wiring rule's exactly-once clause, which refuses a two-hypervisor fleet that would otherwise double-wire; then the rendered microvm@<name> drop-in.

Validation

./check.sh                      # 21 steps, all pass
./check-selftest.sh             # PASS

Evidence beyond the gate, each run against this branch at f39197a:

Claim How it was measured Result
No example machine moves toplevel.drvPath for all four, on master at c02e4e9 and on this branch identical
The lock conflict is resolved by re-deriving, not by reverting merge took master's flake.lock wholesale, then nix flake lock re-applied the follows 28 nodes → 18, one vm, one microvm; revision set byte-identical to master's
The branch is mergeable git merge-base --is-ancestor origin/master HEAD, and the forge's own mergeable flag master is an ancestor; mergeable: true
The composed host is buildable toplevel.drvPath forced on both positive fixtures evaluates
The check fails on a wrong root join's root replaced by a literal red: moved-root credentialFiles and every -fw_cfg argument
The check fails if the restart override comes back restartIfChanged = true re-added to the join red: option true where false expected, drop-in missing X-RestartIfChanged=false, drop-in carrying X-RestartIfChanged=true
The check fails on autostart autostart = false deleted red: microvm.autostart becomes [ "allod-dev" ]
The check fails on the socket microvm.socket deleted red, naming nexus's contract-8 diagnostic
The root-overlap guard fires fixture sets hostPlaintextRoot inside the QMP root refused at evaluation, naming the overlap
The fleet rule fires on an un-wired machine check's machines argument doctored with a runtime = "microvm" entry red: beta-dev selects the microvm runtime and no exported hypervisor starts it
The fleet walk is not vacuous by accident check's machines argument doctored to remove the hypervisor red: the fleet exports no hypervisor, so this check walked nothing

The permanent sabotages are each pinned to the diagnostic they name rather than to "evaluation failed": in the join check, a standalone unextended guest wired into evaluatedConfig, a host source set that does not match the guest, a host plaintext root nested inside the QMP root, a restartIfChanged forced true, an autostart forced true, and a doctored lock graph; in the fleet-wiring check, a declared machine no host starts, one guest wired into two hosts, and a host starting a machine inventory did not select — with two positive controls so a rule that always reported would not pass them.

What this does not prove. No guest is booted and no host is rebuilt: this slice ends at evaluation and rendered artifacts. Everything is measured against the public example fleet, which is not the fleet that matters; a verification request for private machine data follows on the issue.

Review rotation, stated because it was thinner than the standard asks for. The cross-vendor pass allod/memory records as strongest was not callable — the codex runner in this VM is unauthenticated (HTTP 401) — so the rotation was Claude-side only, one model reviewing another's plan. It found the blocker above, so the pass was not decorative. The owner review then read the generated report and sent back two changes, both applied here; those two commits have not themselves had an independent adversarial pass.

A hypervisor can now actually start a microVM guest. Until this change nothing connected the two halves: a guest declared which credentials it expects by name and supplied no values, the host asserted that both sides agreed, and no host ever composed a guest — so that agreement was checked against nothing. The host now hands each guest real file paths derived from its own credential root. Nothing changes for any machine that exists today. No machine selects the microVM runtime, so all four example machines build to byte-identical derivations against current master, and a hypervisor whose fleet has no microVM machine composes none of the new modules at all — measured, not assumed. What proves it: one check builds three hypervisor fixtures, forces each to a real buildable configuration, compares the two sides' actual values, then reads the resulting systemd drop-in and QEMU arguments at a non-default credential root. A second check walks every hypervisor this flake exports and requires each machine whose inventory entry selects the microVM runtime to be started by exactly one of them, so a deployment cannot silently un-wire one. The whole gate passes at 21 steps. If this is wrong, revert the branch. Nothing here runs on a host, no deployed state or credential source is touched, and no machine starts a guest on the strength of it. The owner review ruled on the five judgment calls below and sent two back: close the empty-guest-set seam, and stop overriding `restartIfChanged`. Both are applied in `f39197a`, and the branch is merged up to master with the `flake.lock` conflict resolved. --- ## What this implements `Closes allod/archetypes#38`, `Refs allod/strategy#20` — contracts 1a, 8b and 17, all in `allod/archetypes`. - **One crossing, in one direction.** `mkHypervisor` calls `extendModules` on each selected guest configuration, adding one module whose `microvm.credentialFiles` is built from the host's evaluated `nexus.microvm.hostPlaintextRoot` and the guest's own declared `credentialNames`. `allod/nexus` is not modified, never imports `allod/archetypes`, and never restates the name set. - **The host runs the extended guest.** That extended result is assigned to `microvm.vms.<name>.evaluatedConfig`, and `install-microvm-<name>` installs its runner — a different store path from the standalone result's. - **Which guests a host starts is pinned at the output.** `microvmGuests` stays a free builder argument so checks can drive `mkHypervisor` with a guest set no inventory supplies; what a deployment consumes is constrained instead. `checks/microvm-fleet-wiring.nix` walks the exported hypervisors and requires every microVM machine to appear in exactly one host's guest set. - **`restartIfChanged` is left at upstream's default**, which is `false` for an `evaluatedConfig`-shaped VM. A microVM guest is its own deploy target the way this fleet's libvirt VMs are, so a host rebuild stages the new guest boot image and leaves the running guest alone. The check pins the rendered `X-RestartIfChanged=false` rather than the option, so an upstream default change goes red. - **One revision set.** `nexus.inputs.vm.follows = "vm"` collapses the lock from 28 nodes to 18, leaving exactly one `allod/vm` and one microvm.nix node. No revision moves; the two copies already happened to agree, which is precisely the coincidence this removes. - **Values are strings, never Nix paths.** A path literal would copy the host's credential into the world-readable store. The guest module already rejects a non-string, relative or store-prefixed value, so the extension is checked by the configuration it extends. ## Owner review decisions The five judgment calls this PR raised, with what the owner ruled and what changed. **1. The join also sets the guest's `microvm.socket`, which the issue does not mention.** *Kept as is.* `allod/nexus` refuses upstream's default — a bare `"<hostName>.sock"`, relative to the unit's working directory — and requires a normalized absolute path strictly below `/run/allod`, outside both the plaintext root and the namespace roots (`nix/microvm/host.nix` contract 8). Nothing in `allod/archetypes` or `allod/vm` sets it. So composing the host module produces a configuration that does not evaluate at all until someone does, and the issue's whole goal is a host that can start a guest. The join sets `/run/allod/microvm-qmp/<machine>/control.sock`, the layout nexus's own host fixtures already assume, as a literal rather than an option because no deployment has needed to move it. The owner's ruling: not configurable, because a fixed value cannot be misaimed and this PR's own overlap guard documents what settable roots cost; not in `allod/vm`, because a guest must not know host filesystem layout; and exporting it from `allod/nexus` beside the launcher that deletes at it is a defensible follow-up issue rather than a blocker, since contract 8 already prevents silent drift of the current split. **2. Each wired VM sets `autostart = false`.** *Accepted.* Upstream defaults it to true and `microvms.target` then wants the unit, so wiring a guest without disarming it would make a host rebuild start it. `allod/strategy#20`'s agent gates make "enable microvm autostart for a real machine" a human-only act, so the framework declares the VM and a deployment opts in. Measured: with the line removed, `config.microvm.autostart` comes back as `[ "allod-dev" ]`. **3. The join asserts what `allod/nexus` cannot, and nothing more.** *Accepted.* For every wired guest, its `credentialFiles` keys must equal its declared `credentialNames` — the one property nexus cannot state, because it must not learn the name set. The *value* rule stays in nexus, which already owns it; restating it here would duplicate a fact. The rule is scoped to the `evaluatedConfig` shape, the only shape this builder produces. A deployment reaching past the join with upstream's `config` or `flake` shape is nexus's to judge, and it does: upstream injects its own microvm module into a `config`-shape VM, so nexus's reads succeed and produce named diagnostics. A guest that carries the microvm module but *not* this framework's option tree gets past nexus's reads and is reported here by name — the read is guarded rather than raw, because an unguarded dereference would throw while the merged assertion list was being built and mask every message nexus had already produced. That branch has no paired sabotage: constructing a microvm guest this framework did not build needs a second guest builder the check has no seam for, so it is carried the way `modules/microvm-credentials.nix` carries its name-length guard — documentation with teeth, said out loud rather than implied. **4. `restartIfChanged` was overridden to `true`.** *Sent back, and reversed.* The original reasoning assumed a host rebuild is how a guest gets updated. In this fleet it is not: microVM guests are their own deploy targets the way the libvirt VMs are, `rebuild-vm-from-host` switches a guest in place, its services restart at service granularity, and `allod/vm` contract 6a requires the writable store overlay and the nix daemon precisely so that works. A host rebuild must therefore stage the new guest boot image and leave the running guest alone, which is exactly upstream's default for a pre-evaluated guest. The override is dropped, and the check now pins the *rendered* `X-RestartIfChanged=false` in the generated drop-in rather than the option that produced it — `switch-to-configuration` treats the key as true when absent, so an upstream default change, or an upstream that stopped emitting the key, goes red instead of quietly making every guest restartable on the next host rebuild. Its paired sabotage forces the option back to `true` and requires the same text to go true. **5. The join guards an overlap `allod/nexus` does not.** *Accepted.* Nexus checks that a VM's QMP directory is not inside the host plaintext root, but not the reverse. Since this PR introduces the fixed QMP root, a `hostPlaintextRoot` set *below* it would evaluate clean and then have the launcher's `rm -rf` descend into the mounted credential tmpfs at the next VM start. The join refuses that at evaluation, with a paired sabotage. **6. The QEMU command reconstruction moved to `checks/qemu-command.nix`.** *Accepted.* It was inline in `runtime-module-selection`, and this check is its second consumer — the credential `-fw_cfg` arguments are in the same rendered command as the network interface. A copy would witness its own reimplementation, the drift failure `testing.md` names. The extraction is a pure move, including the divergence guard the moved-root test depends on; `runtime-module-selection` still passes. ## The empty-guest-set seam, and how it is closed The implementation review found that `profileData` and `machine.profile` merge into builder arguments, so a deployment could pass `microvmGuests = { }` and silently un-wire a machine whose inventory entry declares the microVM runtime, with every check still green. That was originally recorded rather than fixed, on the grounds that constraining the argument would break the seam the checks depend on. The owner's ruling was that a footgun does not stay open because it makes testing easier, and named the shape: constrain the outputs, not the builder. `checks/microvm-fleet-wiring.nix` does that. The builder argument stays free, so fixtures keep flowing through it. What the check pins is what deployments consume — for every hypervisor in `nixosConfigurations`, the guest set that host actually composed, read off `config.microvm.vms` and guarded because a libvirt-only host declares no `microvm` option tree at all. The union of those sets must be exactly the machines `microvmMachineNames` derives from inventory, each wired once. Exactly once, not at least once, and that is a second thing the rule catches. `mkHypervisor`'s default hands every hypervisor the whole fleet's microVM machines minus itself, because no inventory fact says which host runs which guest. With one hypervisor that is correct; with two it would wire each guest into both, and two hosts starting one VM share its state directory and its QMP socket. The check refuses that fleet by name and the diagnostic says which fact is missing, rather than letting it ship. **What it witnesses here, stated plainly.** The public example fleet declares no microVM machine, so the fleet read is the empty set against the empty set; its live half is the guarded branch, since the example hypervisor composes no microvm option tree and an unguarded read would be a raw attribute error rather than an empty guest set. The rule itself is a function over data with three sabotages and two positive controls, so it can be shown to fail. What makes it load-bearing is the deploy flake, which reruns this suite against the real data repos — that is where a machine with the microVM runtime exists and where an un-wired one is refused. To show the fleet path itself reports rather than only the rule, the check's `machines` argument was temporarily doctored on this branch: adding a `runtime = "microvm"` machine turns it red with `beta-dev selects the microvm runtime and no exported hypervisor starts it`, and removing the hypervisor turns it red with `the fleet exports no hypervisor, so this check walked nothing`. Nothing here forces a toplevel and no fixture system is built, so the new check adds a walk rather than a fleet — it is one more gate step, not one more machine's worth of memory. ## What earlier review caught, and what it says about the check The plan behind this change was reviewed before implementation, and the review found a blocker that the first working version of the check could not see: **reading `config.microvm.vms` or a rendered unit does not force `config.assertions`.** nixpkgs only enforces those through `system.build.toplevel`. So the check passed while its own fixtures were configurations no host could ever build — the socket problem in decision 1 was sitting there unreported. The fix is finding 0 of the check: every positive fixture now forces `toplevel.drvPath` and reports the failed assertion messages rather than a bare "did not evaluate". With the socket line removed, it now says `nexus microvm contract 8: allod-dev: microvm.socket must be one absolute path`. Two smaller gaps from the same review are also closed: the filter that decides which machines a hypervisor picks up is now a named function exercised with a machines-shaped fixture (no inventory declares the microVM runtime, so it was otherwise only ever witnessed returning nothing), and the lock-graph reader is a function over parsed lock data with a doctored-lock sabotage, since it is a new validator. A second pass then reviewed the implementation diff, and its findings are commented on this PR with what each one changed. The substantive ones: the root-overlap guard in decision 5; the guest-name-to-configuration mapping is now a named function witnessed by a fixture; a hypervisor no longer appears in its own guest set, so a hypervisor that somehow declared the microVM runtime is a refusal rather than a stack overflow; and decision 3's stated reasoning was simply wrong and is rewritten. ## Risk **R3 High.** Cross-repo interface plus generated lifecycle behaviour — a systemd drop-in, a unit's restart semantics, and the argument list a hypervisor hands QEMU. Not R4: no machine selects the runtime, no deployed state is touched, no real credential path exists anywhere in the change, and rollback is a straight revert. The worst credible failure is not a wrong credential path. That one is already eval-blocked by machinery this change does not touch: `allod/nexus` derives its expected value from the host option itself, so a wrong-root extension is refused at deployment evaluation regardless of any check here, and a path that survived would fail loudly at QEMU start on a missing file. The real residual is the class this slice structurally cannot witness — unit lifecycle at runtime. Whether `switch-to-configuration` leaves a changed guest running as intended, and how the launcher and generated unit interact on a real host, are settled by booting, which belongs to the parent arc's nested-boot slice. The `restartIfChanged` reversal moves which behaviour is the untested one but not how much is untested: the drop-in's rendered value is pinned either way, and what happens on a real rebuild is not. The fleet-wiring rule's own residual is that its live half only runs where a microVM machine exists. On this repo it is empty-against-empty, and its teeth are in the deploy flake's rerun against the real data repos. Most useful human scrutiny, in order: decision 1 and whether that socket path belongs to this repo; the fleet-wiring rule's exactly-once clause, which refuses a two-hypervisor fleet that would otherwise double-wire; then the rendered `microvm@<name>` drop-in. ## Validation ``` ./check.sh # 21 steps, all pass ./check-selftest.sh # PASS ``` Evidence beyond the gate, each run against this branch at `f39197a`: | Claim | How it was measured | Result | |---|---|---| | No example machine moves | `toplevel.drvPath` for all four, on `master` at `c02e4e9` and on this branch | identical | | The lock conflict is resolved by re-deriving, not by reverting | merge took master's `flake.lock` wholesale, then `nix flake lock` re-applied the `follows` | 28 nodes → 18, one `vm`, one `microvm`; revision set byte-identical to master's | | The branch is mergeable | `git merge-base --is-ancestor origin/master HEAD`, and the forge's own `mergeable` flag | master is an ancestor; `mergeable: true` | | The composed host is buildable | `toplevel.drvPath` forced on both positive fixtures | evaluates | | The check fails on a wrong root | join's root replaced by a literal | red: moved-root `credentialFiles` and every `-fw_cfg` argument | | The check fails if the restart override comes back | `restartIfChanged = true` re-added to the join | red: option `true` where `false` expected, drop-in missing `X-RestartIfChanged=false`, drop-in carrying `X-RestartIfChanged=true` | | The check fails on autostart | `autostart = false` deleted | red: `microvm.autostart` becomes `[ "allod-dev" ]` | | The check fails on the socket | `microvm.socket` deleted | red, naming nexus's contract-8 diagnostic | | The root-overlap guard fires | fixture sets `hostPlaintextRoot` inside the QMP root | refused at evaluation, naming the overlap | | The fleet rule fires on an un-wired machine | check's `machines` argument doctored with a `runtime = "microvm"` entry | red: `beta-dev selects the microvm runtime and no exported hypervisor starts it` | | The fleet walk is not vacuous by accident | check's `machines` argument doctored to remove the hypervisor | red: `the fleet exports no hypervisor, so this check walked nothing` | The permanent sabotages are each pinned to the diagnostic they name rather than to "evaluation failed": in the join check, a standalone unextended guest wired into `evaluatedConfig`, a host source set that does not match the guest, a host plaintext root nested inside the QMP root, a `restartIfChanged` forced true, an `autostart` forced true, and a doctored lock graph; in the fleet-wiring check, a declared machine no host starts, one guest wired into two hosts, and a host starting a machine inventory did not select — with two positive controls so a rule that always reported would not pass them. **What this does not prove.** No guest is booted and no host is rebuilt: this slice ends at evaluation and rendered artifacts. Everything is measured against the public example fleet, which is not the fleet that matters; a verification request for private machine data follows on the issue. **Review rotation, stated because it was thinner than the standard asks for.** The cross-vendor pass `allod/memory` records as strongest was not callable — the `codex` runner in this VM is unauthenticated (HTTP 401) — so the rotation was Claude-side only, one model reviewing another's plan. It found the blocker above, so the pass was not decorative. The owner review then read the generated report and sent back two changes, both applied here; those two commits have not themselves had an independent adversarial pass.
mkHypervisor now composes nexus.nixosModules.microvmHost for a fleet that
declares a microvm machine, extends each selected guest configuration with
microvm.credentialFiles derived from the host's own plaintext root, and starts
that extended result. The guest's QMP socket comes from the same join, because
nexus refuses upstream's relative default and nothing else sets it.

Each wired VM sets restartIfChanged explicitly, since upstream defaults it to
false for an evaluatedConfig-shaped VM, and autostart false, since arming a
real machine is a human-only act.

nexus.inputs.vm.follows = "vm" collapses the lock to one allod/vm and one
microvm.nix node without moving any revision.

Refs allod/archetypes#38
Refs allod/strategy#20
Three hypervisor configurations are held live, not two.
Author
Member

Review pass on the implementation diff

Read-only adversarial pass over master...HEAD, one model rotated off the author. No blocker. It re-verified the PR body's numbers by execution — 20 gate steps, 28→18 lock nodes with every survivor at its old revision, all four machines' toplevel.drvPath identical between merge-base 8982381 and branch HEAD — and confirmed the extraction to checks/qemu-command.nix is a pure move, that allod/nexus needs no change, and that there is no evaluation cycle.

Six findings, four of which I am fixing in a follow-up commit on this branch.

1. GAP — the production default path is unwitnessed, and a deployment can silently un-wire a declared microvm machine

fleetMicrovmGuests feeds mkHypervisor's default argument, but no check ever executes that default: every fixture passes guests explicitly, and no inventory machine declares the runtime. If the genAttrs line read the wrong variable, all 20 steps would still pass — the byte-identical drvPath comparison cannot tell a working gate from an accidentally empty guest set. This is the failure the check's own header warns about, one seam further out.

Fixing the mechanical half: the name→configuration mapping becomes a named function driven by a machines-shaped fixture, so the wiring itself is witnessed.

Not fixing, and recording instead: profileData and machine.profile merge into builder arguments, so a deployment can pass microvmGuests = { } and un-wire a machine whose inventory says microvm, with every check still green. Constraining that would break the seam the checks themselves depend on. The issue's verification request 2 is the witness for it, which makes that request load-bearing rather than optional — noted on the issue.

2. GAP — the QMP socket root and the host plaintext root can nest, and the failure is destructive rather than loud

The sharpest finding. Nexus's contract-8 assertion checks one direction only — that the QMP directory is not inside the plaintext root — so a hostPlaintextRoot set below the QMP root (/run/allod/microvm-qmp/creds passes its option regex) evaluates clean. At the next start of that VM the launcher's rm -rf on the QMP directory descends into the mounted plaintext tmpfs, destroying every VM's prepared credentials including a running sibling's.

This PR is the party that introduced the fixed QMP literal, so the guard belongs here rather than in prose. Adding an assertion for the uncovered direction, with a paired sabotage.

3. QUESTION — decision 3's stated reasoning is wrong, and this module can mask nexus's diagnostics

The comment and the PR body both claim that a VM wired in upstream's other shapes raises a raw attribute error from allod/nexus before any assertion here renders. Not true in either direction: a config-shape VM gets upstream's microvm module injected, so nexus's reads succeed and produce named diagnostics; and for a guest that has the microvm module but not this framework's option tree, the raw attribute 'allod' missing comes from this file, thrown while the merged assertion list is built — masking nexus's renderable contract-8 and contract-17 messages.

The decision stands; the reason recorded for it does not. Correcting the comment and the PR body, and guarding the read so this module stops masking nexus.

4. QUESTION — a hypervisor declaring the microvm runtime is infinite recursion

microvmMachineNames filters on runtime alone, so a hypervisor entry with runtime = "microvm" would put that hypervisor in its own guest set and recurse. Inventory forbids it (contract 1, asserted there), so this is unreachable from valid data — but it surfaces as a stack error rather than a refusal. Excluding the machine being built from its own guest set: a hypervisor never hosts itself, which is this repo's own truth rather than a restatement of inventory's rule.

5. SIMPLIFY — stale path

flake.nix still says the reconstruction lives in nix/qemu-command.nix; it is checks/qemu-command.nix. Fixing.

6. Nits in this PR body

Decision 4 says the divergence guard is what test 2 depends on — it is test 3, the moved root. And the owner summary says "revert the commit" when the branch carries two. Both corrected in the body.

One observation, not a defect: test 1's host-source comparison round-trips the fixture's own input, since the synthetic sources are built from the same restated name list. Agreement is still established, transitively, through that restated literal and through nexus's set-equality assertion on the forced toplevel — but the comparison is weaker than it reads.

## Review pass on the implementation diff Read-only adversarial pass over `master...HEAD`, one model rotated off the author. No blocker. It re-verified the PR body's numbers by execution — 20 gate steps, 28→18 lock nodes with every survivor at its old revision, all four machines' `toplevel.drvPath` identical between merge-base `8982381` and branch HEAD — and confirmed the extraction to `checks/qemu-command.nix` is a pure move, that `allod/nexus` needs no change, and that there is no evaluation cycle. Six findings, four of which I am fixing in a follow-up commit on this branch. ### 1. GAP — the production default path is unwitnessed, and a deployment can silently un-wire a declared microvm machine `fleetMicrovmGuests` feeds `mkHypervisor`'s default argument, but no check ever executes that default: every fixture passes `guests` explicitly, and no inventory machine declares the runtime. If the `genAttrs` line read the wrong variable, all 20 steps would still pass — the byte-identical drvPath comparison cannot tell a working gate from an accidentally empty guest set. This is the failure the check's own header warns about, one seam further out. Fixing the mechanical half: the name→configuration mapping becomes a named function driven by a machines-shaped fixture, so the wiring itself is witnessed. Not fixing, and recording instead: `profileData` and `machine.profile` merge into builder arguments, so a deployment can pass `microvmGuests = { }` and un-wire a machine whose inventory says microvm, with every check still green. Constraining that would break the seam the checks themselves depend on. The issue's verification request 2 is the witness for it, which makes that request load-bearing rather than optional — noted on the issue. ### 2. GAP — the QMP socket root and the host plaintext root can nest, and the failure is destructive rather than loud The sharpest finding. Nexus's contract-8 assertion checks one direction only — that the QMP directory is not inside the plaintext root — so a `hostPlaintextRoot` set *below* the QMP root (`/run/allod/microvm-qmp/creds` passes its option regex) evaluates clean. At the next start of that VM the launcher's `rm -rf` on the QMP directory descends into the mounted plaintext tmpfs, destroying every VM's prepared credentials including a running sibling's. This PR is the party that introduced the fixed QMP literal, so the guard belongs here rather than in prose. Adding an assertion for the uncovered direction, with a paired sabotage. ### 3. QUESTION — decision 3's stated reasoning is wrong, and this module can mask nexus's diagnostics The comment and the PR body both claim that a VM wired in upstream's other shapes raises a raw attribute error from `allod/nexus` before any assertion here renders. Not true in either direction: a `config`-shape VM gets upstream's microvm module injected, so nexus's reads succeed and produce named diagnostics; and for a guest that has the microvm module but not this framework's option tree, the raw `attribute 'allod' missing` comes from **this file**, thrown while the merged assertion list is built — masking nexus's renderable contract-8 and contract-17 messages. The decision stands; the reason recorded for it does not. Correcting the comment and the PR body, and guarding the read so this module stops masking nexus. ### 4. QUESTION — a hypervisor declaring the microvm runtime is infinite recursion `microvmMachineNames` filters on `runtime` alone, so a hypervisor entry with `runtime = "microvm"` would put that hypervisor in its own guest set and recurse. Inventory forbids it (contract 1, asserted there), so this is unreachable from valid data — but it surfaces as a stack error rather than a refusal. Excluding the machine being built from its own guest set: a hypervisor never hosts itself, which is this repo's own truth rather than a restatement of inventory's rule. ### 5. SIMPLIFY — stale path `flake.nix` still says the reconstruction lives in `nix/qemu-command.nix`; it is `checks/qemu-command.nix`. Fixing. ### 6. Nits in this PR body Decision 4 says the divergence guard is what test 2 depends on — it is test 3, the moved root. And the owner summary says "revert the commit" when the branch carries two. Both corrected in the body. One observation, not a defect: test 1's host-source comparison round-trips the fixture's own input, since the synthetic sources are built from the same restated name list. Agreement is still established, transitively, through that restated literal and through nexus's set-equality assertion on the forced toplevel — but the comparison is weaker than it reads.
Refuse a host plaintext root nested inside the QMP socket root. allod/nexus
checks only the other direction, and this join introduced the QMP root, so a
root set below it evaluated clean and would then have the launcher's rm -rf
descend into the mounted credential tmpfs at the next VM start.

Witness the guest-name-to-configuration mapping with a fixture. It feeds
mkHypervisor's default argument and no fixture reached it, so reading the
wrong binding would have passed the whole gate.

Guard the credential-name read so this module reports a guest it did not build
instead of throwing while the merged assertion list is assembled, which masked
nexus's own diagnostics. Correct the comment that claimed nexus always fails
first; it does not.

Exclude a hypervisor from its own guest set, so a runtime fact inventory
forbids is a refusal rather than a stack overflow.

Refs allod/archetypes#38
Refs allod/strategy#20
First-time contributor

Owner review decisions on the five recorded judgment calls, after a full read of the generated report:

1. The empty-guest-set seam: close it. A footgun does not stay open because it makes testing easier. Requested shape: constrain the outputs, not the builder. Add a check that walks every host configuration the fleet exports and requires its composed guest set to equal the inventory-derived expectation — every machine whose inventory entry selects the microVM runtime appears in exactly one host's guest set. The builder argument stays free, so fixtures keep flowing through it; the pinned object is what deployments consume. The deploy flake already reruns this suite against the real data repos, so the rule holds fleet-wide.

2. Deferred boot test: accepted. Runtime restart and launcher behavior land in the later boot-test slice as planned.

3. autostart = false: accepted. Declaring a machine and arming it stay separate human acts.

4. restartIfChanged = true: reverse it. MicroVM guests should behave like the fleet's libvirt VMs under deploys. Those VMs are their own deploy targets: rebuild-vm-from-host switches them in place, services restart at service granularity, the VM never reboots because of a deploy. The microVM guests were built for the same model — allod/vm contract 6a requires the writable store overlay and nix daemon precisely so in-place deploys work. Under that model a host rebuild must stage the new guest boot image and leave the running guest alone, which is exactly upstream microvm.nix's default for pre-evaluated guests. Requested change: drop the true override, and flip the check to pin X-RestartIfChanged=false in the generated unit text so an upstream default change goes red instead of silently making guests restartable. The report's drift argument assumed host deploys are how guests get updated; in this fleet they are not.

5. Control-socket path placement: keep as is. Not configurable — a fixed value cannot be misaimed, and this PR's own overlap guard documents what settable roots cost. Not in the vm repo — the guest must not know host filesystem layout. The one defensible alternative, exporting the value from allod/nexus beside the launcher that deletes at it, can be a small follow-up issue; contract 8 already prevents silent drift of the current split, so it does not block this PR.

Items 1 and 4 are requested changes on this PR; 2, 3, and 5 are accepted as shipped.

Owner review decisions on the five recorded judgment calls, after a full read of the generated report: **1. The empty-guest-set seam: close it.** A footgun does not stay open because it makes testing easier. Requested shape: constrain the outputs, not the builder. Add a check that walks every host configuration the fleet exports and requires its composed guest set to equal the inventory-derived expectation — every machine whose inventory entry selects the microVM runtime appears in exactly one host's guest set. The builder argument stays free, so fixtures keep flowing through it; the pinned object is what deployments consume. The deploy flake already reruns this suite against the real data repos, so the rule holds fleet-wide. **2. Deferred boot test: accepted.** Runtime restart and launcher behavior land in the later boot-test slice as planned. **3. `autostart = false`: accepted.** Declaring a machine and arming it stay separate human acts. **4. `restartIfChanged = true`: reverse it.** MicroVM guests should behave like the fleet's libvirt VMs under deploys. Those VMs are their own deploy targets: `rebuild-vm-from-host` switches them in place, services restart at service granularity, the VM never reboots because of a deploy. The microVM guests were built for the same model — allod/vm contract 6a requires the writable store overlay and nix daemon precisely so in-place deploys work. Under that model a host rebuild must stage the new guest boot image and leave the running guest alone, which is exactly upstream microvm.nix's default for pre-evaluated guests. Requested change: drop the `true` override, and flip the check to pin `X-RestartIfChanged=false` in the generated unit text so an upstream default change goes red instead of silently making guests restartable. The report's drift argument assumed host deploys are how guests get updated; in this fleet they are not. **5. Control-socket path placement: keep as is.** Not configurable — a fixed value cannot be misaimed, and this PR's own overlap guard documents what settable roots cost. Not in the vm repo — the guest must not know host filesystem layout. The one defensible alternative, exporting the value from allod/nexus beside the launcher that deletes at it, can be a small follow-up issue; contract 8 already prevents silent drift of the current split, so it does not block this PR. Items 1 and 4 are requested changes on this PR; 2, 3, and 5 are accepted as shipped.
master advanced nine input revisions after this branch was cut, which
conflicts with the branch's own rewrite of flake.lock. Resolved by taking
master's lock wholesale and re-running nix flake lock, so the
nexus.inputs.vm.follows redirect collapses master's graph rather than
reinstating this branch's older revisions.

28 nodes to 18, one allod/vm and one microvm.nix node, and the revision set is
byte-identical to master's. All four nixosConfigurations evaluate to the same
derivation paths as master at c02e4e9.

Refs allod/archetypes#38
Two requested changes from the review of allod/archetypes#39.

Pin the fleet's guest wiring at the output instead of constraining the builder.
microvmGuests stays a free argument so checks keep driving mkHypervisor with a
guest set no inventory supplies, and a new microvm-fleet-wiring check walks the
hypervisors this flake exports and requires every machine whose inventory entry
selects the microvm runtime to appear in exactly one host's guest set. That
closes the seam where profileData or a profile setting could hand a hypervisor
an empty guest set and silently un-wire a declared machine. Wiring one guest
into two hosts is refused for the same reason: nothing assigns a guest to a
host, and two hosts starting one VM share its state directory and QMP socket.

Stop overriding restartIfChanged. A microvm guest is its own deploy target, the
way this fleet's libvirt VMs are, so a host rebuild stages the new guest boot
image and leaves the running guest alone. That is upstream's default for an
evaluatedConfig-shaped VM, and the join check now pins the rendered
X-RestartIfChanged=false so an upstream default change goes red rather than
making every guest restartable on the next host rebuild.

Refs allod/archetypes#38
Refs allod/strategy#20
Author
Member

Owner review decisions implemented, and the branch synced to master

Both requested changes are in f39197a, and ff98236 merges master and resolves the flake.lock conflict. The forge now reports mergeable: true. Gate: ./check.sh passes at 21 steps, ./check-selftest.sh PASS. All four example machines evaluate to the same toplevel.drvPath as master at c02e4e9. The PR body is rewritten to match.

Item 1 — the empty-guest-set seam: closed at the output

New check, checks/microvm-fleet-wiring.nix, in the shape requested. microvmGuests stays a free builder argument so fixtures keep flowing through it; what is pinned is what a deployment consumes. For every hypervisor in nixosConfigurations it reads the guest set that host actually composed off config.microvm.vms — guarded, because a libvirt-only host declares no microvm option tree at all — and requires the union to be exactly the machines microvmMachineNames derives from inventory, each appearing once.

Two notes on what that rule does beyond the un-wiring case.

Exactly once refuses a two-hypervisor fleet. mkHypervisor's default hands every hypervisor the fleet's microVM machines minus itself, because no inventory fact assigns a guest to a host. With one hypervisor that is right; with two it wires each guest into both, and two hosts starting one VM share its state directory and its QMP socket. The rule reports that by name and says which fact is missing, rather than shipping it. If you would rather that case were silently allowed, say so — it is a one-line change to the rule, but it looked worse than the un-wiring it was written for.

What it witnesses in this repo, said out loud. The example fleet declares no microVM machine, so the fleet read is empty against empty; its live half is the guarded branch on the example hypervisor. The rule is a function over data with three sabotages and two positive controls, so it can be shown to fail, and the deploy flake's rerun against the real data repos is what gives it teeth — which matches your note. To show the fleet path reports and not only the rule, I doctored the check's machines argument on this branch twice: adding a runtime = "microvm" entry turns it red with beta-dev selects the microvm runtime and no exported hypervisor starts it, and removing the hypervisor turns it red with the fleet exports no hypervisor, so this check walked nothing. Both reverted.

Nothing in the new check forces a toplevel and no fixture system is built, so it is one more gate step rather than one more machine's worth of memory. It is a separate check rather than another section of microvm-host-join for that reason too: that check already holds three hypervisor fixtures live and allod/archetypes#31 is open about its peak.

Item 4 — restartIfChanged: reversed

The override is dropped. Your model is right and the report's drift argument assumed the wrong deploy path: guests are their own deploy targets, rebuild-vm-from-host switches them in place, and contract 6a's writable store overlay and nix daemon exist so that works — so a host rebuild stages the new boot image and leaves the running guest alone, which is upstream's default for a pre-evaluated guest.

The check is flipped as asked: it pins the rendered X-RestartIfChanged=false in the generated drop-in text, and asserts the absence of =true. The absence matters as much as the value, because switch-to-configuration treats a missing key as true, so an upstream that stopped emitting it would be as bad as one that flipped the default. The paired sabotage now forces the option to true and requires the same text to go true. Measured in the other direction as well: re-adding restartIfChanged = true to the join turns the check red with all three findings — the option value, the missing =false, and the present =true.

The option read (equals "the wired guest's restartIfChanged" false) is kept alongside the text read so the two cannot drift silently apart.

Items 2, 3 and 5 — accepted as shipped

No code change. The body now records your reasoning for each rather than presenting them as open questions, including that exporting the control-socket path from allod/nexus beside the launcher that deletes at it is a follow-up issue rather than a blocker. Say the word and I will open it.

Residual

The two commits above have not had an independent adversarial pass of their own; the rotation recorded in the body covers the work up to ee99a08.

## Owner review decisions implemented, and the branch synced to master Both requested changes are in `f39197a`, and `ff98236` merges master and resolves the `flake.lock` conflict. The forge now reports `mergeable: true`. Gate: `./check.sh` passes at 21 steps, `./check-selftest.sh` PASS. All four example machines evaluate to the same `toplevel.drvPath` as master at `c02e4e9`. The PR body is rewritten to match. ### Item 1 — the empty-guest-set seam: closed at the output New check, `checks/microvm-fleet-wiring.nix`, in the shape requested. `microvmGuests` stays a free builder argument so fixtures keep flowing through it; what is pinned is what a deployment consumes. For every hypervisor in `nixosConfigurations` it reads the guest set that host actually composed off `config.microvm.vms` — guarded, because a libvirt-only host declares no `microvm` option tree at all — and requires the union to be exactly the machines `microvmMachineNames` derives from inventory, each appearing once. Two notes on what that rule does beyond the un-wiring case. **Exactly once refuses a two-hypervisor fleet.** `mkHypervisor`'s default hands every hypervisor the fleet's microVM machines minus itself, because no inventory fact assigns a guest to a host. With one hypervisor that is right; with two it wires each guest into both, and two hosts starting one VM share its state directory and its QMP socket. The rule reports that by name and says which fact is missing, rather than shipping it. If you would rather that case were silently allowed, say so — it is a one-line change to the rule, but it looked worse than the un-wiring it was written for. **What it witnesses in this repo, said out loud.** The example fleet declares no microVM machine, so the fleet read is empty against empty; its live half is the guarded branch on the example hypervisor. The rule is a function over data with three sabotages and two positive controls, so it can be shown to fail, and the deploy flake's rerun against the real data repos is what gives it teeth — which matches your note. To show the fleet path reports and not only the rule, I doctored the check's `machines` argument on this branch twice: adding a `runtime = "microvm"` entry turns it red with `beta-dev selects the microvm runtime and no exported hypervisor starts it`, and removing the hypervisor turns it red with `the fleet exports no hypervisor, so this check walked nothing`. Both reverted. Nothing in the new check forces a toplevel and no fixture system is built, so it is one more gate step rather than one more machine's worth of memory. It is a separate check rather than another section of `microvm-host-join` for that reason too: that check already holds three hypervisor fixtures live and `allod/archetypes#31` is open about its peak. ### Item 4 — `restartIfChanged`: reversed The override is dropped. Your model is right and the report's drift argument assumed the wrong deploy path: guests are their own deploy targets, `rebuild-vm-from-host` switches them in place, and contract 6a's writable store overlay and nix daemon exist so that works — so a host rebuild stages the new boot image and leaves the running guest alone, which is upstream's default for a pre-evaluated guest. The check is flipped as asked: it pins the rendered `X-RestartIfChanged=false` in the generated drop-in text, and asserts the absence of `=true`. The absence matters as much as the value, because `switch-to-configuration` treats a missing key as true, so an upstream that stopped emitting it would be as bad as one that flipped the default. The paired sabotage now forces the option to `true` and requires the same text to go true. Measured in the other direction as well: re-adding `restartIfChanged = true` to the join turns the check red with all three findings — the option value, the missing `=false`, and the present `=true`. The option read (`equals "the wired guest's restartIfChanged" false`) is kept alongside the text read so the two cannot drift silently apart. ### Items 2, 3 and 5 — accepted as shipped No code change. The body now records your reasoning for each rather than presenting them as open questions, including that exporting the control-socket path from `allod/nexus` beside the launcher that deletes at it is a follow-up issue rather than a blocker. Say the word and I will open it. ### Residual The two commits above have not had an independent adversarial pass of their own; the rotation recorded in the body covers the work up to `ee99a08`.
The fleet-wiring rule is this repo's own, not allod/strategy#20 contract 1a,
which is about the microvm pin set. Contract 8b covers how a host crosses into
a guest it already selected and says nothing about which guests it selects.

Record beside the code that contract 8b's closing sentence still mandates the
restartIfChanged override the owner review reversed, so a reader hitting the
contract knows which of the two is stale.

Refs allod/archetypes#38
Refs allod/strategy#20
Author
Member

Contract 8b now disagrees with the code, and it needs a decision

Raising this rather than acting on it, because it is another repo and it is your call.

allod/strategy dev-plans/microvm-framework-adoption.md contract 8b ends: "Supplying evaluatedConfig rather than config also flips upstream's microvm.vms.<name>.restartIfChanged default to false … and switch-to-configuration skips the unit on every rebuild instead of stopping it. Each selected microvm therefore sets restartIfChanged = true explicitly."

That last sentence is exactly what item 4 reversed. The reversal is right — the contract sentence carries the same assumption your review named, that a host rebuild is how a guest gets updated — but the contract text is now the written record of a rule this branch deliberately breaks, and nothing in allod/strategy says otherwise.

d4a8420 records the disagreement beside the code so a reader who checks the contract knows which side is stale, and corrects one citation of mine while there: the new fleet-wiring rule is not contract 1a (that is the pin set) and not 8b (which covers how a host crosses into a guest it has already selected, not which guests it selects). No strategy contract states it; it is this repo's own property, since this repo owns the builders and the nixosConfigurations output.

Two things worth deciding:

  1. Contract 8b's closing sentence should be rewritten to say the opposite, with your deploy-model reasoning. I have not touched allod/strategy.
  2. The fleet-wiring rule has no contract at all. If "every machine whose inventory entry selects the microVM runtime is started by exactly one exported hypervisor" belongs in the parent plan, it wants a contract number rather than living only as a check header in this repo.

Happy to open one allod/strategy PR covering both. It does not block merging this one — the code and the checks agree with each other and with your decision; only the plan text lags.

## Contract 8b now disagrees with the code, and it needs a decision Raising this rather than acting on it, because it is another repo and it is your call. `allod/strategy` `dev-plans/microvm-framework-adoption.md` contract 8b ends: *"Supplying `evaluatedConfig` rather than `config` also flips upstream's `microvm.vms.<name>.restartIfChanged` default to false … and `switch-to-configuration` skips the unit on every rebuild instead of stopping it. Each selected microvm therefore sets `restartIfChanged = true` explicitly."* That last sentence is exactly what item 4 reversed. The reversal is right — the contract sentence carries the same assumption your review named, that a host rebuild is how a guest gets updated — but the contract text is now the written record of a rule this branch deliberately breaks, and nothing in `allod/strategy` says otherwise. `d4a8420` records the disagreement beside the code so a reader who checks the contract knows which side is stale, and corrects one citation of mine while there: the new fleet-wiring rule is not contract 1a (that is the pin set) and not 8b (which covers how a host crosses into a guest it has already selected, not which guests it selects). No strategy contract states it; it is this repo's own property, since this repo owns the builders and the `nixosConfigurations` output. Two things worth deciding: 1. **Contract 8b's closing sentence** should be rewritten to say the opposite, with your deploy-model reasoning. I have not touched `allod/strategy`. 2. **The fleet-wiring rule has no contract at all.** If "every machine whose inventory entry selects the microVM runtime is started by exactly one exported hypervisor" belongs in the parent plan, it wants a contract number rather than living only as a check header in this repo. Happy to open one `allod/strategy` PR covering both. It does not block merging this one — the code and the checks agree with each other and with your decision; only the plan text lags.
allod/strategy 362bf30 reverses contract 8b's restartIfChanged rule and adds
contract 1b for the fleet wiring rule, so the code no longer records a pending
follow-up or claims no contract states it.

Refs allod/strategy#20
Refs allod/archetypes#38
Author
Member

Contract text updated in allod/strategy directly

Done on master as 362bf30, no PR. Three edits, all in dev-plans/microvm-framework-adoption.md:

Contract 8b's closing rule is reversed. It now records that upstream's false default for an evaluatedConfig-shaped VM is kept deliberately, with your deploy-model reasoning, that a host rebuild re-runs install-microvm-<name> to stage the changed runner and leaves the running guest alone, and that the check pins the rendered X-RestartIfChanged=false plus the absence of =true rather than the option.

The acceptance bullet follows, and the nested test changed shape rather than just losing a clause. Item 10 listed "a rebuild stop driven by a guest change that moves the toplevel" among the stop paths whose ExecStopPost behaviour it asserts — under the new rule that is not a stop path at all, so it is removed from that list. New item 11 asserts the opposite behaviour: rebuild with a guest change, require the microvm@<name> unit to keep its main PID and start timestamp across switch-to-configuration while install-microvm-<name> re-runs and current comes to point at the new runner, then stop and start and require the changed guest to boot. It is written against a change that would have restarted under X-RestartIfChanged=true, so a fixture that failed to change anything cannot pass it. That is the behavioural half your decision needs and this slice cannot give.

Contract 1b is added for the fleet-wiring rule, beside contract 1's runtime fact rather than under 8b: every machine whose inventory entry selects the microvm runtime appears in exactly one exported hypervisor's guest set, asserted on nixosConfigurations rather than the builder argument, with the exactly-once clause and its two-hypervisor consequence stated.

I did not touch review-prompts/microvm-framework-adoption-review.md. Its pass log records that pass 8 verified f3c93bc's explicit restartIfChanged = true held — true as history, and rewriting a review record to match a later decision would falsify it.

59cfc2c updates this branch's two code comments accordingly: the join no longer records a pending follow-up, and the fleet-wiring check cites contract 1b instead of saying no contract states it. Gate still passes at 21 steps.

## Contract text updated in `allod/strategy` directly Done on `master` as `362bf30`, no PR. Three edits, all in `dev-plans/microvm-framework-adoption.md`: **Contract 8b's closing rule is reversed.** It now records that upstream's `false` default for an `evaluatedConfig`-shaped VM is kept deliberately, with your deploy-model reasoning, that a host rebuild re-runs `install-microvm-<name>` to stage the changed runner and leaves the running guest alone, and that the check pins the rendered `X-RestartIfChanged=false` plus the absence of `=true` rather than the option. **The acceptance bullet follows,** and the nested test changed shape rather than just losing a clause. Item 10 listed "a rebuild stop driven by a guest change that moves the toplevel" among the stop paths whose `ExecStopPost` behaviour it asserts — under the new rule that is not a stop path at all, so it is removed from that list. New item 11 asserts the opposite behaviour: rebuild with a guest change, require the `microvm@<name>` unit to keep its main PID and start timestamp across `switch-to-configuration` while `install-microvm-<name>` re-runs and `current` comes to point at the new runner, then stop and start and require the changed guest to boot. It is written against a change that *would* have restarted under `X-RestartIfChanged=true`, so a fixture that failed to change anything cannot pass it. That is the behavioural half your decision needs and this slice cannot give. **Contract 1b is added** for the fleet-wiring rule, beside contract 1's runtime fact rather than under 8b: every machine whose inventory entry selects the microvm runtime appears in exactly one exported hypervisor's guest set, asserted on `nixosConfigurations` rather than the builder argument, with the exactly-once clause and its two-hypervisor consequence stated. I did not touch `review-prompts/microvm-framework-adoption-review.md`. Its pass log records that pass 8 verified `f3c93bc`'s explicit `restartIfChanged = true` held — true as history, and rewriting a review record to match a later decision would falsify it. `59cfc2c` updates this branch's two code comments accordingly: the join no longer records a pending follow-up, and the fleet-wiring check cites contract 1b instead of saying no contract states it. Gate still passes at 21 steps.
Author
Member

Owner review — follow-up recorded, not a change request against this branch.

The autostart = false decision stands as accepted: the default is correct and this is not a blocker for merge. What the owner asked about is the half the explainer already flags under "Unresolved operator interface" — there is no repository-specific opt-in option, documented deployment recipe, or sanctioned location for arming autostart, so today the only route is lib.mkForce copied out of checks/microvm-host-join.nix, which is evidence the setting is reachable rather than a policy.

Recorded as allod/archetypes#47, proposing nexus.microvm.machines.<name>.autostart as the surface — beside credentialSources, which is the same class of per-machine deployment composition input — with the safe default explicitly unchanged.

Context for whoever picks it up: the libvirt fleet gets reboot recovery today from nixpkgs' virtualisation.libvirtd.onBoot = "start" default, which restores guests that were running before shutdown. The first real microVM machine loses that at its first unattended host reboot, and the manual fallback is systemctl start microvm@<name>. That moment is the trigger the issue names.

Owner review — follow-up recorded, not a change request against this branch. The `autostart = false` decision stands as accepted: the default is correct and this is not a blocker for merge. What the owner asked about is the half the explainer already flags under "Unresolved operator interface" — there is no repository-specific opt-in option, documented deployment recipe, or sanctioned location for arming autostart, so today the only route is `lib.mkForce` copied out of `checks/microvm-host-join.nix`, which is evidence the setting is reachable rather than a policy. Recorded as allod/archetypes#47, proposing `nexus.microvm.machines.<name>.autostart` as the surface — beside `credentialSources`, which is the same class of per-machine deployment composition input — with the safe default explicitly unchanged. Context for whoever picks it up: the libvirt fleet gets reboot recovery today from nixpkgs' `virtualisation.libvirtd.onBoot = "start"` default, which restores guests that were running before shutdown. The first real microVM machine loses that at its first unattended host reboot, and the manual fallback is `systemctl start microvm@<name>`. That moment is the trigger the issue names.
Author
Member

Owner review, decision 1 — the microvm.socket placement follow-up is now recorded as allod/nexus#27.

The ruling stands unchanged: the fixed value and its placement in the join are accepted, and nothing here blocks merge. The issue captures the alternative the review named — allod/nexus exporting the path constructor beside the post-stop hook that recursively removes the resulting directory — so the reasoning is findable from the nexus side rather than only from this pull request's body.

It is explicit that it closes no current gap: nexus duplicates no literal (qmpDirectory = builtins.dirOf qmpSocket, nix/microvm/host.nix:84), both sides of the overlap assertion come from one string in one file, and checks/microvm/isolation.nix already witnesses that the removal spares a running sibling's socket and prepared credential. Its trigger is a second consumer of the QMP root, or the layout ceasing to be a fixed value.

Owner review, decision 1 — the `microvm.socket` placement follow-up is now recorded as allod/nexus#27. The ruling stands unchanged: the fixed value and its placement in the join are accepted, and nothing here blocks merge. The issue captures the alternative the review named — `allod/nexus` exporting the path constructor beside the post-stop hook that recursively removes the resulting directory — so the reasoning is findable from the nexus side rather than only from this pull request's body. It is explicit that it closes no current gap: nexus duplicates no literal (`qmpDirectory = builtins.dirOf qmpSocket`, `nix/microvm/host.nix:84`), both sides of the overlap assertion come from one string in one file, and `checks/microvm/isolation.nix` already witnesses that the removal spares a running sibling's socket and prepared credential. Its trigger is a second consumer of the QMP root, or the layout ceasing to be a fixed value.
Author
Member

Owner review — the remaining suffixed lock labels were checked, and one more is collapsible. Recorded as allod/archetypes#48; not a change request against this branch.

After this branch the graph is 18 nodes with two suffixed pairs left, and they are not the same kind of thing.

pair revisions verdict
inventory / inventory_2 both 15ad5528 duplicate — same source, same revision, same effective nixpkgs
home-manager / home-manager_2 bf9ce9fe / 3ee51fbd not a duplicate — the extra one is the pin agenix declares for its own module

inventory_2 exists because flake.nix:11 redirects the secrets input's nixpkgs but not its inventory. One line takes the graph to 17, with no revision moving — the same coincidence this branch removed for allod/vm, and worth removing for the same reason: inventory is the sole source of machine facts, so two nodes at different revisions would have allod/secrets deriving key material against one fleet definition while this repo builds another. allod/deploy carries the identical pair and needs the same redirect.

home-manager_2 is left alone deliberately. Those hold different revisions, everything first-party already follows to home-manager_2, and this repo receives agenix as a follows rather than a declared input, so the redirect could only land in allod/vm and would assert agenix's module works against a home-manager it did not pin. That is a compatibility bet rather than a duplicate removal, and it is out of scope in allod/archetypes#48 with that reasoning recorded.

Owner review — the remaining suffixed lock labels were checked, and one more is collapsible. Recorded as allod/archetypes#48; not a change request against this branch. After this branch the graph is 18 nodes with two suffixed pairs left, and they are not the same kind of thing. | pair | revisions | verdict | |---|---|---| | `inventory` / `inventory_2` | both `15ad5528` | duplicate — same source, same revision, same effective nixpkgs | | `home-manager` / `home-manager_2` | `bf9ce9fe` / `3ee51fbd` | not a duplicate — the extra one is the pin `agenix` declares for its own module | `inventory_2` exists because `flake.nix:11` redirects the `secrets` input's nixpkgs but not its inventory. One line takes the graph to 17, with no revision moving — the same coincidence this branch removed for `allod/vm`, and worth removing for the same reason: inventory is the sole source of machine facts, so two nodes at different revisions would have `allod/secrets` deriving key material against one fleet definition while this repo builds another. `allod/deploy` carries the identical pair and needs the same redirect. `home-manager_2` is left alone deliberately. Those hold different revisions, everything first-party already follows to `home-manager_2`, and this repo receives agenix as a `follows` rather than a declared input, so the redirect could only land in `allod/vm` and would assert agenix's module works against a home-manager it did not pin. That is a compatibility bet rather than a duplicate removal, and it is out of scope in allod/archetypes#48 with that reasoning recorded.
vnprc approved these changes 2026-08-22 02:40:24 +01:00
vnprc merged commit 59cfc2c49f into master 2026-08-22 02:40:30 +01:00
vnprc deleted branch agent/microvm-host-guest-join 2026-08-22 02:40:30 +01:00
Sign in to join this conversation.
No description provided.