docs/assumptions.md
Ref: Size: 42.7 KiB History
# Assumptions
What the decisions rest on. Every entry is a claim that could be false, the
decision that would be wrong if it were, and how we know—or that we don't.
Three lines each. For the choices themselves, see [decisions.md](decisions.md).
Write one down whenever a decision is made. An assumption nobody recorded is
one nobody can re-check when the ground moves.
### A guest's address is host-local
Nothing outside a host ever dials a guest: the gate resolves a name to
(host, VM) and tunnels over the sync connection, and the agent on that host
does the dial. Underpins putting network attachment inside the provisioner.
**Proven**: traced in code, and live—gate SSH reaches guests on two hosts.
### The reservation table is the used-address set
The agent's DHCP responder builds its allocation exclusion list from the same
map it stores reservations in, under one lock. This is why replaying
reservations at startup makes address collisions impossible, and why the
address hint was redundant enough to delete.
**Proven**: verified in code; a new VM has never taken a live guest's address.
### An agent upgrade preserves running guests and their addresses
Replacing the binary under running VMs leaves them alive and re-adopts them
with the same addresses. Underpins rolling agents to a live fleet at all.
**Proven** 2026-08-01 on bare metal: a guest created by the old agent survived
the swap, kept its address, and stayed reachable through the gate.
### A host reboot restores guests, with their addresses
The boot identifier changes, every guest is detected lost, and every one of them
is booted again—the restart policy is not a per-VM choice. Underpins running
anything you care about on a machine that reboots.
**Proven** 2026-08-01 on bare metal: a host rebooted, and within a minute of
boot a guest was running again on the same address it held before, its disk
intact.
Re-proven 2026-08-02 on the nested gate host after pidfiles became boot-scoped:
same address, same disk, and the agent correctly read the pre-reboot pidfile as
naming nothing of its own.
### An image we cannot name is safe to treat as raw
Sniffing recognizes gzip and qcow2 by magic number; everything else is called
raw and handed to the guest firmware to interpret. Underpins classifying by
bytes at all, rather than rejecting what we do not recognize.
**Unverified for the tail**: a VMDK or VHD would be called raw and materialized
as one, and the guest would fail at boot rather than the agent failing at
create. Raw has no magic of its own, so the alternative—an allowlist—would
reject legitimate raw images, which is worse.
### Old agents tolerate raw images
`qemu-img convert -O raw` auto-detects its input, so a raw image is copied
rather than rejected. This is what makes the rollout order safe—publish raw
and repoint the default *before* shipping the agent that drops conversion.
**Proven** 2026-08-01: converting a raw input with `-O raw` yields a
byte-identical copy at exit 0, with the input detected as raw. The claim is
about the tool, not a particular host—which is why it outlived the fleet's
last old-agent machine.
### Guests nest two levels deep
A guest running on a guest can itself run guests, so the boot-gate harness can
live in a VM on a fleet host rather than needing bare metal of its own.
Underpins rebuilding the gate anywhere except a spare physical machine.
**Proven** 2026-08-01: a full stack inside a guest created, booted, gate-SSH'd,
power-cycled and reaped its own guest, two levels down on the current host.
Cold start was 20s against ~10s on bare metal—the extra layer costs latency,
not viability.
### macOS hands out guest addresses; we can only read them
vmnet's DHCP assigns after the guest boots, and the lease is readable without
privileges. Underpins discovering the address rather than assigning it, and
rejecting both bootpd reservations and a userspace network stack.
**Proven** 2026-08-02 on an M1: an arm64 guest leased 192.168.64.9 from vmnet's
bootpd, the agent found that lease under the VM's deterministic MAC, recorded
it, and `eitri ssh <name>` reached the guest through the gate on the strength of
it. The guest's `DHCPv4 Client ID` is the MAC, which is what makes the lookup
find the live lease rather than a stale one — see the DHCP-identifier entry.
### Nobody depends on bring-your-own qcow2
Dropping conversion entirely—rather than keeping a fallback—assumes no user
supplies their own qcow2 image today.
**Unverified**: we have no telemetry on custom `image_url` use. The blast
radius is a permanent, legible failure at create, not silent breakage.
### A laptop is a viable fleet host
A Mac that sleeps and wakes rejoins without intervention. Underpins enrolling
one at all.
**Partly proven**: the agent survived sleep and reconnected on its own—with
zero guests. What sleep does to a host *running* VMs is untested, and neither
reconcile nor lost-VM detection was designed for it.
### Image servers send Content-Length
A truncated download therefore errors inside the copy, before the checksum is
compared—so reaching a mismatch means the artifact genuinely is not what was
pinned. Underpins treating a checksum mismatch as permanent rather than
retrying it twice more for the same verdict.
**Unverified**: a length-less identity response, or an HTTP/2 stream closed
cleanly early, would truncate indistinguishably. Real image hosts send it.
### `/usr/local/bin` is on every agent's PATH
The startup preflight resolves `cloud-hypervisor` the same way the provisioner
later will, so a fatal preflight cannot refuse to start a host that would
otherwise have worked. Underpins making the check fatal at all.
**Partly proven**: it is where bootstrap installs, and systemd's default PATH
covers it. A fleet-wide roll is the moment this would bite, so a cold-start run
must pass before one.
### Lowest-free-first allocation is a property, not an implementation detail
Reaping a VM frees its exact address for the next allocation, which is what
makes "the address came back" a meaningful assertion rather than a coincidence.
**Proven** in code (`ipalloc`), but nothing pins it as a contract—a future
allocator that randomised would silently weaken the address-reuse gate.
### Fleet hosts can reach the image server locally
Serving guest images from the box that serves eitri.sh assumes every host
reaches it over the LAN rather than hairpinning out through the uplink, and
that hosts stay on that LAN.
**Unverified**: one fleet host is a laptop that leaves the network, and a
public hostname resolving to a public address would route the long way round.
### A backend that refuses preflight refuses everything after it
Reconcile asks the backend once, before the image fetch, whether this host can
run a guest at all, and treats a permanent refusal as the VM's verdict.
Underpins spending nothing—no download, no create slot—on a VM the host can
never boot, and reporting the host's own reason rather than the first expensive
step's symptom.
**Partly proven**: pinned in reconcile's tests, and `vfkit` is the only backend
that refuses today — a Mac with no vfkit installed. It assumes refusal is a
static property of the host, which is why preflight is asked per create rather
than once at start: a host whose answer can change (vfkit installed while the
agent runs) is then re-asked rather than judged at boot.
### A gzipped image wraps a raw one
Gzip is transport, not a disk format, so a `.gz` download is decompressed and
the result treated as the image. Underpins publishing guest images gzipped—802
MB on the wire against 3.5 GB raw, and smaller than the qcow2 it replaces.
**Proven** for the failure that matters: the decompressed head is sniffed
before a byte is written, so a gzipped qcow2 is rejected permanently instead of
landing in the cache under a `.raw` name and failing later at boot. Corrupt
gzip is permanent for the same reason a checksum mismatch is—the sha already
matched, so re-fetching returns the same bytes.
### Decompression is cheaper than the download it saves
Measured on the real image: 802 MB gzipped, 6.8s to decompress with C gunzip
and 14.4s writing sparsely. Go's `compress/gzip` is slower, so budget 15-25s.
Underpins choosing gzipped raw over plain raw.
**Partly proven**: measured on one host (6 vCPU, nested). A slow host pays more
CPU for less transfer, which is the right trade on a LAN and the wrong one only
if a host is very slow and very well connected.
### Guest images are ours to decode, not a tool's
qcow2, vmdk, vhdx, vdi, parallels, vpc and asif are parsed in-process by
go-qcow2reader; anything none of them claims is raw. Underpins dropping the
`qemu-img` dependency outright rather than making it a per-host capability, and
so underpins a macOS host booting a stock cloud image at all.
**Proven** for qcow2: a real qcow2 fixture decodes byte-identically, trailing
zeros and offsets included. **Unverified** for the other six formats — we
publish none of them and the library's tests are the only evidence they work.
The failure is legible either way: an unparsable header is a permanent error
naming the artifact, not a guest that quietly refuses to boot.
### A gzipped image is raw inside
Gzip is a stream, so the decoder — which needs random access — cannot look
inside one. A `.gz` is therefore decompressed and its first bytes sniffed
directly, and a gzipped qcow2 is refused rather than decoded.
**Proven** for the qcow2 case. A gzipped vmdk would slip through as raw and
fail at boot; nobody publishes one, and the fix if they do is to decompress to
a temp file and hand it to the decoder.
### A guest image is self-contained
A qcow2 may name a backing file, and the decoder follows the name: an absolute
path as written, a relative one against the download's directory, opened by
the agent as root and read through wherever the image left a cluster
unallocated. eitri refuses any image that names one, before a byte is decoded.
Underpins letting a tenant point `image_url` anywhere: the artifact can only
carry what the tenant put in it, never what sat beside it on the host.
**Proven** in code: the refusal is a permanent error naming the URL, the
decoder is handed a reader with no filename so a relative reference cannot
resolve even if the check is bypassed, and a fixture whose backing file holds
a sentinel decodes to nothing that contains it. No published cloud image ships
with a backing file; one that does is a snapshot, not an image, and was never
going to boot elsewhere.
### vfkit is the helper we would have written
Virtualization.framework is reachable only from Objective-C or Swift, so a Mac
host needs a signed helper process between the agent and the framework. vfkit is
that program already written — Apache-2.0, entitled, Homebrew-installed, one
process per VM that outlives its parent. Underpins shipping a macOS backend
without a Swift target, a signing identity, or a notarization step of our own.
**Unverified as a dependency**: it is maintained for podman and crc, not for us,
and its command line is the contract we build against. A breaking change there
breaks guest creation on Macs. The escape hatch is that the surface we use is
small — a bootloader, three device kinds and two REST calls.
### A Mac's guests live outside the CIDR the fleet assigned it
The server allocates every host a bridge CIDR, and macOS ignores it: vmnet owns
its own subnet and its bootpd assigns from that. So a Mac's guests report
addresses from a range the console never handed out.
**DISPROVEN as harmless** 2026-08-01, on the first real guest. This was written
claiming the divergence was harmless because nothing dials a guest from outside
its host, so the address is only a label. It is not only a label: the control
plane validates it. `store.RecordVMStatus` drops any reported address outside
the owning host's `bridge_cidr` — a deliberate guard against APIPA addresses and
agent bugs, and a silent one, because logging per tick would spam. So the whole
chain worked and the address died at the last hop: vfkit booted the guest,
bootpd leased it 192.168.64.7, the agent read the lease and recorded it, and the
server discarded it. What that costs was established by experiment on
2026-08-02, and it is NOT `eitri ssh`: the gate resolves a connect name to
(hostID, vmID) and never reads the stored address (`internal/server/sshgate`),
and the agent then dials the address in its OWN record
(`internal/agent/syncclient`), which vfkit's `Address` does populate. What
breaks is every consumer of the stored `assigned_ip` — the console's IP column,
the MCP tools' wait-for-address (`internal/mcpserver/tools.go`) and the deploy
boot-gate (`internal/smoke/scenario.go`), and the last two HANG rather than
fail, because both poll for a field that will never arrive. A host whose OS owns
addressing has to tell the fleet which subnet its guests are on.
The same day's live `eitri ssh` against the real macOS guest DID fail, with
"cannot reach VM", and that was a separate fault now fixed in the seed: the
guest's record held 192.168.64.7, the lease bootpd keyed on the VM's MAC, while
the guest had come up on 192.168.64.8 under a DUID client identifier. Two
failures, one symptom, and only the second one stopped a connection.
**Resolved** 2026-08-02: the guard stopped asking about topology. It now asks
only whether the value names a guest anything could reach — parses, and is not
unspecified, loopback, link-local or multicast — which needs to know nothing
about any host. Two halves were needed, because the guard explained a blank
address once and the status cache is what made it permanent: `RecordVMStatus`
returns the address it wrote, and `syncsvc` caches that rather than the value it
sent, so a dropped address no longer suppresses every later report that would
have corrected it. **Proven on hardware** 2026-08-03 on an M1 against a
Mac-local control plane: `assigned_ip = 192.168.64.10`, an address no fleet
allocation contains, accepted and stored. The Linux gate cannot show this — its
guests sit inside their own host's `bridge_cidr` and satisfy the old guard too.
**Inverted** 2026-08-04, which is what the entry above was asking for: the
subnet a host's guests are on is now reported BY the host, in every
`Report`, and the fleet records what it is told. Proven on the same
M1 — its row read 10.102.1.0/24, an allocation from a pool it never used, and
now reads 192.168.64.0/24, which is where its guests actually are.
The transition is worth keeping, because it is the empty-means-no-answer rule
made visible rather than asserted. With a guest running: `guest subnet
cidr=192.168.64.0/24`. Twenty seconds after it was reaped, vmnet's bridge gone
with it: `guest subnet unknown — the fleet keeps this host's existing record`.
The row still reads 192.168.64.0/24. A host that cannot see its own network
leaves the fleet's memory alone rather than erasing it.
### A Mac can read the configuration of its own guest network
vmnet states the guest network outright in
`/Library/Preferences/SystemConfiguration/com.apple.vmnet.plist` —
`Shared_Net_Address` and `Shared_Net_Mask`, an address and a netmask, exactly
what a host needs to report its subnet. Underpinned reading it there.
**DISPROVEN** 2026-08-04: that file is root-only, and the agent does not run as
root on macOS. It has no reason to — the guest network belongs to
Virtualization.framework and the entitlement to vfkit — so reading one config
file would have been the only thing it ever needed privileges for, which is a
bad trade. Two implementations died on this. The first asked `defaults`, which
resolves domains through cfprefsd rather than opening the path it is given, and
answered "The domain/default pair does not exist" for a file `plutil` reads
happily. The second asked `plutil`, which opens the file and is refused.
What replaced it uses nothing privileged: macOS's lease database is
world-readable and holds addresses vmnet has actually handed out, and the
interface list carries each network's own mask. The guest subnet is the host
network containing a leased address — an address inside it, and a prefix length
read rather than assumed, which is the half a lease alone could never supply.
The cost is that a Mac with no guests running reports no subnet, because vmnet's
bridge exists only while a guest is attached to it. That is honest, and the poll
design makes it free: the answer arrives with the first guest.
**The thing that made this expensive to find** was that all three readers fail
IDENTICALLY and correctly. An unreadable configuration reports no subnet, the
fleet keeps its existing record, and nothing anywhere says so — a wrong command
and a Mac with no network configured are indistinguishable. The agent now logs
its guest subnet when it changes and warns when it goes away, which is what
turned the third attempt into a single run rather than a fourth guess.
### Apple Silicon runs arm64 guests only
Virtualization.framework cannot emulate a foreign architecture, so a Mac host
needs an arm64 image where the rest of the fleet uses amd64.
**Proven the hard way** 2026-08-03: a one-click create on an M1 took the fleet's
single amd64 default and the guest never booted. Every symptom followed from
that one fact — no serial output, so a blank console; no boot, so no DHCP and no
address; then the hypervisor exited and the VM was reaped, reported as
"ephemeral VM lost", which names neither the image nor the architecture.
The original conclusion — leave the fleet default alone and pass an arm64 URL
per VM — was wrong, because it made a correct create depend on the creator
remembering. Both halves are addressed instead. `default_images` is keyed by the
architecture of the host a VM lands on, so a one-click create takes the image
its host can run, and an architecture with no configured image is refused at
create naming it rather than handed something it cannot execute. And a lost
guest now quotes whatever its hypervisor said on the way out, so the next
failure of this shape reads as a cause rather than an absence.
An explicit image is still not arch-checked, deliberately: a URL says nothing
about what its contents can execute, and guessing from a filename would reject
legitimate custom images to catch a mistake the operator made on purpose.
### vfkit's command line is what we think it is
The whole backend is an argv and two REST calls, built against vfkit's
documentation rather than against a running Mac. Underpins every claim about
how a guest boots here.
**Proven on Linux** 2026-08-01, against vfkit's own parser: the exact argv this
backend emits is accepted by `cmdline.AddFlags` + `config.BootloaderFromCmdLine`
+ `AddDevicesFromCmdLine` + `rest.NewEndpoint`, yields the intended machine (EFI
bootloader with `createVariableStore`, root disk before seed, NAT'd NIC with our
MAC, a pty serial, rng) and round-trips back to the same command line. What that
does NOT prove is that the framework then boots it — only that vfkit will not
reject it at the door.
### A pid is evidence only within the boot that wrote it
Every backend records a guest's process id and later signals it — to stop it, to
kill it, to ask whether it is alive. The state directory outlives the host and
pids are recycled, so a pidfile written before the last boot names whatever now
holds that number. Underpins recording the host's boot identifier beside the pid
and refusing to signal across a mismatch, rather than trusting reconcile's own
boot-ID guard, which does not cover the path that matters: `reapVM` reaches
`Shutdown` before any of that reasoning runs, and on Linux the agent is root, so
nothing would refuse the signal on its behalf.
**Proven** 2026-08-02 on the nested gate host: a persistent guest was booted
(pid 20774, boot `1c7730e5…`), the host was rebooted (boot `676cac05…`), and the
agent read the stale pidfile as naming nothing of its own, restarted the guest
on its old address, and rewrote the pidfile against the new boot. The one thing
still unproven is the collision itself — pid 20774 happened to be unused after
the reboot, so no stranger was there to be spared.
### An agent upgrade must not disown the guests it inherits
A pidfile with no boot identifier is treated as this agent's own, because it can
only have been written by the binary being replaced. Underpins rolling a
boot-scoped agent to a fleet whose guests were started by one that was not.
**Unproven in the field**: covered by unit tests on both backends, but the fleet
roll that would exercise it has not happened. The failure it guards against is
severe and quiet — refusing would read every live guest as lost and boot a
second hypervisor onto its disk — so the first roll should watch for a VM going
from running to creating rather than trusting the tests.
### 30000–32767 belongs to eitri on every host
Host ports for published guest ports are allocated from this range, and nothing
else on the host is assumed to want it. Underpins allocating a port without
asking the host what is free.
**Proven** in code: a bind that loses the port is reported failed with the OS
error and retried on every converge.
### A published port may be bound on every interface
The socket binds 0.0.0.0—every interface, IPv4 only. An exposure is
reachable on any IPv4 address the host has, and on none of its IPv6 ones.
Underpins there being one exposure rather than one per network.
**Proven** in code; the posture is deliberate—see the auth answer in
[faq.md](faq.md). A host whose networks are not equally trusted gets more
reach than it asked for.
### A published UDP port may be used as a reflector
UDP has no handshake, so a datagram's source address is a claim and nothing
more. The host sends the guest's reply to whatever address that claim names,
which means a published UDP port can be aimed at a third party. Underpins
publishing UDP at all, on the same terms as TCP: reaching the host is reaching
the service, and a port is published onto a network the tenant is content to
serve. **True by construction**, not mitigated—there is no rate limit and no
source check in the proxy. A guest whose service amplifies (answers larger than
it is asked) makes the host a better reflector than a bare port would.
### eitri holds no signing key for anyone
A caller lends eitri a credential instead: eitri offers an ephemeral public key,
the caller's own CA signs it, and eitri authenticates with the certificate until
it expires. Underpins a caller with nothing but a token reaching its own VMs,
which is what makes the hosted MCP endpoint usable — without a signing key
existing on the server to lose.
**Proven** in code: the keyring is a leaf package with no persistence, every
validation rule is unit-tested, and the deploy gate refuses a certificate signed
by a CA the tenant never registered.
### A delegation lives for the process, and nowhere else
Delegations are held in memory. A control-plane restart or redeploy drops every
one of them, and callers delegate again — one `ssh-keygen` and one call, because
the offered public key is stable per process. Underpins not persisting a
credential that would otherwise outlive the session that granted it: a restart
is a revocation, which is the property we want rather than a cost we pay.
**Proven** in code: nothing writes a delegation to the store, and the refusal a
caller gets afterwards carries the whole three-step recipe.
### A delegated certificate's principal is the guest login user
A guest trusts its tenant's CA set through a bare `TrustedUserCAKeys` line with
no `AuthorizedPrincipalsFile`, so sshd matches the certificate's principals
against the account being logged into — `ubuntu`, not the tenant. Underpins
validating the principal at delegation time rather than letting the mistake
surface as an opaque authentication failure at the guest.
**Proven** in code: the refusal names the principal that is missing and the
`-n` flag that adds it.
### A guest's SSH host key is generated on its host and never leaves it
The host generates the key, reports the public half, and the control plane signs
a certificate for the principal it derives from the VM's row. Underpins the
claim that eitri holds no guest key: there is nothing to escrow, because nothing
is ever sent. A host that has reported a key waits for its certificate rather
than booting a guest clients would refuse.
**Proven** in code: an integration test drives the round trip over a real sync
connection, and both the snapshot and the report are marshalled and searched for
private-key bytes.
### A host that cannot certify a guest is not a place to put one
An agent from before v0.0.4 never reports a guest host key, so a VM it creates
can never be verified and is unreachable through the gate for the rest of its
life. The control plane reads the version the host reported and refuses the
create outright — and reads a version it cannot parse, or none at all, as too
old, because neither proves otherwise. Underpins refusing early: at create the
operator can still upgrade the agent, while at connect the only remedy left is
to recreate the VM. Only a connected host is judged, because only a connected
host has said anything: what a registry holds in memory about an offline one is
not evidence about the agent that will run the VM.
**Proven** in code: the create path refuses each shape of unprovable version
and names the upgrade endpoint, lets a host it has not heard from take the
create, and placement chosen by eitri skips such hosts and says why when that
leaves none.
### What eitri signs with is encrypted where it rests
Every piece of key material the server holds—the fleet's own host CA and gate
host key, in the data directory—is sealed under a key-encryption key that lives
in the plane's config and nowhere near the data. A copied database, a nightly
backup, or a lifted volume therefore yields ciphertext and no signing power.
Underpins DR: a restore needs the config as well as the data, and the config
Secret is the key's only home.
**Proven** in code: sealed on the way out, opened on the way in, and a key file
that will not open stops the server rather than being regenerated—a fresh host
CA would invalidate every client's pin and every VM's host certificate at once.
### A staging plane on the same cluster catches hosted-shape failures
stg runs from the same manifests as prod, differing only in values, so a defect
in the deployed shape—a config key the Secret lacks, a listener that disagrees
with its advertisement, a route that does not carry /mcp—fails at stg first.
Underpins promoting to prod by re-running one script rather than by inspection.
**Unproven**: no pre-release has been shipped through it yet. One difference is
deliberate and known: stg signs in against the bundled issuer, so prod's
relying-party wiring against a third-party provider is proven only at prod.
### A pre-release orders below the release it leads to
v0.0.4-pre.1 < v0.0.4-pre.2 < v0.0.4, and builds derived from either sit
between them, so an agent walks a release cycle's tags the way it reads.
Underpins a staging plane rehearsing the agent-upgrade path on the very tags
that cycle produces—the failure that defined the v0.0.3 release day.
**Proven** in code, by the ordering the fleet itself uses: the full chain is
pinned in a test, and everything else—dev, a dirty tree, rc.1, a malformed
tag—stays unordered and is never offered an upgrade.
### Nested KVM is enough to gate a branch
A guest of a workstation VM boots under cloud-hypervisor and exercises the same
agent, bootstrap, gate and exposure paths bare metal does. Underpins moving the
branch gate off real hardware so a hosted outage cannot fail it.
**Proven** 2026-08-07: all four proofs in deploy/server/README.md passed on
eitri-dev1 (nested on the dev machine over macvtap) — cold deploy with the
agent bootstrap, warm re-deploy, deploy after a recycle, coverage merged.
Bridged networking and real-hardware quirks remain out of its reach, which is
why real metal gates pre-release tags instead.
### A proxied MCP origin carries long calls
The public-MCP story rests on Cloudflare not cutting a `vm_create` that runs
for minutes. Underpins serving `/mcp` through an orange-cloud hostname.
**Proven** 2026-08-07 at stg: progress notifications traversed the proxy at a
2-second cadence (8 events; create returned in 18.5s), and a deliberately
silent call survived 120 seconds intact — the believed ~100s silent-origin
cliff did not appear. What the proxy did block was a client signature:
python-urllib drew Cloudflare error 1010 (browser integrity check) while Go's
client and any self-naming User-Agent passed. A proxied `/mcp` must exempt
API user agents — or clients must send their own.
### The console shows a boot as well as the host's own log did
The serial console the control plane serves carries the same evidence the
agent's `serial.log` does — the host replays its backlog to whoever attaches,
so a boot is readable however late the watcher arrives. Underpins the boot gate
proving a guest booted through the API rather than by logging into its host,
which is what makes the gate host-agnostic: nothing it proves needs an ssh
target, a sudo rule, or a path on any machine in the fleet.
**Proven** on the branch gate: the same login prompt satisfies the first-boot
and post-power-cycle proofs, read from the console instead of the file. Console
history survives a restart, so the reboot proof marks the stream at the start
command and counts only what arrives after it — the freshness the old proof got
by truncating the file.
### The console ships no server runtime
`ssr = false`, `prerender = false`, adapter-static with an index.html fallback:
SvelteKit's server half is built but never executed, and the only thing that
reaches a browser is the client bundle the Go server embeds. Underpins reading
an npm advisory by where its code runs — a SvelteKit request-handling flaw or a
cookie parser is dev-tree weight, not shipped attack surface, and clearing it
does not justify a major bump of vite, svelte or kit. **Proven** in code and in
the artifact: the built `build/` tree contains no reference to the packages the
advisories name. False the moment anything runs `node` against this app — a
preview server put in front of users, a prerendered route, an SSR endpoint. Any
of those turns the accepted advisories live and this list must be re-read.
### A gate is reachable only at the name on its host certificate
Both hops verify under strict checking, so the name a client dials must be a
principal on the cert it is presented. Underpins refusing to boot a gate that
binds every interface with no `ssh_gate_domain` — that cert names `localhost`,
and no `EITRI_GATE` a remote client sets can make it verify — rather than
booting one and letting the failure surface as a denied host key.
**Proven** in code: the gate's cert carries exactly `ssh_gate_domain` as its
one principal, and every client path (`eitri ssh`, the console recipe, the
manual flow) dials that name with `StrictHostKeyChecking=yes`. False if a
deployment ever fronts the gate at a second name — that needs a second
principal on the cert, not a second address in the config.
### Narration is commentary, not state
A creating VM's `status_detail` is published from inside a running reconcile
pass — the one moment the agent knows it is 1.2 GiB into a download — and the
pass's own result lands on top when it ends, so nothing said mid-pass can
outlive the pass that said it. Underpins letting a report row exist for work
that has not finished without weakening the level-triggered contract: the row
carries the same address and host key the final row would, and a v0.0.5 agent
that never narrates produces exactly the old silence. False if anything ever
reads `status_detail` to decide — it is written for eyes, dropped on
completion, and absent on any agent that predates it.
### Exposure counters start at the agent, not at zero forever
An exposure's session counts — the active gauge, and the cumulative refusals
and drops — live in agent memory and reset when the agent restarts. Underpins
answering "did the datagram arrive" from the console without a metrics store:
a counter that survives rebinds but not restarts is enough to read a live
incident, and the console labels the totals "since agent start" so a reset
reads as what it is. The counters ride a nested message, so an agent that
never counted stays distinguishable from a port that counted zero. False if
anything alerts or bills on these numbers — that needs durable counters with
an epoch, which these are deliberately not.
### An advertisement below the machine is a cap, and nothing else is
An agent advertises the machine's totals clamped to its `--max-*` flags and the
clamp only ever lowers, so a reported capacity below the size the same report's
metrics imply can only be a cap its operator set. Underpins the create-time
capacity refusal firing there and nowhere else: an uncapped agent admits
whatever it is asked — guest disks are sparse and guest memory is not
preallocated — so refusing against its raw totals would block the overcommit
such a host exists to do. **Proven** in code on both platforms and pinned by an
invariant test in `hostinfo`: memory's used+available is exactly the advertised
total, and disk's used+free undercounts it by the filesystem's reserved blocks,
an error that can only fail to prove a cap, never invent one. The wire carries
no cap flag, so three cases read as uncapped and fall back to the agent
refusing at boot — a `--max-vcpus` cap (no report carries a core count), a
failed probe, and a cap set at exactly the machine's total. False the moment
the wire says which a number is; the refusal should then judge the declared
capacity directly and this arithmetic goes away.
### A networked guest is flat on the LAN, and still on its host's private fabric
A guest on a named network is a machine on that network: every port, every
device, no gate in front of that NIC. That is the feature — the stance the
exposure proxy already takes for one published port ("the LAN address is the
feature"), here unbounded — and it underpins putting guests on a named network
with nothing of eitri's in front of them. NAT-only stays the default; flatness
is asked for by name, per VM. **True by construction**, and false as an
expectation the moment a tenant assumes eitri-enforced isolation between
guests sharing a LAN — that is VLAN filtering at the site's switch,
deliberately out of scope.
The second half is the price of the NIC being additive: the same guest also
sits on `eitri0` beside every other guest on its host, because that is where
its reservation, its gate splice and its egress live. VM-to-VM on the host is
exactly why the NIC is additive rather than a replacement — and it means
compromising a LAN-facing guest is a foothold on the host's private L2, not
only on the network that guest was exposed to. Stated rather than mitigated:
the alternative was a guest that trades one attachment for the other, which is
two networking models to ship and reason about instead of one.
### A named network's LAN serves DHCP and tolerates new MACs
A guest's named NIC asks the site's DHCP server for an address and shows up at
the switch as one more MAC. Underpins allocating nothing for that NIC: the
site's DHCP addresses it, the agent discovers the result. **Falsified at the
switch**: 802.1X or MAC ACLs refuse the guest with the switch's diagnostics,
not eitri's. The soft half is a guest configured static — it never DHCPs, so
it is reachable there but unreported and `network_ip` stays empty, a
documented limitation rather than a fault.
Tolerating new MACs is not tolerating new servers, and eitri does not leave the
site to tell them apart. A bridge-family nft rule drops UDP source port 67 at
the ingress of every named tap, so a guest of eitri's cannot answer a lease —
not to the operator's real machines, not to a sibling guest across the bridge,
not to the switch beyond it. The rule is programmed only on hosts that serve
named networks, and a kernel that cannot take it fails agent startup rather
than bridging unfiltered. DHCPv6 (source port 547) and router advertisements
are deliberately unfiltered, and that is the design rather than a gap in it: a
bridged guest is meant to be a full peer, an operator may run a router guest on
purpose, and neither v6 path can poison what eitri records, since the snoop
reads IPv4 ACKs only. **True by construction** for v4; false as an expectation
the moment a tenant reads it as eitri policing the LAN's v6 too.
The blast radius of both halves is bounded by the NAT NIC being unconditional.
Whatever the site's DHCP does or refuses to do, `assigned_ip` is allocated
before the guest boots, so the gate, the console and every published port
still reach it: a network that never answers costs the guest its LAN identity,
not its existence.
One downgrade residue rides with this. Admission keeps networked VMs off agents
that never advertised the network — a pre-field agent advertises nothing and
is refused — but a host that advertised and was then downgraded to a pre-field
binary ignores the wire field and gives those guests the NAT NIC alone — no
second NIC, no LAN address, the one outcome admission exists to forbid. Closing
it takes a release floor at snapshot time, the `FirstDatagramExposures` pattern;
accepted for now because downgrading an advertising host is an operator's own
deliberate act, and rare.
### The guest honours the route metrics the seed asks for
A two-NIC guest is handed `dhcp4-overrides.route-metric` in its network-config
v2 seed — 100 on the NAT NIC, 200 on the named one — and that is the whole
mechanism putting the default route on the NAT NIC. Underpins the egress
promise: outbound leaves through the host's masquerade whatever the operator's
network is doing, and either attachment can be down without taking the other
with it. **True on the Ubuntu cloud image**, where cloud-init renders v2 to
netplan and the metrics arrive verbatim. **Unverified off netplan**:
cloud-init's other renderers, sysconfig and NetworkManager, have historically
ignored `dhcp4-overrides.route-metric` when translating v2, which leaves both
NICs at the distro's default metrics and the default route undetermined — a
guest that egresses over the LAN, or that swaps between the two on a lease
renewal.
Falsified by an operator pointing the default image at a non-netplan distro,
and quietly: both NICs still address, the guest still boots, and nothing in
eitri reads a route table to notice.
### An operator will notice a leaked host credential and revoke it
A host credential is minted once at enrollment and never re-issued, so the only
bound on a stolen one is someone calling revoke-credential on that host. The
age limit that would bound it automatically (`credential_max_age`) is a deadline
rather than a rotation — nothing renews, so every host it applies to goes dark
and needs re-enrolling by hand — which is why it ships unset.
**Not proven**, and the weakest link in host authentication: it assumes
detection. Renewal on the sync channel is what would replace the assumption
with a mechanism.
### A guest keeps the host certificate it was born with, for as long as it lives
The certificate reaches a guest exactly once, in the cloud-init seed built at
create (`internal/agent/reconcile`), and converge never rebuilds a seed — so
eitri has no way to hand a running guest a new one. That is what "guest owns the
guest" costs here, and it is why the certificate's ten-year window is deliberate
rather than lazy: any shorter TTL is a date on which every guest older than it
becomes unverifiable, with recreate as the only remedy.
**Proven**: traced in code — `e.Seed` has one call site, inside create, and
cloud-init runs once per instance-id. The consequence is the open one: a
certificate for `<tenant>.<name>` outlives the VM it was issued to, and VM names
are reusable, so deleting a VM does not retire its identity. Bounding that needs
a delivery channel for a replacement certificate, which does not exist yet.
### A protobuf message's names are ours to change
The binary encoding carries field numbers, not names, and nothing in eitri
renders a wire message by name: `protojson` is imported nowhere, and the only
JSON that leaves the process is `internal/server/api/types`, which is
hand-written and deprecates on its own schedule. Underpins renaming the wire
vocabulary (`VMDesired` → `VMSpec`, `ActualVM` → `VMStatus`) with older agents
still connected — they write the same numbers and read the same numbers.
**Proven** 2026-08-23, and enforced rather than argued: R15 in `internal/arch`
sweeps the whole import graph for `protojson`, and `depguard` denies it in the
editor. Falsified the moment that import lands anywhere, because the names it
writes to a log or a row become a contract retroactively — and the rename after
that one breaks it silently.
### cloud-init honours `merge_how` from the part being merged in
eitri's own cloud-config — the CA trust file and sshd drop-in, the clock
drop-in, the online root grow, the sshd reload — reaches a guest as the LAST
part of a multipart user-data, after the tenant's own document. Within one data
source, cloud-init's cloud-config handler folds each part into what the earlier
parts built, using the mergers the incoming part names, and a part that names
none falls back to `dict(replace)+list()+str()`. So eitri's part carries
`merge_how: "list(prepend)+dict(recurse_array,no_replace)+str()"`, and that one
line is why a tenant's `write_files` and eitri's both land, why eitri's grow
runs before the tenant's commands, and why every scalar the tenant set still
wins. Underpins the whole seed contract for BYO cloud-init.
**Traced** 2026-09-02 in cloud-init 26.1's own source (`handlers/cloud_config.py`
and `mergers/__init__.py`) on the Ubuntu resolute image, where the merger string
itself was also run against the guest's copy. Held to the boot gate from here:
the smoke's VM is created with a tenant document carrying both keys, and the
gate leg reads back a CA-signed login, a grown root, and the tenant's own
markers, so a run that goes green has proven this and a run that does not fails.
Falsified by a guest whose BYO `write_files` leaves `/etc/ssh/eitri_user_ca.pub`
absent — it boots, runs sshd, and refuses only CA-signed certificates, which is
why the boot gate has to ask rather than assume.
The same claim was first made for vendor-data and was **falsified by the boot
gate on 2026-09-02**: `mergers.dict_extract_mergers` pops `merge_how` out of a
document the first time the part handler reads it and the stripped result is
what gets written, so the later user-data-versus-vendor-data merge never sees
one and its first-wins default stands. Vendor-data was dropped from the seed
entirely rather than left as a second mechanism.
### A volume is exactly as durable as one host's disk
A volume is a raw sparse file on the host that first materialized it. There is
no replication, no snapshot, and no migration; `e698610c` (VMs cannot move
between hosts) now also means volumes cannot. A host that loses its disk loses
every volume on it. Force-removing a host is the one deliberate path that takes
its volumes with it — their claims return to pending rather than staying bound
to a host that is gone.
**Falsifier**: a volume readable from any host other than the one it was
created on.