# Disposable systemd VM acceptance contract Status: Milestone 9 is paused at the user's request. The exact disposable VM was powered off after a guarded first start and an installer progress screen; read-only `list runningvms` was empty afterward. Debian installation completion, guest provisioning, reset, cleanup, and systemd acceptance are unverified. This contract supplements [Testing and Acceptance](TESTING.md) and the M9 criteria in [PLANS.md](../PLANS.md). Product behavior is governed by [SPEC.md](SPEC.md), [Runtime observation and verification](RUNTIME.md), and [ADRs 0001–0003](adr/). ## Selected target The selected target is exactly one new VirtualBox VM: | Field | Required value | | --- | --- | | Provider | Oracle VirtualBox, local `VBoxManage` 7.2.8_Gentoo | | VM name | `wga-m9-debian137-20261025` | | Guest base | Official Debian 13.7.0 (Trixie) amd64 netinst ISO | | ISO path | `/media/data/wm/Virtualbox/wga-m9-lab/debian-13.7.0-amd64-netinst.iso` | | ISO SHA-256 | `a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355` | | VirtualBox OS type | `Debian13_64` | | Lab root | `/media/data/wm/Virtualbox/wga-m9-lab` (owner `sasa`, mode `0700`) | | Networking | One NAT NIC only, no forwarding; all other NICs disabled | | VM UUID | `b14c850b-a21e-4a56-acca-785590f0c4ac`, pinned in the mode-`0600` identity manifest | | Guest marker | `/etc/wga-m9-lab-marker`, exact content `wga-m9-debian137-20261025` after provisioning | The ISO is present, has the stated SHA-256, and was obtained from Debian's [official amd64 netinst directory](https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/). VBoxManage command forms were checked against the local 7.2.8 help and Oracle's [VirtualBox 7.2 VBoxManage manual](https://docs.oracle.com/en/virtualization/virtualbox/7.2/user/vboxmanage.html). Read-only `VBoxManage unattended detect --iso=... --machine-readable` reported `OSTypeId=Debian13_64`, `OSVersion=13.7.0 Trixie`, and `IsInstallSupported=on`, but then exited 1 with `NS_ERROR_NOT_IMPLEMENTED`. The later bounded `unattended install --dry-run --start-vm=none` succeeded for this exact VM with private synthetic password files, a fully qualified `.invalid` hostname, and the local Guest Additions ISO. The dry run made no VM changes; actual unattended preparation and guest boot remain unverified. VirtualBox's dry-run output contains plaintext password fields, so the wrapper suppressed them and displayed only redacted output. Any actual unattended step needs a reviewed exact-target procedure and post-prepare media inspection before start. The approved read-only inventory outside the sandbox found `/dev/vboxdrv` available and `VBoxManage` 7.2.8 working. It initially listed Windows 7 and Windows 11 VMs; neither is a test target or was modified. The create-only run registered only the new Debian target and stopped before configuration. The default sandbox does not expose the host driver/device, so host inventory and later VBoxManage operations must use the approved outside-sandbox path. The guard matches the exact new name/UUID and does not inspect existing VMs' per-VM files. ## Guarded host operations [`tests/vm/guard.py`](../tests/vm/guard.py) is a narrow standard-library Python guard, not a general VirtualBox wrapper. Its default `check`, `start`, and `authorize-guest` actions are outside Cargo's default test targets. The guard: - Requires the exact lab root to be a real directory owned by the current user with mode `0700`, and `target-identity.json` to be a regular current-user file with mode `0600`. - Requires the manifest to pin the exact VM name, current owner UID, one valid UUID, and host marker `:`. - Uses `VBoxManage list vms` to require that exact name occur once with the pinned UUID, then `showvminfo --machinereadable` to compare UUID/name/marker. - Requires the VM configuration and its one VDI to reside below the lab root, the VDI to be named exactly `wga-m9-debian137-20261025.vdi`, and any mounted installer ISO to be the pinned Debian ISO in that root. - Requires a complete inventory of all eight NIC slots, with only NIC 1 in NAT mode. NAT forwarding and all VirtualBox shared folders are refused. - Performs every identity, path, and isolation check before `startvm`. A missing, duplicate, malformed, or mismatched identity fails closed. The only start target is the pinned UUID, in the GUI frontend for console installation. - Before guest authorization, repeats all host checks, requires a current-user password file with mode `0600`, and reads only the guest marker via `VBoxManage guestcontrol ... run /usr/bin/cat` with `--wait-stdout` and a 15-second guest process timeout. Host stdout/stderr capture is also capped at 1 MiB while the process runs. The password is supplied using `--passwordfile`; it is never placed in argv. Marker output is compared exactly and is not echoed. This read is unprivileged inside the guest. The host marker is a pre-install bootstrap identity: it binds the created VirtualBox object and protected manifest before the first start. It is not proof of guest identity. The guest marker is installed only after Debian setup and is checked separately after boot. Do not perform root provisioning, run application acceptance, or access the guest system bus until `authorize-guest` succeeds for the exact UUID. The guest marker check is deliberately read-only; it does not grant the caller a general guest command runner. The exact current focused checks are: ```sh python3 -m unittest discover -s tests/vm -p 'test_*.py' -v python3 -m py_compile tests/vm/guard.py tests/vm/test_guard.py git diff --check ``` They use a temporary synthetic VM inventory and fake VBoxManage responses. They do not call VBoxManage, start a guest, contact a host service, or read any registered VM configuration. The behavioral cases cover normal exact identity, missing/duplicate/wrong UUID, wrong name, manifest/root permissions, storage escape, NIC mode and incomplete inventory, NAT forwarding, shared folders, host/guest marker mismatch, no `startvm` on refusal, and exact UUID start scope. The guard supports these later commands once the selected VM has been created, its manifest and marker have been recorded, and the coordinator has reviewed the mutation step: ```sh python3 tests/vm/guard.py check python3 tests/vm/guard.py start python3 tests/vm/guard.py authorize-guest --password-file /path/to/private/guest-password ``` The placeholder password path above must name a private file owned by the current user with mode `0600`. Do not put a password value in the command line, environment, terminal history, or output. The `start` command is an implemented guarded operation but has not been run. Running it requires the approved outside-sandbox path because the default sandbox does not expose the host VirtualBox device. ## Create-only procedure (ran once; do not rerun) [`tests/vm/create.py`](../tests/vm/create.py) exposes only the fixed create operation; it accepts no target, path, or action override. After confirming the lab root owner/mode, exact ISO digest, absence of both the identity manifest and the exact target VM path (using `lstat`, so dangling symlinks also count as occupied), and a well-formed VirtualBox inventory without the selected name, it issues bounded VBoxManage calls to: 1. Register `wga-m9-debian137-20261025` as `Debian13_64` below the lab root. 2. Configure 1024 MiB RAM, one CPU, NIC 1 in NAT mode, NICs 2–8 disabled, audio disabled, USB disabled, and DVD then disk boot order. 3. Create one dynamically allocated standard 8192 MiB VDI, attach it on SATA port 0, and attach the pinned Debian ISO on SATA port 1. 4. Write the actual VM UUID and `:` host marker in `target-identity.json` with mode `0600`, set the same marker as the VirtualBox description, then run the existing read-only host guard. Run this exact command only after coordinator review of the current host state and the scoped mutations: ```sh python3 tests/vm/create.py ``` The command must run through the approved outside-sandbox path because the default sandbox cannot access the VirtualBox host driver. Its effects are VM registration, storage/network/device/boot configuration, ISO attachment, and the protected host identity manifest. It does not start the VM, install Debian, use guest control, create snapshots, or delete/clean up resources. VBoxManage calls have 30-second timeouts and 1 MiB per-stream output limits. Errors omit VBoxManage output; after registration they report the exact target name and known UUID. A failed procedure retains any partial target and its identity for inspection; it performs no automatic rollback or broad cleanup. The final guard must pass before the script reports success. The first run registered the UUID above, then stopped when strict parsing rejected VirtualBox's indented `rec_screen0` recording marker. The parser was narrowly repaired and independently re-reviewed. The target path is now occupied, so `create.py` must not be rerun; use the reviewed resume procedure below. ## Resume the registered partial target (ran once; do not rerun) The earlier create attempt registered the selected target before stopping at the initial machine-readable parse. The exact retained target is `wga-m9-debian137-20261025` with UUID `b14c850b-a21e-4a56-acca-785590f0c4ac`; it is powered off and has only its `.vbox` configuration file. Its observed NIC inventory was NIC 1 in NAT with NICs 2–8 disabled. It had no identity manifest or VDI. That prior state was the sole input accepted by [`tests/vm/resume.py`](../tests/vm/resume.py). The resume-only procedure is fixed to that UUID and lab root. Before its first mutation it checks the private root, exact pinned ISO path and SHA-256, absent manifest and target VDI, unique registered name/UUID identity, exact `.vbox` path, powered-off state, empty host description, default memory/CPU settings, the observed NIC inventory (NIC 1 in NAT and NICs 2–8 disabled), no attached media or storage controller, no shared folders, and no NAT forwarding. It refuses an unexpected adapter or preexisting state. After preflight it configures memory/CPU, NAT-only networking, disabled audio and USB, boot order, one standard 8192 MiB VDI, and the pinned ISO; then it writes the protected host manifest, sets the host marker, and runs the existing read-only guard. It does not create, start, unregister, delete, or clean up a VM. A failure after mutation retains the exact target and reports its pinned UUID for inspection; no rollback is attempted. The focused command is: ```sh python3 -m unittest discover -s tests/vm -p 'test_resume.py' -v ``` It uses only a temporary synthetic lab and fake VBoxManage responses. It does not call VBoxManage or establish host/VM evidence. The implementation command was `python3 tests/vm/resume.py`; it ran once via the approved outside-sandbox path. It configured the exact target and wrote the host marker/manifest. Its final guard call initially refused observed VirtualBox storage metadata; focused repairs and independent re-review closed those parser and guard findings. A subsequent read-only `python3 tests/vm/guard.py check` passed against the actual powered-off target. Do not rerun the resume command on this occupied state. Do not infer `unregistervm --delete`, snapshot restore, guest-control, or broad cleanup commands from the CLI help. No provisioning command is documented because Debian installation, Guest Additions/management channel, the unprivileged `wga` account, and guest marker placement have not been validated. No reset or deletion command is documented because a baseline snapshot and exact owned resource IDs do not yet exist. Existing registered VMs must remain untouched. ## Unattended preparation (ran once; do not rerun) [`tests/vm/install_prepare.py`](../tests/vm/install_prepare.py) is pinned to the VM UUID above and accepts no target, path, credential, or action overrides. It first checks the current user's `/tmp/wga-m9-private` directory is a real mode `0700` directory and its nonempty `wga-password` and `root-password` files are current-user-owned regular mode-`0600` files. These are synthetic install credentials only; password contents never enter argv or output. It verifies the pinned Debian ISO and `/usr/share/virtualbox/VBoxGuestAdditions.iso` SHA-256 digests, then calls the existing exact-target read-only guard and additionally requires the VM to be powered off, with the expected VDI at `SATA-0-0`, the pinned installer ISO at `SATA-1-0`, and every other reported SATA attachment slot set to `none` or `emptydrive`. This accepts VirtualBox 7.2.8's real `showvminfo` inventory (`SATA-2-0` through `SATA-29-0` are `none`) while rejecting any additional attached media. It refuses before mutation if any check fails. The script runs a bounded `VBoxManage unattended install ` dry run first with `--iso=` and `--admin-password-file=/tmp/wga-m9-private/root-password`, using the synthetic `wga` account and password file, `.invalid` FQDN, `en_US`, `UTC`, US country, minimal package adjustment, and the pinned Guest Additions ISO. The root credential option is `--admin-password-file`; `--root-password-file` is not a valid option for the local VBoxManage 7.2.8. Only a successful dry run permits the same bounded command without `--dry-run`. Both commands request `--start-vm=none`; the script has no start, cleanup, or retry operation. VBoxManage output is captured privately because its dry-run output contains plaintext password fields; errors and status output omit captured text. The command has a 120-second deadline and 1 MiB per-stream capture limit. After independent review, the command ran once through the approved outside-sandbox VBoxManage path: ```sh python3 tests/vm/install_prepare.py ``` The command exited 0 and left the VM powered off. Read-only `showvminfo` inspection found the exact VDI at SATA 0 and a VirtualBox-generated auxiliary `.viso` at SATA 1, both in the private target folder. The generated installer files are mode `0600`; the target folder is mode `0700`. The normal host guard refuses the `.viso`, as designed, so the first start requires a separate guard for this exact installer state and independent review. Do not rerun preparation on the occupied state. Guest boot, Guest Additions setup, and guest provisioning are not established by this result or the synthetic tests. The focused behavioral tests use only temporary synthetic files and a fake VBoxManage runner: ```sh python3 -m unittest discover -s tests/vm -p 'test_install_prepare.py' -v ``` ## First start of the prepared installer (ran once; VM stopped) [`tests/vm/install_boot.py`](../tests/vm/install_boot.py) adds a separate, fixed-target `check`/`start` command for the one powered-off installer state. The ordinary [`guard.py`](../tests/vm/guard.py) deliberately continues to reject the generated `.viso`; this installer guard does not loosen that normal guard. It reuses the strict machine-readable parser and checks the exact current-user-owned mode-`0600` identity manifest, pinned UUID and matching host description, exact target name/configuration path, and current-user-owned mode-`0700` target folder. It requires the VDI at SATA 0 and exactly `Unattended-b14c850b-a21e-4a56-acca-785590f0c4ac-aux-iso.viso` at SATA 1; SATA ports 2–29 must all be `none`. The `.viso` and any generated `isolinux`/`preseed`/`postinstall` files are checked as current-user-owned mode-`0600` regular files with exactly one hard link. The VM configuration and VDI are also required to be regular current-user-owned files with exactly one hard link. The guard never opens or prints installer-file contents. The VM must remain powered off during preflight, have only NIC 1 in NAT mode, and have no forwarding rules or shared folders. Symlinked paths, extra media, unknown attachment metadata, and incomplete or mismatched identity refuse before start. The exact commands are: ```sh python3 tests/vm/install_boot.py check python3 tests/vm/install_boot.py start ``` `check` only reads the protected manifest, filesystem metadata, and bounded VirtualBox inventory; it does not change VM state. `start` repeats all checks, then issues only `VBoxManage startvm b14c850b-a21e-4a56-acca-785590f0c4ac --type=headless`. It accepts no UUID, root, media, credential, or action override. Each VBoxManage call uses the existing guard's 15-second deadline and 1 MiB per-stream output cap. Output is not copied into errors. If the start call fails or times out, the result explicitly says the VM may have started and requires a separate read-only inspection; there is no retry, cleanup, or rollback. A successful command means only that VirtualBox accepted the start request. It does not prove guest boot, Debian installation, or Guest Additions. Focused fake-runner coverage is: ```sh python3 -m unittest discover -s tests/vm -p 'test_install_boot.py' -v ``` These tests create synthetic private files and never call VBoxManage. Both commands ran once against the actual VM after independent review. `check` passed; `start` returned success; VirtualBox listed only the pinned target as running. A private console screenshot showed Debian's base-system installer at 6%. The user then stopped Milestone 9, so the exact target was powered off and read-only `list runningvms` returned no running VMs. Do not restart it without a new explicit request. Installation completion and guest boot remain unverified. After installation, provision only synthetic lab packages, fixed reviewed service units, synthetic WireGuard keys/configuration, and the guest-local marker. Keep all server/client data, outputs, and working backups under a guest-only root-owned `0700` directory; secret files are `0600`. The guest-local marker must be readable by the unprivileged `wga` account for the read-only marker gate. This work belongs to the next task after target creation and guard review; it is not claimed as implemented here. ## Isolation and acceptance boundary - Use synthetic server/client keys, configuration, settings, and vault contents generated inside the guest. Preserve imported originals. Do not copy or read production keys, configuration, backups, or credentials. - The VM may have only one NAT management adapter, with no forwarding rules; do not use bridged, host-only, internal, NAT-network, USB network, or physical adapters. NAT-only VM egress still traverses the host's ordinary network; do not claim host-independent network isolation. No host D-Bus, `/etc`, `/run`, `/var`, WireGuard interface, service control, or general shared folder may be mounted or forwarded into the guest. - A guest system D-Bus and PID 1 must be guest-local. Keep console management independent of the VPN under test. Tunnel probes target only synthetic addresses reachable through the test tunnel, never the NAT management path. - Provision only fixed, reviewed lab units/drop-ins and fixture hooks. Imported configuration text remains data. Preserve administrator settings and prove preflight refusal using synthetic fixtures rather than silently rewriting `SaveConfig`. - Exercise the public `Application::restart` flow with the real zbus service adapter and Rustix observer. A test seam may inject persistence failures only where already established; it may not replace service submission or runtime observation as evidence of systemd behavior. Alpine fixture apply remains separate evidence. - Run the actual release executable separately from any `cfg(test)` driver. Capture source/dirty identity, compiler/locked dependencies, target, artifact SHA-256, and dynamic-library requirements for both artifacts. Prove each executable starts in the guest. - Keep secrets out of argv, environment variables, image metadata, logs, transcripts, and evidence. Use bounded typed control, private files/channels, monotonic timeouts, and bounded output. A timeout after service submission means it may still complete; it is not cancellation. - Before every scenario, reset to a recorded clean guest state and verify both the pinned host identity and guest marker. Afterward, stop only resources created by that run and verify their exact IDs. If cleanup is uncertain, preserve IDs and report them; never use broad host cleanup. Preserve the original failure if cleanup also fails. ## Required scenario evidence For every run, record UTC start/end, guest and base identities, kernel/systemd/ D-Bus/WireGuard versions, both marker results, source/artifact identities, exact commands, exit status and bounded redacted outcomes, backup/archive identities, reset/cleanup result, and the acceptance criteria supported. Keep raw console output private; evidence must contain no secrets or complete client config. Record each scenario independently so failures cannot be masked by unrelated fixture behavior. | Scenario | Required observable evidence | | --- | --- | | Successful apply and fast job | Existing synthetic client has positive tunnel-only IPv4 traffic. Through one `Application`, create and Commit a client; show saved/runtime divergence and that Commit did not submit a service job. Cancel preview with no backup/job/mutation. Confirm restart: pre-restart backup completes first, real zbus job for the resolved primary unit reaches `done`, Rustix observes the expected interface and complete matching peer/routes, and durable verification is written. Verify new tunnel traffic, reopen and show verification is historical. Repeat with fast completion and a configured alias resolving to the primary unit; retain the completion signal. Include application outcomes, not journal inference alone. | | Deletion and reservations | Establish positive traffic before deleting/Committing. Show the client remains in runtime and its address remains reserved until successful real restart and complete verification. Then show it absent, tunnel-only traffic fails within a bounded probe, durable reservation releases, and old backups remain. | | Failure and deadline | Using fixed guest-only units/drop-ins, show terminal job failure and submitted-job deadline expiry with bounded results, accurate submitted/may-still-complete status, no automatic retry/rollback, no false Verified state, and retained reservations. Also show unresolved unit fails before submission and backup failure prevents restart. After late completion, explicitly observe and reset the guest; timeout is not cancellation. | | Job succeeds, verification fails | A dedicated fixed fixture service completes while leaving the interface absent or peers mismatched. Show the real adapter reports verification failure and reservations remain. Separately inject saved-state write failure only through the established persistence seam after real restart/observation; show runtime changed without durable Verified state and `RecoveryRequired` blocks unsafe mutations. | | Restore and manual recovery | Preview and confirm a known-good archive without vault password. Show precautionary backup, full saved settings/config/vault replacement, and unchanged runtime before explicit restart. Then run real restart/verification and expected tunnel check. Demonstrate documented manual recovery; no hidden rollback. Confirm permissions and reopen behavior. | | Release executable smoke | In guest console/PTY, prove identified release binary starts from its private directory and settings resolve beside it. Exercise current-client/statistics view and one controlled Commit/restart through the real worker path; confirm exit/cancel restores terminal. Help/version work as non-root without settings; non-root management is rejected. Record output/backup permissions. | The final M9 evidence must map these observations to M9-A1–A9 and the specification-to-evidence matrix. State the tested scope, including IPv4 tunnel coverage, synthetic dual-stack tests, unverified real IPv6 behavior, and QR/device interoperability limits. A mocked bus, Alpine fixture apply, missing prerequisite, skipped test, or guest boot alone does not satisfy real systemd acceptance. ## Next action None while Milestone 9 is paused by the user. The disposable VM is powered off; do not resume installation, provision it, or run acceptance without a new explicit request. No M9-A1–A9 check is complete.