How EmberVM works: the current state and the decided future, readable end to end. Assumes Kubernetes and Linux virtualization; EmberVM terms are in the section 2 vocabulary. Claims carry four flags:
- Built: current behaviour, and the default for any unflagged claim. If reality disagrees with this document, one of them is a bug to fix.
- Planned: designed, not yet or only partly landed; scheduled work carries its tracking issue.
- Decided direction: agreed design, not an implementation claim.
- Accepted risk: an eyes-open trade, stated where it applies.
model-checked means the named TLA+ spec in projects/embervm/specs/
satisfies the stated property under TLC in the build; implementation
conformance (trace-to-action validation) is Planned (#4699).
1. What EmberVM is
Self-hosted Firecracker orchestration: a private Lambda equivalent on metal you own. An organization sizes (or elastically bounds) a Firecracker nodepool; EmberVM provides placement, fairness, isolation, metering, and lifecycle so internal workloads get Lambda-shaped submit / scale-to-zero / warm-serve behaviour without a hosted FaaS product, without a Kubernetes object per invocation, and without etcd in the execution path.
EmberVM deploys on any Kubernetes with KVM-capable nodes, a local scratch disk per FC-labelled Kubernetes node, and an S3-compatible object store; section 11 states the platform contract. The reference deployment in this monorepo is a small on-prem cluster whose concrete shape lives in deploy/README.md.
Goals: private Lambda ergonomics (HTTP invoke, zip or image source,
caps, quotas, internal chargeback); honest scheduling on finite capacity;
isolation by default; the control plane off the hit path; one
Helm-installed system. That system comprises an Elixir control plane,
fixed-size brick pods running noded, node and edge Envoy tiers, and a
Workload CRD; the op-log database and the S3-compatible store are
operator-provided.
Non-goals: a hosted multi-tenant cloud; "agent platform as the product" (agents are dogfood consumers); every workload class as an equal pillar (task, serving, session are the core; stateful and composite are optional advanced classes); pretending capacity is infinite (queue depth, saturation signals, and admission control are the product surface instead).
Capability matrix
| Capability | Status | Limitation / tracking |
|---|---|---|
| Task execution | Built | Fresh VM per invocation |
| Zip lane | Built | Runtime base and handler shim |
| Sessions (bank/relight + workspace) | Built | Retention TTLs apply; a workspace size budget is Decided direction (#5074) |
| Serving | Built | Control-plane lifecycle on misses |
| Stateful | Built | Node-local authoritative volume |
| Composite | Built | No current consumer |
| Node-local activator | Planned | Partly landed |
| Brick autoscale | Built at rung up; Planned scale-down |
Full ladder remains |
| S3 archive-at-bank | Decided direction | Archive at bank commit |
| Transport auth CP-to-noded | Built (bearer + ingress policy) | SPIFFE mTLS is Planned: SPIRE is live with no EmberVM consumer yet, phase 2 of #5706 in flight |
| Guest identity (JWT-SVID) | Decided direction | Per-principal SVID delivered over vsock, phase 3 of #5706 |
| Encryption at rest | Built | Per-principal mutable artifacts (#4691), enabled per environment by values; Account-scoped immutable rootfs chunks remain planned (ADR 028, #4182) |
| Cells / multi-cell | Planned | No cell seams exist in code yet (#4753); one control plane today |
| Standalone packaging | Decided direction | Open-sourceable artifact |
| Website snapshotter (task guest) | Built | Headless Chromium screenshot over MCP (ADR embervm/035), #4994 |
Why. The original single-node RPC path had no durable task record, ownership, managed retry, cross-node placement, or fairness (ADR embervm/001). Argo Workflows was rejected because per-job Kubernetes objects move high-churn state into etcd and add pod startup to every short task; a bare pull queue was rejected because it discards placement and snapshot locality. A BEAM control plane over a Firecracker data plane was chosen with an accepted Elixir toolchain cost and a smaller contributor ecosystem (ADR embervm/001).
Hard multi-tenancy (virtual control planes) is deferred pending real demand. Internal rung IDs R0-R9 map onto the capabilities above.
2. System overview
Vocabulary
<details> <summary><b>EmberVM terms</b>, used throughout without redefinition</summary>- Kubernetes node: a KVM-capable machine.
- brick: a fixed-size capacity pod scheduled onto a Kubernetes node, several per node.
- noded: the Go daemon on each brick that owns Firecracker and node-local I/O.
- op-log: the durable ordered journal of lifecycle and enforcement actions.
- bank / relight: suspend a VM into a warm snapshot, then restore that snapshot.
- primed / banked / cold / running: ready pristine, snapshotted, fresh-boot, or actively executing instance states. Cold and warm describe boot mechanics only; task isolation remains no reuse, including after snapshot restore.
- generation: the volume incarnation number used to prove state provenance and pair a stateful volume with a memory snapshot.
- principal: the identity boundary a workload runs as.
- dial-home: noded registration and status sent to the control plane.
- warmth: a reusable boot artifact, such as a base or banked snapshot, that can make the next start faster but is never correctness-critical.
- bundle: the published set of artifacts needed to relight a workload.
- blessing: the durable control-plane act that authorizes a volume generation.
- wake grant: a bounded authorization for an activator to advance a workload wake during control-plane absence.
The diagrams use vsock (Firecracker's host-guest socket), DNAT (destination network address translation), xDS (the Envoy configuration protocol), BEAM (the Erlang virtual machine), ETS (Erlang Term Storage), and CNPG (CloudNativePG).
</details>graph TB
subgraph cp ["Control plane (Elixir/OTP, 'ember')"]
API["HTTP API<br/>/v1/workloads /v1/usage"]
DISP["Admission + placement<br/>(dispatcher, class managers)"]
POOL["Warm-pool refill<br/>(PoolManager)"]
XDS["Serving endpoint publication<br/>(EndpointPublisher, sole xDS writer)"]
OPLOG[("op-log<br/>Postgres in reference deployment,<br/>SQLite-WAL in chart/dev defaults")]
ETS[("ETS hot set<br/>rebuilt on start")]
end
subgraph node ["brick (pod on a Kubernetes node)"]
NODED["noded (Go)<br/>Firecracker driver, vsock,<br/>tap/DNAT, volumes, activator"]
ENVOY["node Envoy<br/>(serving relay)"]
PROXY["egress proxy"]
VM1["task / session VM<br/>vsock only, no NIC"]
VM2["serving VM<br/>tap NIC"]
VOL[("vol.img on node NVMe<br/>stateful, authoritative")]
SCRATCH[("/var/lib/embervm/scratch<br/>warmth cache: bases,<br/>banked snapshots")]
end
S3[("S3-compatible object store<br/>artifact export / archive")]
K8S["Kubernetes<br/>Workload CRD (definitions),<br/>brick pods, PriorityClasses"]
CALLER["task/session caller"] --> API
API --> DISP -->|gRPC| NODED
NODED -->|vsock| VM1
EDGE["Gateway API HTTPRoute"] --> ENVOY
XDS --> ENVOY
ENVOY -->|DNAT into tap| VM2
VM1 --> PROXY
VM2 --> PROXY
NODED ==>|dial-home NodeStatus:<br/>authoritative state| DISP
NODED <-->|Export/Restore/Evict| S3
NODED --- VOL & SCRATCH
K8S -.->|watch definitions| API
Diagram edges: solid = request path; thick = facts; dotted = definitions. The edge Envoy tier is the Gateway API ingress in front of node Envoy.
Division of labour (each responsibility placed where it is cheapest to make correct):
| Component | Owns | Never does |
|---|---|---|
| Control plane (Elixir) | Coordination facts: queue, placement, fairness, backpressure, lifecycle decisions, xDS publication, op-log appends | Carry steady-state payloads; sit on the serving hit path |
| noded (Go, one per brick) | Payload and OS work: Firecracker process supervision, vsock relay, tap/DNAT, volume attach, snapshot byte movement, node-local wake | Make fleet-wide decisions; join the BEAM cluster (it speaks a narrow gRPC surface instead) |
| Envoy (edge + node tier) | The serving request path, health-based ejection, rate limits | Lifecycle |
| Kubernetes | Low-churn Workload definitions, brick pod scheduling, PriorityClasses |
Per-invocation objects, execution state |
| Object store (S3-compatible) | Artifact export/archive, off-node durability | Anything on a hot path (read only on deliberate restore or local miss) |
Bricks dial home: on start and on a jittered interval each noded POSTs
its identity {node, pod_uid, address, boot_id} to /v1/nodes/register;
the control plane adopts it keyed by (node, pod_uid) and streams
WatchNode. The control plane never lists-and-watches daemon pods. Growing
the fleet is labelling a node (homelab.io/firecracker=true), not a values
edit.
Decided direction (#4696): enrollment consolidates on a single
chart-configurable key, embervm.jomcgi.dev/node, used as both the
enrollment label and the key of the optional hard taint; the separate
serving label is retired, since every enrolled node carries the serving
relay.
How one invocation works
A serving hit (the steady state), where the control plane is not involved at all:
sequenceDiagram
participant C as Caller
participant E as node Envoy
participant VM as Serving VM
C->>E: edge HTTPRoute
E->>VM: kernel DNAT into tap NIC
VM-->>C: response
A wake (a serving or stateful miss) is normally coordinated by the control plane, which restores the VM and republishes the endpoint through its EndpointPublisher:
sequenceDiagram
participant C as Caller
participant E as node Envoy
participant A as Fallback activator
participant CP as Control plane
participant VM as Guest VM
C->>E: request (workload scaled to zero)
E->>A: fallback endpoint, request parks
A->>CP: wake request
CP->>VM: single-flighted restore or cold boot
CP-->>E: real endpoint published via xDS
E->>VM: parked bytes splice through
VM-->>C: response
The node-local activator (Planned, partially landed) runs the same wake node-side during control-plane absence: parked bytes splice via stable node-local DNAT with no mid-wake xDS republish.
A task (the all-miss case by policy), a fresh VM per invocation, async by default:
sequenceDiagram
participant C as Caller
participant CP as Control plane
participant N as noded
participant VM as Guest VM
C->>CP: task POST /v1/workloads/:name/tasks
CP->>CP: admission + quota, op-log append
CP-->>C: 202 + task_id (?wait=true parks instead)
CP->>N: gRPC assign (primed VM)
N->>VM: payload over vsock
VM-->>N: result
N-->>CP: result, VM destroyed after one task
The platform contract is in section 11; the security posture summary is in section 10.
Why. Per-dispatch metering and placement made control-plane work grow with the number of sandboxes, while session endpoints in xDS would make configuration grow with session count (ADR embervm/020). Keeping central filter, score, and bind placement at dispatch rate was rejected as a scheduler throughput ceiling, and a global session directory was rejected because it adds a lookup to every request. The control plane now owns fleet facts and precomputed assignment while bricks own runtime facts, accepting reconcile-time metering gaps and bounded stale-state risk during partitions (ADR embervm/014, ADR embervm/018, ADR embervm/020).
3. Workload classes
Definitions are Kubernetes Workload CRs (schema-validated, GitOps-synced);
kubectl get workloads reads back snapshotRef, observedGeneration, and
readiness. Task invocation is POST /v1/workloads/:name/tasks. Session
invocation is POST /v1/workloads/:name/sessions, followed by
POST /v1/sessions/:id/invoke. source is a oneOf ladder: image (bring an OCI image, no SDK;
contract is "listen on the declared port, answer a health path") and zip
(runtime base + Lambda-compatible handler(event, context) shim; archives
are fetched and unpacked inside the disposable guest).
| Class | Semantics | Network | State | First consumer |
|---|---|---|---|---|
| task | Fresh VM per invocation from a pristine base, destroyed after one task. Dispatch is assignment-only from a primed pool | vsock only, no NIC | none | scan fleet (semgrep), zip functions |
| session | Bank/relight sandbox: idle snapshot to disk, restore on next invoke, principal-bound lineage | vsock only, no NIC | memory snapshot (+ workspace tier) | agent sandboxes |
| serving | Long-lived warm HTTP endpoint; Envoy routes hits, CP only on miss/wake | tap NIC | none durable | tenant web APIs, og-image |
| stateful | Scale-to-zero singleton datastore; L4 wake-on-connect; volume owns data, snapshot owns warmth | L4 via node Envoy | vol.img on node NVMe (authoritative) |
demo-postgres |
| composite | Multi-VM group, private per-group /24, all-or-none bundle-set bank/relight; warmth only, no member volumes | per-group bridge | group snapshot set | no current consumer |
A serving base built from an image registers its rootfs as the serving-images
entry with no handler path, and a fresh boot runs the image's own entrypoint as
the HTTP server; only zip-lane boots attach a handler drive (Built, ADR
embervm/038, noded/server/activator.go).
Planned:
- Isolated high-throughput lane: Envoy routes straight to per-brick listeners, and each brick pops a fresh VM per request.
- Persistence as a declared property: workloads declare persistence flags; memory and filesystem persistence decouple from class.
- Composition: multi-component apps become independent Workloads wired by bindings rather than composite groups.
Why. Per-request tenant isolation (one fresh VM per request, destroyed after the response, re-primed locally with no control-plane hop) is the fast execution path the session and task classes do not offer, and it is retained, not dropped. It is not built as a second stack: the Semgrep scan pod (#6132) is extracting the task substrate (prime, assign, destroy, registry, budget, jailer) into a native library with a local pool loop, and the lane is that library behind an Ember-facing request entry. Fresh execution state per request is the invariant; restoring many guests from one clean base satisfies it, reusing a prior tenant's mutated state never does. Routing reuses Kubernetes Service plus pod readiness first; LEAST_REQUEST, retry-on-503 and outlier ejection are added only for a demonstrated gap, and a retry never replays tenant work after an uncertain result. The per-brick fail-closed quota lease was withdrawn by ADR 020 and stays withdrawn. The lane becomes agent-ready when the library exists and a workload needs it (#3864).
Why. Classes originally bundled persistence policy, leaving no class for a filesystem-backed workload without a memory snapshot (ADR embervm/027). A new class for each persistence shape was rejected because persistence is orthogonal to class scheduling and network semantics; composite groups were rejected as the default application graph because joint lifecycle and private networking couple otherwise independent components (ADR embervm/022, ADR embervm/027). Declared persistence properties and mediated bindings carry the added schema and migration cost while composite remains available for multi-kernel groups.
4. Lifecycle
Stateful bank/relight and the interruptible bank
stateDiagram-v2
[*] --> serving: wake (cold boot or relight)
serving --> banking: idle window elapses, 0 active connections
banking --> banked: atomic bank (pause, snapshot, destroy)
banking --> checkpointed: interruptible bank opt-in:<br/>pause + snapshot to temp
checkpointed --> serving: connection parked -> ABORT<br/>(bless gen+1, delete temp, resume: HOT)
checkpointed --> banked: nobody waiting -> COMMIT<br/>(publish bundle, destroy)
banked --> serving: wake -> relight<br/>(only if bundle_generation == volume_generation)
banked --> serving: pairing mismatch -> cold boot from volume<br/>(slower, never incorrect)
Facts that make this safe:
- Bank only starts at zero active connections; a long-lived connection is never severed by an idle bank (rolls are the exception, bounded by the drain contract: a graceful roll gets the platform's own 110 s budget, a GCE Spot preemption about 30 s, two numbers never to be conflated).
- Resume requires an exact (memory snapshot, volume generation) pair;
mismatch discards warmth and cold-boots from the durable volume.
Model-checked (
bank_relight.tla), including the monotonic floor: a lagging node report can never regress the stored pair key, so a stale report cannot legalise a stale snapshot. - The interruptible bank (
spec.stateful.interruptibleBank: true, off by default; armed in the reference deployment for demo-postgres only) makes steady-state wakes always hot or warm, never cold. The three cold exceptions: genuine first boot, explicit operator reset, and the max-lifetime forced roll. - Only a snapshot taken immediately before teardown may be kept. Any snapshot whose VM resumes is discarded (the abort orders bless-generation, delete-temp, resume, so a surviving temp is always refused by pairing).
Generation blessing and quarantine
The control plane is the adjudicator of volume generations, with exactly three legitimate issuance shapes:
- CP-issued pre-dispatch: blessed durably before a wake/attach (op-log-before-dispatch). The default.
- Checkpoint-abort auto-heal: noded's resolve-timeout
auto-abort self-bumps by exactly +1 on the same
vm_id. A durablecheckpoint_dispatched{workload, vm_id, generation}record lets a restarted CP prove the +1 was its own checkpoint and bless it instead of quarantining. Anything unproven stays quarantined; the break-glass runbook isdocs/runbooks/embervm-stateful-generation-quarantine.md. - Activator self-advance (Built, Fork A): during control-plane
absence the volume's anchor brick (the brick holding its authoritative
volume) self-bumps the generation on wake. On CP return the fenced-writer
anchor-adoption rule trusts a self-bump from the current anchor and
backfills it; anything it cannot prove is quarantined. A first form of
Fork B is Built: every wake grants the anchor a durable bounded
blessing lease (the
blessing_lease_grantedop: start and lease_end generations, reloaded on boot) that pre-authorises the advance. Decided direction (Fork B remainder): lease expiry, moving toward a steady-state lease with brick-owned idle-bank.
The advance changes who may issue a generation, never who may write a volume (invariant 6).
That fence is also what makes anchor-loss recovery safe (ADR 040). When a
brick is confirmed gone and the volume has an export, the control plane
restores it onto a live peer and re-anchors there, writing node_id alone:
the blessing remains the sole generation authority, so a stale copy on a
node that later returns carries an older generation and loses its pair
check rather than competing for the write. The anchor pin from standing
decision 11 is unchanged for a volume that exists on exactly one disk,
which is still the case the fence was built for.
Wake path and the node-local activator
A request to a scaled-to-zero workload lands on a fallback endpoint, parks,
and triggers a single-flighted wake; the real endpoint is then published and
bytes splice. The activator (L7 serving, L4 stateful/composite) belongs in
noded rather than the CP pod so that a CP Recreate roll cannot black-hole
cold wakes: stable DNAT inside noded's own netns, NodeStatus advertises
the noded pod IP and activator port as the activator endpoint,
EndpointPublisher renders it as the fallback, and
instance ids minted node-side carry origin: ACTIVATOR for the CP to adopt
and backfill on reconcile. Planned: partially landed, soak ongoing.
Each published stateful endpoint shares its EDS assignment with a per-workload
L4 activator fallback at the next Envoy priority. Serving workload clusters
instead reuse one shared L7 activator endpoint, with the injected
x-ember-workload header disambiguating the workload. Active TCP health checks
eject unreachable live endpoints, so traffic falls through to the activator and
returns to the live priority when it recovers. On the stateful L4 path, a
connection that reaches the activator withdraws the stale resident health fact
before starting the ordinary rate-limited wake, unless it is within the
five-second publish cooldown or the registry's retained node inventory reports
the exact VM with healthy: true and last_probe_unix_ms no older than one
two-second status interval. In either guarded case, the activator treats the
failover as a transient and splices to the published endpoint.
For serving, Envoy's default overprovisioning factor makes the priority shift fractional, so one unhealthy host out of two can still send some traffic to the activator. The node-local serving straggler lookup only splices to an endpoint whose probe is healthy and no older than its configured probe cadence plus the bounded probe timeout; otherwise, and only when no bank or active splice owns the entry, it withdraws and tears down that endpoint before following the ordinary bounded wake path (#5818). A bank-owned or actively spliced entry is left for its operation, and the witnessed-connect withdrawal above remains stateful-only.
Why Fork B stays unbuilt. ADR embervm/018 split brick-owned lifecycle into Fork A (node-local wake, shipped) and Fork B (brick-authoritative idle banking, generation advancement under a wake grant, checkpoint COMMIT on the node). Fork A answers the availability question that motivated the split: a request during a control-plane gap reaches a ready VM. Fork B would move generation authority off the control plane, the seam ADR embervm/011 made single-writer on purpose, and no workload has yet needed idle bank or relight through a sustained control-plane gap, nor has control-plane sweep load been measured as a problem. Fork B therefore waits for one of those two facts and starts with one bounded transition (ownership, authority, persistence, recovery stated), not the seven-phase plan in the ADR history. Wake-path retirement (#4013) is gated separately on the #4702 model and runtime evidence and does not imply Fork B.
Sessions: the durability ladder
| Tier | Window | Artifact | Pinning |
|---|---|---|---|
| Live | up to maxLifetimeSeconds (21600s in the agent lanes by values; CRD default 86400s, no platform clamp) |
running VM | node-resident |
| Warm bank | 7 days from last bank | memory snapshot in S3 | CPU-vendor + base-generation |
| Durable workspace | 7 days from last use | zstd content-addressed file set | none |
Resume is one interface with four verbs: cold boot; base-snapshot restore;
warm (memory) restore; base + workspace hydration. The CP picks the cheapest
unexpired artifact. Two different windows apply: the same banked session
is resumable for its warm-bank TTL (an hour in the agent lane, a deploy
value, and bounded strictly below the warm-bank GC TTL: the chart render and
the CP's Workload admission both reject a bankedTtlSeconds greater than or
equal to warmthS3Gc.sessionTtlMs, because a banked snapshot has no expiry
hold in that GC), after which resume takes the session-expiry 410 path; the durable
workspace lineage is adoptable for 7 days, so a new session generation
inherits the prior workspace rather than starting blank. Decided
direction: capture decouples from bank
(close-triggered for no-memory-snapshot workloads), and the workspace size cap
becomes a declared soft budget. persistence.filesystem.retention is inert
today, and artifact GC keeps only the newest artifact. latest + N remains the
planned retention direction.
Repo-backed sessions hydrate their workspace with a direct HTTPS clone through
the egress lane. The node-local git mirror sidecar (gitMirror) is Built
but off in every deployment and has never served a hydration: the shim's
mirror attempt is denied at the egress proxy and falls through to the direct
clone.
A sequential Firecracker dirty-page diff bank path exists and is dormant:
noded.diffBanking defaults false because the merge-at-bank step copies the
previous full base on reflink-less ext4 scratch, so the Bank RPC gets longer,
not shorter. Enable it only after an in-place merge or reflink-capable scratch
lands (#4970, #5699). Only full bundles ever enter the archive.
maxLifetimeSeconds is a version-convergence bound, not a data lifetime:
it stops a session riding a stale base forever and is never raised to buy
continuity. Continuity comes from adoption: lineage is decoupled from session
generation, so a later generation inherits the prior lineage's workspace.
Parked sessions count as disk, not against concurrency.cap: a parked
session holds zero RAM, concurrency.cap bounds running VMs only, and wake
does not re-check it; placement's memory admission and the per-principal
wake-rate limit protect the receiving node.
The S3 artifact GC uses an 8-hour TTL for stateful warmth and 7-day TTLs for session memory, serving snapshots, session workspaces, and group sets.
Why. A control-plane restart during an interruptible bank could leave a benign generation advance indistinguishable from an unauthorized one, forcing quarantine (ADR embervm/017). A live-volume timer snapshot was rejected because blocks can be captured inconsistently, and keeping wake only in the control-plane pod was rejected because a control-plane roll could black-hole cold wakes (ADR embervm/018, ADR embervm/025). Durable checkpoint provenance and a node-local activator preserve the fail-closed generation rule, accepting a narrow quarantine window when provenance was never recorded and best-effort metering during a control-plane gap.
The control plane does not retry a session invoke, and the guest classifies
its own failures. Unlike a task, a session invoke's guest error is the
guest's answer, not a VM failure: run_invoke issues one Assign and returns
whatever the guest said, and the router proxies that status and body verbatim.
Embervm.Retry and a workload's invocation.retry are task-lane only, so
nothing in EmberVM retries a session turn. That leaves the guest 422 body as
the only place a transient cause can be declared, and the shim is the only
layer that can tell one from a permanent one: it raises TransientTurnError
and adds retryable: true to the body, and the caller owns the backoff.
Why. A guest 422 is a catch-all: the shim's except Exception maps a
provider stream disconnect, a missing config file, a missing binary, and an
exhausted usage limit to one status with one body shape. Over the 14 days to
2026-09-16 that was 40 turns, of which 16 were a codex config load failing, 8 a
missing muse binary, 11 an unreachable model catalog, and exactly one the
stream disconnect a retry clears, so a blanket retry would have doubled the
loud failures rather than fixed them. That ratio is also why the transient set
is limited to an ALREADY-ESTABLISHED leg dying, never a bare transport generic:
a permanent egress fault (a host missing from egressTo, a dead catalog entry
denying its host, a broker grant failing closed) emits "transport error" and
"error sending request" on every attempt, so matching those would spend a whole
ladder to arrive at the same failure. Classifying in the control plane was
rejected because it would have to parse guest payloads and still could not
separate these causes; retrying in the control plane was rejected because the
shim has to classify first either way. The cost of NOT retrying is not one
wasted boot: a 422 marks the factory attempt uncertain, and with
factoryStopSupervisionEnabled false that parks the run holding a parallel
slot and its cost reservation, so the cheap same-session retry replaces an
expensive fresh-VM node attempt. The accepted cost is that a codex failure
raised after turn/start may already have run tool calls under
danger-full-access, and the retry re-delivers the same message to the same
live thread, so a partially effectful turn can replay up to the ladder's bound.
A header-based control-plane retry remains open (see Direction) for callers
that do not go through the monolith transport.
Why snapshot anywhere uses the between-turns safe point (#6256). A provider
stream can run for twelve hours, so waiting for it makes routine rollouts take
hours and cannot fit a Spot notice. Every drain edge now fences session invoke
admission and interrupts the exact active dispatch using the interrupt primitive.
An operator stop surface can be added on top of that primitive when there is a
caller.
The shim stops the provider, preserves its native transcript and partial output
in the workspace, syncs the session volume, and returns
terminal_reason: interrupted_for_drain. Only that ended turn permits banking;
a signaling acknowledgment or elapsed timer never proves a safe snapshot.
The flush window is bounded by EMBERVM_SESSION_DRAIN_FLUSH_MS (default
60000) and the remaining drain deadline less the bank budget. The shim
reserves another two seconds for sync and response delivery, with a one-second
minimum provider wait. An idle guest banks immediately. Deadline expiry is a
failure to complete evacuation, never permission to snapshot a running provider.
The control plane journals the interrupted turn sequence and CLI identity before banking. On the next invoke after relight, on any eligible brick, it prepends a continuation hint to the caller's prompt: the previous turn was interrupted for a drain and the transcript and workspace are intact. Factory retains the pending logical turn, graph attempt and reservation, and records the interrupted prefix and its cost. It emits no escalation card or terminal Discord notice. The next physical turn resumes the guest's own state; it does not replay the original prompt. Filesystem-only sessions use the same flushed safe point for park/archive and rejoin. The memory-enabled lane uses the existing memory bank/export path.
| Direction | Status | Tracking |
|---|---|---|
| Snapshot anywhere through exact interrupt, bounded flush, bank/export and next-turn continuation | Built; one path for SIGTERM and Spot, sharing the shim handler with user stop | #6256 |
5. The invariants
These are the rules everything else rests on. Every design change is judged against them.
The hit/miss invariant. A task requires synchronous control-plane admission and assignment. A serving or stateful miss normally requires the control plane; the node-local activator (Planned, partially landed) additionally runs a bounded node-side wake during control-plane absence. Steady-state hits never touch the control plane.
Facts through the control plane, payloads never. Snapshot bytes move node-to-store via noded; serving traffic moves through Envoy; the control plane carries facts. The two deliberate exceptions are lifecycle-rate: the parked first request after a serving miss, and task dispatch/results.
No mutable VM or snapshot lineage ever crosses a principal. Memory snapshots, workspaces, volumes, and other mutable artifacts never deduplicate across principals (erasure would become a cross-tenant reference-counting problem). ADR 028 defines the narrow exception for immutable, reconstructable rootfs chunks: private chunks may deduplicate within one Account, explicitly published platform chunks may deduplicate globally, and principal-scoped mount authorization remains mandatory. The workload-class table in section 3 carries the per-class network and state boundaries.
Fail closed on enforcement, fail open on warmth, and metering is not enforcement. Containment (concurrency caps, fair queues, admission, the node-side pressure predicate, node-confirmed destruction) fails closed. Warmth (a missing snapshot, an unreachable store) fails open to a cold boot: slower, never incorrect. Metering is counting, not enforcement, and fails open by design: a brick out of contact keeps running and keeps counting, and unreconciled spend is written off. Cutting off a principal is an admission action (stop minting tokens, 402 at the edge), not a metering one. The quota gate is model-checked (
quota.tla): budgets are opt-in, and for a principal with a budget both the submit gate and the dispatch gate fail closed when the usage cache is unreadable.The node agent is authoritative for instance runtime state. What a brick reports over dial-home about its VMs, taps, and volumes is the truth; control-plane tables are a reconciled cache. The registration route binds that authority to the pod UID and node name claims in the brick's bound ServiceAccount token, so a brick can establish only its own stream. The one carve-out is destruction: an instance is recorded destroyed only after the owning node confirms teardown (gate
nodeConfirmedDestroy: true in production and dev since 2026-08-22, #4758), and reconciliation is fail-closed toward destruction (an unrecognised node VM is an orphan to destroy, unless it carries theorigin: ACTIVATORmarker, in which case it is adopted and backfilled). Model-checked (adoption.tla) against control-plane and node crashes between any two steps: the dispatch restart wedge, forget-before-kill resurrection, and reap-would-wipe-fleet bug classes are excluded by invariant. The carve-out has its own carve-out: a destroy whose owning node has LEFT the fleet completes asdestroyedwithterminal_reason: node_gone, both at request time and from the reconcile sweep, so a session stuck indestroyingself-heals. A node counts as gone only when it publishes no capacity facts for the session's dial AND the node registry holds no runtime instance for it, or holds only a tombstone. Health is deliberately not part of the test. An exact stop is included, and terminalizes with itsstop_completionleft null rather than a synthesised tuple, so a caller holding the stop precondition can see that departure produced no proof and decide for itself whether that is acceptable. The same registry-departure proof terminalizes dormant session warmth that cannot wake: aparkedrow follows itsvolume_node_id, abankedrow follows itsnode_id, and an expired owner moves either row toevicted. A stale conflicting secondary ID cannot override that state-specific owner. The row remains non-terminal only when a healthy peer positively reports the exact workspace volume or snapshot it needs, or when a banked row has an exact snapshot reference plus an eligible peer that can restore it from reachable object storage. Those are the recovery targets the established wake paths can actually use. Archival and node cleanup remain best effort, and a transient terminal-op append failure is retried without restamping a successful terminal row, so neither can hold irrecoverable warmth indefinitely.Why. A replaced brick can never send the confirmation the gate waits for, so before #6004 the session sat in
destroyingforever and held every downstream reservation bound to it, which is a worse failure than completing a destroy whose VM demonstrably died with its node; departure from the registry is the only sound signal for that, because the registry ages an instance tounknownand retracts its capacity row after five seconds of silence, so any "unhealthy for longer than a threshold" test would also fire on a healthy brick with a brief status gap under a destroy that had been retrying for minutes, and recorddestroyedfor a VM still running.Single-writer for stateful is a physical fact, not a protocol. The storage section explains the raw sparse file, one-writable-attach rule, and generation-as-provenance detail; failover remains a deliberate operator action and the partition window is bounded by the brick silence timeout.
Durable-before-observed for the journal. Every lifecycle and enforcement action is an ordered op-log append; callers await their (group-committed) batch. The op-log doubles as the audit record.
Kubernetes arbitrates pods; EmberVM arbitrates VMs; the pod is the ABI. No scheduler or autoscaler code is imported and no node provisioning is rebuilt. The capacity section's three-layer table shows the levers, and a Pending brick is the scale-up signal or the fleet-full page.
Classes are reuse semantics; substrates are lanes. New execution technologies (Hyperlight, wasm) enter as lanes under existing classes, never as new classes.
Users express posture, never mechanics. The surface is a small set of declared scalars in user-facing units, each under a platform ceiling with a non-escalation rule. Priority rungs, disruption budgets, grace periods, and CPU are platform-derived; users do not select those mechanics. The scalar details are carried by the storage and control-plane sections.
Why. Worker runtime state changed faster than synchronous durable writes
could support, while stale reports could otherwise resurrect destroyed VMs or
regress a generation (ADR embervm/014). The optional async path avoids keeping
Postgres on four boot and wake appends, but the reference deployment currently
keeps asyncLifecycleWrites: false, so those appends remain write-through
(see: /projects/embervm/deploy/values.yaml and
/projects/embervm/chart/values.yaml). Vocabulary-only conformance was also
insufficient because a modeled action can exist in an enum and never be emitted
(ADR embervm/034). Worker-authoritative runtime state, forward-only
reconciliation, and model-checked invariants support the async path's late
durable records while keeping enforcement fail-closed and warmth discardable
(ADR embervm/006, ADR embervm/014).
6. Control plane internals
The control plane holds sparse facts and derives dense things on demand; it never stores or witnesses anything that scales with the fleet.
- State model: hot working set in ETS (rebuilt on start, healed by
adoption from node reports); durable book-of-record in the op-log behind
the
Embervm.OpLogbehaviour. The reference deployment configures Postgres on the shared CNPG cluster; the chart default and isolated dev deployment use SQLite-WAL. SQLite remains the zero-dependency single-node backend and the reference deployment's rollback path.Embervm.Application.op_log_mod/0selects between them purely onEMBERVM_OPLOG_DSNbeing set, so the rendered pod spec names the runtime-selected backend (see: /projects/embervm/deploy/values.yaml, /projects/embervm/dev/deploy/values.yaml, and /projects/embervm/chart/templates/deployment.yaml). Either adapter creates its own schema on boot, so there is nothing to migrate. The dispatch path never reads the durable store. - Retention: result TTLs enforced at read time; terminal tasks
pruned past 7 days; the ops journal prefix-compacted past a 30-day horizon
behind a durable
compacted_through_seqmarker. Long-horizon audit lives in the observability stack. - Adoption: noded reports
primed_vm_ids, session VMs, checkpoint-pending VMs, and banked artifacts on everyNodeStatus; the dispatcher and managers reconcile on boot and every sweep. Adoption is ETS-only except the stateful bundle heal, which appends astateful_bankedop markedadopted: true. The protocols are model-checked: the specs inprojects/embervm/specs/run under TLC in the build, so a spec violation is a red build, andvocabulary.exskeeps them honest against the code's enums, though not against whether a modeled op kind is ever appended (#4756) or the gate it needs is armed (#4758). Planned: trace validation, op-log events checked against TLA+ actions. The debug-gated SpecTrace implementation (#4770) ships and runs in dev withspecTrace.enabled; production keeps it off. The full harness (hermetic and deployed lanes, direct-checker and TLC tiers, anti-vacuity manifests, DRILL and VACUOUS as distinct verdicts) is Decided direction (ADR embervm/034), tracked in #4761 and #4763. - Cells: the unit of horizontal scale is a cell, a complete
single-writer control plane owning a bounded set of bricks and workloads,
with one op-log appender (ordering is within-cell only). Planned
(#4753): no
cell_id, workload-to-cell assignment, or per-cell dial-home address exists in code yet; there is exactly one control plane today. A thin stateless fleet layer (route + capacity roll-up) arrives only with a second cell. - Registry survives restarts: noded persists its last-synced registry to NVMe marked stale; a restarting noded with an absent CP serves warm workloads from cache. No dependency's brief absence may turn a warm node into a dead one.
- Bounded node dials: every node dial carries a 3 s TCP connect timeout,
and channel dials run in callers so they never block the
NodeChannelprocess (#5124). - Health surface tiers (ADR embervm/031):
/healthlatches unhealthy immediately on a sustained artifact-export failure streak (tier 1, a user's data at risk now) and only after more than 24 h without a completed warmth GC sweep, measured as the age of the newestgc-manifests/object (tier 2, maintenance debt with days of runway). Both end in the health surface; Discord alerts are a companion signal, not the record.
Decided direction:
- Op-log restructuring: payloads separate from facts and
opsare time-partitioned, making principal-scoped erasure an indexed delete. - Admission-only control plane: the CP admits and mints encrypted session-routing tokens; peers redistribute under pressure and metering fails open.
- Resource model:
memMibis the only declared dial, CPU is derived from it, and GB-seconds is the accounting unit.
Why. The original journal retained request payloads without a bound, and a full durable volume would stop submissions because lifecycle writes fail closed (ADR embervm/002). A larger unbounded volume was rejected because it delays the same outage; central placement and metering on every dispatch were rejected as control-plane throughput ceilings (ADR embervm/002, ADR embervm/020). Projections, prefix compaction, group commit, and forecast-time assignment keep durable work off dispatch. Separating payloads from journal facts adds a second write and an orphan-reclamation path, an accepted consequence of shorter payload retention and principal-scoped erasure (ADR embervm/019).
7. Capacity, bricks, and Kubernetes
A brick is the capacity unit everywhere: a fixed-size noded
Deployment pod in a T-shirt size class (2gi through 16gi; small classes
pack tasks densely, the largest hold 1-2 serving/session VMs), with honest
Guaranteed requests, sized roughly 4-8x the largest VM of its class, a
handful per node. The daemon is budget-agnostic: it reads its ceiling from
its own cgroup, so a size class is a resources block, not a code fork, and
a brick's size is fixed for its lifetime.
| Layer | Owner | EmberVM's lever |
|---|---|---|
| VM to brick slot | EmberVM control plane | per-brick contiguous-headroom ledger; pack-to-empty scoring; class-exact placement (no cross-class borrowing) |
| Brick pod to node | kube-scheduler | pod shape only |
| Node provisioning | Karpenter or equivalent (cloud) / none (fixed fleet) | brick count vector from the single-writer controller; a Pending brick is the signal |
Why. The reserve constants are provisional and measured only once:
daemonReserveMib (256 in the chart, 512 in the binary default when the chart
does not inject it), vmOverheadMib (0 until a brick is switched to reserved),
and the roughly 3 MiB per guest the overhead script reported on the first hub
samples. Stats() is read once at release, so there is no recurring per-VM RSS
series and no separation of daemon working set from guest RSS from shared page
cache. That is deliberate: a sampler is cheap to write and expensive to keep
honest (page-cache first-toucher misattribution, cgroup v2 no-internal-process
rules, cardinality), and no sizing or incident decision has yet needed a number
the existing facilities cannot supply. Recurring per-VM telemetry (#4773) is
added when a named decision needs it, scoped to that decision, and never changes
the reserve, admission or banking policy in the same change. The B5 shadow
ledger (#6056) is the next measurement that exists by design.
On a fixed fleet a Pending brick is the fleet-full signal: the
controller flags :fleet_full, the dispatcher refuses placement (503), and
a human is paged, rather than overcommitting.
Built: every brick and the serving relay run a disposable-shaped
priority class (homelab-disposable, or homelab-preemptible on GKE, see
below), so guests are the first to yield under node memory pressure;
per-workload arbitration happens only in CP dispatch. QoS is Burstable:
CPU carries no limit (request-only, sized as a CFS fair-share weight rather
than a reservation), and memory requests sit at roughly 75% of limit (PR
#5519, merged 2026-09-01) since Firecracker guest RAM is demand-faulted and a
brick's resident set runs far under its configured ceiling; the limit itself
is untouched and still sets usable_mib, so no guest's admitted capacity
changes (ADR embervm/039).
Decided direction (ADR embervm/039, from the 2026-09-01 capacity
incidents): overcommit blast radius is a ladder, guest (Firecracker jailer
per-VM cgroup, memory.oom.group) before brick (kubelet eviction of the
lowest-priority pod) before node; and unsatisfiable demand (no class fits, or
class and pool headroom are both exhausted) becomes one signaled capacity
condition on /health and Workload status, held with hysteresis, rather
than a retry at request frequency (today only the :fleet_full 503 exists).
Pressure shedding is Built, disarmed (#5521): a brick sheds its
least-recently-active idle guests via the existing bank paths once its
observed memory crosses statefulSweeper.pressureBanking.highWaterFraction
(0.85), with hysteresis back to the low-water mark (0.70) before shedding
stops; the lane is off in every deployment pending live verification.
homelab-preemptible (-9, platform chart) replaces homelab-disposable
(-1000) on GKE bricks specifically so a Pending brick stays visible to the
cluster autoscaler's scale-up (above its -10 expendable-pods cutoff) while
remaining first-evicted; this reopens, and only partially answers, the
mass-VM-death preemption concern ADR embervm/016 raised against
below-default brick priority (scheduler preemption deletes rather than
drains). The Firecracker jailer that backs the guest rung of the ladder is
Shipped-but-suspended (#5520; section 10 has the containment detail). It
lands dark pending live verification on one brick, after which a values-only
change can arm it.
Why. Host memory usage cannot say whether a guest is reclaiming hard or has
already killed its application: a guest OOM leaves the Firecracker process
alive, a host cgroup OOM kills the VMM. Today noded classifies only the second
(cgroup.OOMKilled() at process cleanup); everything else is an unclassified
VMM exit and a guest application death is invisible until the workload's own
health signal notices. Host-observed capacity stays authoritative: a guest
report can never raise capacity, bypass a quota, or relax idle-only banking, and
a missing or stale report is neither healthy nor proof of OOM. A guest feedback
channel (available memory, PSI where the kernel has it, OOM evidence, freshness
tied to the current VM activation rather than a snapshot-cloned flag) is
Planned (#5805) behind a named diagnostic gap, because it means a new
command on the frozen guest-agent contract and a rootfs rebake for every
runtime. The first slice when the gate fires is exit classification alone (host
cgroup OOM, guest kernel panic from the serial marker, unclassified) surfaced as
a structured lifecycle event, which needs no guest change.
Decided direction: PriorityClass ranking of brick pools by lane with sacrificial balloon bricks for burst headroom, and remaining-node-lifetime as a placement input so a terminating node takes only work that fits its horizon. Disruption splits workloads into preemptible posture (task, isolated lane) and durable posture (session, stateful: continuity via banked-state durability, not node pinning).
Brick discovery and scale: bricks are the only source of node capacity,
so the control plane picks the brick mix from placement demand rather than
running one fixed daemon per node; discovery is dial-home, never a Service
or a per-node DaemonSet. Desired per-class counts are a values knob
reconciled by Embervm.BrickController through /scale, because ArgoCD
ignores /spec/replicas fleet-wide and git-declared replicas would not
sync; instance identity is the kubelet pod UID. Brick autoscale runs at
rung up on the observe -> up -> full ladder: denial-driven scale-up
acts, clamped to the chart's maxReplicas. Planned: promoting to full
adds drain-aware scale-down. The current brick mix is deployment state and
lives in the fleet section.
Decided direction (ADR embervm/042, Accepted 2026-09-05, not yet built): the
maxReplicas ceiling itself becomes denial-driven, not only the replica count
clamped inside it. A class stuck at a ceiling of 0 today needs a human to raise
it by hand (the values-gke.yaml edits behind #5503 and #5504); instead, a new
ceilingBound values field names the operator's outer bound per class (absent
still means never, preserving today's explicit-zeros contract), and sustained
denial pressure for a class below its bound raises the ceiling one step,
unblocking the existing scale-up decision on the next tick. Scale-to-zero is
symmetric, on a longer idle window than the replica-level one, and reuses the
same drain-aware victim selection. maxReplicas in git becomes a bootstrap
floor read only at first render, the same ownership shift #5498 already made
for replica counts. Unsatisfiable-forever demand (no class fits at any bound,
or a class pinned at its bound and still denying) rides the existing decision-6
capacity signal (ADR embervm/039) with two new reason codes rather than a
second alerting path. This does not reopen in-place pod resize, which ADR
embervm/013 section 7 and ADR embervm/039's own alternatives already declined
to reopen. Implementation is tracked on #5505.
The availability contract is spot semantics with two budgets. The chart
default gives a routine roll, upgrade, or scale-down 110 seconds of drain
notice. A GCE Spot preemption supplies about 20 seconds through noded's
preemption-notice watcher (drain.preemptionNoticeEnabled, armed on the hub
since 2026-09-06, #5819, off in the chart default). Both edges publish the
same drain_deadline_unix_ms signal. Sessions fence admission, interrupt the
active turn, give the guest a bounded window to flush progress and state to
the workspace, then bank and export at the existing between-turns safe
point. They no longer wait for the twelve-hour invoke bound. New and queued
invokes receive retryable drain responses until bank completes. A failed bank
retains the fence. Relight resumes as the next turn on the same factory
attempt and reservation. The deadline still bounds evacuation: node loss, a
failed flush, or an export that cannot finish before it can lose progress
since the last durable archive. The GKE hub overlay
sets a 150 second drain (180 second termination grace) so the budget covers
flush plus bank rather than a whole factory turn; the brick progress deadline
stays the chart default because a cold rootfs rebuild still needs it. State
durability within the stated archive interval is the guarantee, connection
continuity is not. Artifact retention TTLs and the GC sweep behaviour are in
deploy/README.md.
An ordinary brick rollout closes placement at the node drain edge and closes session invoke admission before evacuation. Closing new admission is not a drain by itself: guests that were already running remain on the old brick. A session therefore rejects queued and later invokes retryably, lets its one current invoke finish, and asks to bank immediately afterward. Temporary bank admission failures keep the fence closed and retry; a failed bank RPC restores the fence when the session process resumes. The old pod remains terminating until its guests leave the live registry or the externally configured deadline expires. This means one long factory turn can delay its GKE brick replacement for up to the configured 43,800 second bound. Sequential sync waves can compound that delay across a full fleet rollout. The Deployment progress deadline is longer than one termination grace plus a cold rootfs build. Promotion tooling with a shorter wait budget can still report a timeout while Kubernetes is gracefully terminating the old brick; recovery must resume observation and must not force-delete that pod.
That protection is intentionally limited. Spot preemption, force deletion,
node loss, and expiration of the drain deadline can still destroy a running
guest. Temporary control-plane or bank unavailability is not evidence that a
guest stopped, so it never authorizes a replacement attempt. When EmberVM has
durably recorded failed with terminal reason brick_gone, factory
supervision may settle only the exact matching guest invocation and use only
the graph attempt's remaining bounded retry. Guest, generation, invoke stamp,
or node mismatches remain uncertain, as do outcomes with ambiguous external
effects. No retry is admitted from absence or temporary unavailability alone.
Why drains bank in place rather than hand over. A drain edge is one signal
(drain_deadline_unix_ms, SIGTERM or the Spot preemption notice, whichever is
earlier) and the coordinator answers it one way: force-bank every class in
durability order inside the deadline. A pod-surge roll keeps the node's disk, so
the replacement daemon can relight locally; the bank also exports its durable
artifacts before shutdown when the store is configured. A Spot
preemption gives about 20 seconds, enough to bank and export but not to export,
restore onto a peer, re-anchor, and evict, so pre-drain redistribution would
race the deadline for no availability gain; when the node is gone, anchor-loss
recovery (ADR embervm/040) restores the banked volume onto a peer from the store.
The manual handover verb (POST /v1/stateful/:name/handover/:target) exists for
the planned case: a node leaving the fleet on an operator's schedule. Automatic
handover at the drain edge would need a drain cause the daemon does not publish
and a workload whose downtime budget banking cannot meet; neither exists.
Why. Per-invocation pods and Kubernetes objects made pod churn and etcd the ceiling for short work, yet bypassing Kubernetes meant its autoscaler could no longer observe Firecracker demand (ADR embervm/001, ADR embervm/005). A pod per VM was rejected for restoring that churn, while whole-node daemons were rejected because they make capacity changes too coarse for mixed workload lanes (ADR embervm/013, ADR embervm/016). Fixed-size bricks expose schedulable capacity to Kubernetes and keep VM placement inside EmberVM, accepting bin-fragmentation and drain coordination as explicit costs.
8. Storage, artifacts, durability
Artifact model: one typed verb family,
ExportArtifact / RestoreArtifact / EvictArtifact, over ArtifactRef {kind: BASE | SESSION | SERVING | STATEFUL | GROUP_SET | VOLUME}.
Control-plane-driven, idempotent per key; evict refuses while referenced, and
base retention holds when the current base is unverified (#4401). Keys are
namespaced by workload (and vendor, below); a principal-scoped
shared/<principal>/<sha256> keyspace is Planned (#5075), not
implemented: ArtifactRef is {kind, workload, ref} with keys
<kind>/<vendor>/<workload>/<ref>/<file>.
Baked rootfs cache (Built, #5772): Baked guest rootfs files use
rootfs/<digest>/<payload-sha256>.ext4, with
rootfs/<digest>/rootfs.ext4.sha256 written last as the completeness marker.
The marker names the payload key and checksum. A local miss reads the marker first,
downloads that payload, and verifies its bytes, so bases hydrate across nodes under
the identity contract, with a local hardlink cache in front.
Planned rootfs plane (ADR 028, #4182): OCI images convert to deterministic
flattened EROFS manifests and immutable chunks. Private chunks deduplicate under
rootfs/account/<account>/...; allow-listed published platform chunks may use
rootfs/platform/.... A brick fully hydrates and verifies every chunk of an
active manifest before reporting the rootfs READY, then presents a local-only
read-only ublk device. The object store is a preparation dependency, never a
live guest block-read dependency. Phase 0 (rootfs/PHASE0-RESULTS.md) measured a
16.8 percent Account-scope saving with Gear CDC at 64 KiB / 256 KiB / 1 MiB and
showed -Enoinline_data is required for rebuild stability; the build waits for
EKS because converter placement and Account key custody are its prerequisites,
and the two larger wins it uncovered (superseded-rootfs reclaim, dropping unused
baked images) need no chunk store.
| Failure | task | session | serving | stateful |
|---|---|---|---|---|
| VM process | Fresh VM is discarded; Built cold boot fallback | Lineage restores from warmth; Built | Endpoint wakes a replacement; Built | Volume remains authoritative; Built |
| brick | Assignment and primed pool reconcile; Built | Banked state can relight; Built | Cold wake fallback; Built | Volume is node-resident, and failover is now automatic when an export exists: the wake restores the exported volume onto a live peer and re-anchors it there, once the anchor is absent from capacity AND absent for the full registry-expiry window AND the CP has been up that long (Built, ADR 040). The manual handover RPC (POST /v1/stateful/:name/handover/:target) remains for moving a volume off a brick that is still alive |
| Kubernetes node | Recreate on another brick; Built | S3 warmth is available on deliberate restore; Built | Cold wake fallback; Built | Export at bank commit is Built and is what makes the brick row's automatic failover possible; recovery is to the last export, so a write made after it and before the node vanished is lost (ADR 040) |
| control plane | No new task admission; Built | Existing local state continues; Built | Hits continue; misses wait, node-local activator Planned | Existing local state continues; Built |
| object store | Local execution continues; Built | Local warmth continues, restore falls back cold; Built | Local warmth continues, restore falls back cold; Built | Local volume remains authoritative; archive is Decided direction |
State durability within the stated archive interval is the guarantee. The matrix defines the current recovery and the gaps in automated recovery.
Vendor pinning: Firecracker memory snapshots restore only within a CPU
vendor (and a narrow intra-vendor matrix), so all warmth artifacts are keyed
by (vendor, template) and never cross the boundary; the daemon refuses a
vendor-mismatched restore loudly. Volume data is fully portable. Legacy
artifacts cut before stamping existed are grandfathered: restorable on their
home node forever, never distributed.
A base snapshot is also bound to the exact rootfs it was captured on: noded records the rootfs ext4 UUID at capture and refuses a restore, sibling adoption, or prime against a mismatched or missing identity, moving an in-use mismatched bundle aside rather than resuming a guest onto a filesystem it never saw (#5674). A rebaked rootfs after a node replacement therefore forces a fresh base build on that node unless the baked rootfs cache above serves the same bytes (#5772).
Principal artifact envelope encryption (Built, armed in dev and
production, chart defaults off, #4691): each mutable principal artifact is
zstd-encoded then AES-256-GCM chunk-framed with a per-file nonce; meta.json
carries the opaque control-plane envelope and SHA-256 stays over plaintext.
An enveloped restore needs a five-minute capability minted by the control
plane for the exact target brick, HMAC-bound to the (principal, lineage,
brick, workload, ref, kind, generation) tuple; legacy plaintext restores
unconditionally, and a malformed or below-floor envelope suppresses the
restore so the cold-boot fallback runs. Root, epoch, and custody transitions
rewrite envelopes without reading payloads, by a bounded control-plane sweep
and a lazy noded pass after access, both guarded by an exact-ETag
compare-and-swap so a newer export always wins; raising an epoch floor or
retiring a root stays a separate operator action. Arming order and flags:
kekRoot, EMBERVM_ARTIFACT_ENCRYPTION, store.encrypt,
requireRestoreCapability; mechanics in noded/server/store.go and
control/lib/embervm/envelope_rewrap_sweeper.ex.
Decided direction:
- Local disk is authoritative: local node NVMe relight needs no network.
- S3 is a zstd content-addressed archive, written at bank commit, not a hot tier.
archiveIntervalis the user-facing durability control in acceptable-loss units.- Failover and node rotation are deliberate operator actions; the
banked-volume handover RPC that implements them is Built
(
POST /v1/stateful/:name/handover/:target).
Ownership arbitration is class-scoped (Decided direction):
| Class | Exclusion | Two-incarnation cost | Mechanism |
|---|---|---|---|
| stateful | physical (one node, one writable attach) | cannot arise implicitly | none needed; grants are provenance |
| session | none exists | divergence from a common ancestor | durable relinquish record before handoff; divergence bounded, detected by generation comparison on reconnect |
| composite | none (warmth-only) | bundle-set divergence | inherits session rules, governed as one unit |
| serving / task | n/a | nothing durable | n/a |
The divergence bound is the brick silence timeout: a brick that has not heard from the control plane for longer than the timeout (~6h, in the grant-expiry range, so a CP roll never trips it) stops serving everything it holds. Token TTL is a convenience, never the correctness parameter. Built (ADR 037, #5073): noded tracks last control-plane contact from its dial-home Register responses and WatchNode stream activity and refuses new node-local work (activator wakes, group wakes, blessing-lease self-advance) once silence exceeds the bound; live VMs keep running and warmth stays intact, and blessing-lease exhaustion remains the generation-count complement for quiet lineages.
Why. ADR embervm/011 chose replicated block storage to avoid whole-volume exports, then ADR embervm/025 superseded it for stateful storage because the CSI path added another placement owner and imposed the same replication cost on every workload; the reasoning against whole-file write amplification still holds. Timer-driven copies of live volumes were rejected as crash-inconsistent, and network demand-loading of root filesystems was rejected because an object store outage could stall a running guest (ADR embervm/025, ADR embervm/028). Authoritative local volumes, bank-time archives, and eager local chunk hydration accept an archive-interval loss window and slower restore after node loss. Mutable warmth is encrypted per principal because storage ACLs alone expose process memory to a compromised storage reader, accepting that loss of the platform root forces cold boot (ADR embervm/033, ADR embervm/036).
9. Identity, tenancy, security
Only principal ships now: it is the TokenReview-authenticated caller
identity, and the op-log's existing tenant field is a deployment constant
occupying the Account slot. Planned (#5072): domain (env or grouping
within exactly one principal, so a same-domain-by-default binding can never
cross the isolation boundary), and shared platform definitions (such as the
sandbox-session and scan-fleet templates) owned by a reserved platform
principal with an explicit broad instantiation grant, the widest and
most-reviewed grant in the system. Neither exists in code today.
Embervm.KeyService is the platform key custodian (ADR embervm/036): it
derives per-principal, per-epoch KEKs on demand from one current root and
stores only the current and minimum accepted epochs, whose floor is the
revocation fact; one previous root may coexist during rewrap. Customer-managed
principals instead use a Secret-configured HTTPS KMS oracle that keeps the KEK
and returns a data key plus an opaque wrapped key, so disabling the customer
key or grant makes warmth unrestorable and restore-on-miss degrades to cold
boot; a custody switch declares mode plus transition_from and accepts the
old custody only in that window. No customer oracle is configured in the
reference deployment, so that mode is available but inert. Mechanics in
control/lib/embervm/key_service.ex and customer_kms.ex.
Decided direction:
Hierarchy:
Account billing / grouping, NO isolation semantics
└ Product grouping, NO isolation semantics
└ Principal THE isolation boundary
└ Domain env or grouping within exactly one principal
└ Workload
Definitions at scale: one product template plus N enrollment records, one Git CR per product expanded into CP definitions, and idempotent registration as a desired-set reconcile.
Credential handling (class taxonomy Decided direction; the
brick-local proxy's egressTo-gated header injection, sidecar-only key
material, and the TLS-MITM CA lane are Built in the reference
deployment): material may sit where
it can be stolen only if the platform can kill its validity on demand;
otherwise the request moves to the credential. Secrets are classed:
derivable short-lived (class 1, may enter PLATFORM-TRUSTED guest classes only,
revoked at bank), fixed-but-rotatable (class 2, brick-lease only, injected at
the egress hop),
fixed-manual (class 3, never leaves a central key-sharded swap tier). A
UNTRUSTED workload guest never receives any credential class. The brick-local
proxy on every guest
egress reads the plaintext request, sets the configured header to the real
value (mounted only in the sidecar), and originates a fresh verified TLS
connection onward. Injection fires only when the destination is in that
secret's egressTo, so the credential is unreachable at every other host
(projects/firecracker/substrate/egress-proxy/cmd/swap.go). Header
injection is used rather than placeholder substitution because a
guest-controlled placeholder can be spliced into a URL and reflect the
credential into a request line. RAM scrubbing before snapshot is rejected
as a mechanism to rely on; revocation at the validator is the control.
Muse 1.0.3-R2198.1 was verified on 2026-09-10 to trust only its bundled
roots. Its adapter therefore uses an http:// base URL on the sidecar's
plaintext lane rather than the CA-backed HTTPS interception lane: the guest
to sidecar hop is a host-local vsock, the sidecar injects the credential
there, and it originates verified TLS to Meta itself, so nothing leaves the
brick in the clear.
Token broker (projects/embervm/tokenbroker/, Built): the single
owner of every mutable OAuth grant. A catalog credential marked with a broker
grant is resolved by the egress sidecar as a short-lived access token fetched
from the broker rather than from a static Secret, so a subscription refresh
token (Codex ChatGPT) or an authentik service-account grant rotates in one
place and never enters a guest. The grant Secret's data is broker-owned and
ignoreDifferencesd in the Application.
Grant pools (Built, armed on the hub with codex-cluster and
codex-b since 2026-09-10):
a catalog entry may name brokerGrants, an ordered pool of grants on one
provider, and the sidecar picks the active grant per connection from the
broker's per-grant quota views: remaining quota in 25% bands taken from the
worst unexpired window, the current grant kept until another is a full band
ahead, ties to the sooner reset, an exhausted grant out of the ranking until
its reset, a grant the broker cannot mint for skipped for a minute. Quota
reports carry the grant they were served with, so the broker keeps a
per-grant view beside the per-provider one and the monolith's model pools
judge a class by its least-used open grant.
Why. The sidecar is one per brick pod, shared by every guest on it, and nothing per session reaches it: choosing an account per session would need the control plane, noded and the proto to carry it. Choosing per connection with hysteresis gets nearly the same cache locality (a brick stays on one account for long stretches and each account burns down in quarter steps) for no cross-service plumbing, and the occasional cache miss on a flip is cheaper than that plumbing. See #5974.
Agent MCP lane (Built on the hub): the guest half of the agents tier
described in projects/mcp/ARCHITECTURE.md.
The shim writes a strict MCP client config from a URL delivered by
kernelBootArgs as an ember.env.* token (the only values-driven way a
guest-read setting reaches the shim; initEnv is a base-signature input
only). The sidecar's catalog entry for that destination resolves the
broker's agent-mcp grant, opts into plaintextUpstream because the tier
is an in-cluster plaintext listener, injects on injectAlwaysPaths /mcp
and /mcp/ because the codex client sends no Authorization header, and
serves one request per connection because the tier's 5 second keep-alive
otherwise killed the first call after idle. The upstream therefore sees the
broker's identity rather than the guest's, the gap phase 4 of the SPIFFE
program below closes.
Public surface hardening (Built): the public routes are scoped at their
HTTPRoutes, with serving routes additionally constrained by node Envoy authority
matches. Node Envoy returns 404 for /shim/* before routing to a serving
workload, so the workload handler never sees the reserved prefix. Inside the
guest, hydrate is build-only and the shim refuses another hydrate once it is
ready. The pattern's proof is a public Bazel-query demo that serves each visitor
query from a disposable CoW clone of a warm-Skyframe snapshot: server-controlled
argv, zero egress, reaped per request.
Guest identity: a guest never holds a cluster credential (Built, by construction: no NIC, no mounted ServiceAccount); the platform holds real credentials and acts on the guest's behalf through the brokered egress path.
Decided direction (ADR embervm/041, #5706): one self-hosted SPIRE trust
domain, embervm.jomcgi.dev, issues every EmberVM identity. Platform
components (control plane, noded, egress proxy, token broker) get X.509-SVIDs
registered as ClusterSPIFFEIDs, and the CP-to-noded hop moves to symmetric
mTLS with credentials delivered as files by a spiffe-helper sidecar and read
at dial time, on second listeners beside the plaintext bearer ones so each
side flips independently before the bearer retires. Guests get per-principal
JWT-SVIDs (spiffe://embervm.jomcgi.dev/ember/principal/<p>, 5 minute TTL)
fetched by noded as the node agent's delegate and delivered over vsock at
boot and every relight, never written to scratch or into a snapshot; the
egress proxy then authorizes by (principal, host) and the MCP lane validates
the guest's own SVID. GCP credential federation follows. State: SPIRE is
live with the platform registration entries and no EmberVM consumer (phases
0 and 1); platform mTLS (phase 2) is in flight; guest identity,
request-scoped injection, and federation (phases 3 to 5) are Planned.
Decided direction: GitHub leaves the agent egress catalog. Host-keyed injection bounds which host a credential reaches, never which request reaches it, so a prompt-injected guest can shape any GitHub API call and have the token attached to it. Agent principals instead reach GitHub through MCP tools whose URLs name the target repo, entitled per identity-provider group and backed by a per-group fine-grained PAT, so what a guest can reach is a fixed tool set rather than an API surface. The egress broker keeps the credentials that genuinely need host-keyed injection, the model providers among them.
Why. The original Kubernetes identity string conflated the credential holder, the isolation principal, and permission to use management actions, leaving object ownership checks unsafe for additional users (ADR embervm/032). TokenReview-only identity was rejected because it has no browser or device flow, while per-request identity-provider introspection was rejected because it adds a network dependency to every request. Provider-neutral actors, explicit principals, local token verification, and object-level authorization accept expiry-bounded revocation and loss of new login during an identity-provider outage (ADR embervm/024, ADR embervm/032).
10. Threat model
- Built: Firecracker boundary; no NIC for task/session guests; principal-bound lineage; no cluster credential in guests; Envoy-only serving ingress; bound dial-home registration identity.
- Accepted risk: privileged noded with /dev/kvm; external-allow guest egress via the broker; taint optional; CPU side-channel between co-resident tenant guests on the same brick, unmapped (#5255).
- Shipped-but-suspended (ADR embervm/039, #5520): the Firecracker jailer
provides per-VM chroot, uid/gid drop, and cgroup containment, closing the
direct-root-exec gap when enabled. The chart lands it dark
(
noded.jailer.enabled: falsein every deployment) pending live verification on one brick.--new-pid-nsis deliberately omitted because a PID namespace breaksexecProcessWaitandPidsupervision semantics. - Planned (ADR embervm/041 phase 2, in flight): X.509-SVID mTLS on the CP-to-noded hop and the token broker, retiring the static bearer.
EmberVM evaluates itself against the threat model published by agent-substrate/substrate at its cited 2026-06-25 revision, adopted as the external conformance frame (ADR embervm/033): the most complete public enumeration of what a multi-tenant agent execution plane must defend. The tables below condense it (their actor is Ember's guest, their worker pod a Firecracker slot on a brick, their atelet noded, their snapshot Ember's warmth artifact; threats are numbered 1 to 43 in upstream order as of its 2026-06-25 revision). Shared-worker threats with no Ember analogue are answered by the "no reuse across principals" row. Five threats remain unmapped and bind on Ember: 8 (template-author reach into storage), 21 (noded runs privileged with /dev/kvm), 26 (policy propagated out of band with scheduling), 31 (image-extraction resource limits), and 42 (detection integrations).
The boundary in one picture: what untrusted code can reach, and what never crosses toward it.
graph LR
subgraph vm ["Guest VMs"]
G1["task/session guest<br/>vsock only, no NIC"]
G2["serving guest<br/>tap NIC"]
end
subgraph brick ["Brick: trusted host"]
N["noded"]
P["egress proxy<br/>(holds credentials)"]
E["node Envoy"]
end
subgraph cp ["Control plane"]
F["admission, facts,<br/>op-log audit"]
end
S[("artifact store<br/>SigV4 per-identity access, gateway enforces (#4708)<br/>per-principal encryption enabled per environment (#4691)")]
X["external hosts"]
G1 -- "vsock only" --> N
E -- "DNAT into tap" --> G2
G1 -- "plaintext egress" --> P
G2 -- "plaintext egress" --> P
P -- "fresh TLS, credential injected<br/>only for allowlisted hosts" --> X
N -- "facts (dial-home), bound pod identity;<br/>bearer token + ingress policy enforced" --> F
N -- "snapshot bytes, never via the CP,<br/>SigV4 per-identity access, gateway enforces (#4708)" --> S
Trust diagram legend: every edge is a current path.
Attacks from guests
| Requirement (external mapping #) | Ember state |
|---|---|
| Hardened sandbox, never bare containers (15) | Built. Every guest is a Firecracker microVM; new execution technologies enter as lanes under existing classes (invariant 9). |
| Default-deny actor networking (17) | Built for task and session guests: they have no NIC and use vsock only. For tap-bearing serving and stateful guests, the generated serving table accepts established/related replies and drops NEW traffic entering the shared serving bridge at the inet forward hook. Whether two guests on that bridge can exchange bridge-local unicast, broadcast, ARP, or IPv4 traffic is runtime-unverified (#6159), not established by the generated inet rules. Composite members intentionally share a per-group L2 bridge: the generated group table keeps same-bridge member traffic while dropping NEW traffic that leaves the group bridge. That is composite lifecycle connectivity, not independent service authorization. #5813 is closed as NOT_PLANNED, and EmberVM ships no platform VM-to-VM L7 authorization layer. Brokered external egress remains the deliberate exception described in section 9. |
| No guest access to node-local endpoints or local substrate services (16, 19) | Built for task and session guests: with no NIC, their platform path is the vsock shim and brokered egress contract. Source-level gap for tap-bearing serving and stateful guests: noded/serving/net.go generates no input hook and no per-tap source IPv4 or source MAC match. Its forward-hook drop therefore does not prove denial of the bridge gateway, noded pod IP, or other host-local listeners, and does not prove that a packet can be attributed to one VM. Installed rules, actual listener reachability, authentication behavior, spoof outcomes, and deployed hook traversal remain live checks in #6159. |
| No Kubernetes-internal topology discovery (34) | Built for task and session guests, which have no NIC and receive no cluster DNS path. For tap-bearing guests, the generated forward posture is not a runtime proof about guest DNS configuration, pod or Service ranges, CNI policy, or bridge-local discovery. #6159 separately retains live validation of cluster DNS and topology exposure; this is not inferred from the threat 16/19 host-input gap or from the normal NEW-forward drop. |
| No Kubernetes or management-API escalation from guests (20, 22) | Built: no cluster credential by construction; the SPIFFE guest identity is Decided direction (section 9). Definitions are CP-owned and there is no self-modification verb. |
| Worker state fully reset between actors (18, 27, 30) | Built: no execution environment is reused across principals (invariant 3); placement is CP-owned, each VM sees an immutable rootfs plus private scratch, and ADR 028's planned chunk sharing exposes no other manifest or writable filesystem. |
| Credentials never inside the sandbox by default (28, 29) | Built: class 1 credentials enter PLATFORM-TRUSTED guests only and are revoked at bank; every other credential is injected at the sidecar hop for hosts in its egressTo (section 9). Planned: (principal, host) injection under SPIFFE (section 9). |
| Quotas and rate limits on creation and spend (9, 33) | Built, model-checked (quota.tla): admission fails closed, a budget of 0 is a hard stop, metering rides the operation (invariant 4). The per-principal daily budget is unset in the reference deployment, so spend is bounded by admission caps and concurrency. |
| Snapshot theft, substitution, or self-written snapshots (23, 24, 25, 32) | Built: the store gateway enforces SigV4 and only the embervm identity writes the ember buckets (#4708); envelope encryption and the restore capability bind mutable warmth to its tuple (#4691). Digest-verified manifests remain Planned. |
Attacks from clients and the internal network
| Requirement (external mapping #) | Ember state |
|---|---|
| No direct internet exposure of guests, nodes, or the CP (1, 2, 3) | Built. Nothing faces the internet directly; ingress rides the zero-trust edge tunnel, public routes are scoped at their HTTPRoutes, node Envoy returns 404 for /shim/* before routing, and hydrate is build-only (section 9). |
| Mutual authentication and encrypted transport between components (4, 10) | Built for CP-to-noded authentication: one bearer Secret rendered into the control plane and every noded pod, attached to every gRPC request, enforced in production and dev (#4693). The ingress-only CiliumNetworkPolicy on each noded listener holds on the home cluster only: the hub gates it off (values-gke.yaml noded.networkPolicy.enabled: false, no Cilium CRDs), so there the bearer is the sole control. SPIFFE X.509-SVID mTLS is the additive upgrade the proto reserves, in flight as phase 2 of #5706 (section 9). Encrypted session-routing tokens and the actor / principal / permission split are Decided direction; management callers authenticate via Kubernetes TokenReview against an allow-list. |
| Control plane isolated from the data plane (6) | Built as a seam: the CP runs on Kubernetes, noded on bricks, payloads never traverse the CP (invariant 2). Accepted risk on a small fleet: guests co-locate with the etcd masters (section 11). |
| Runtime configurable only by administrators (7) | Built. A workload chooses class and source; the sandbox technology, kernel, and bases are CI-built platform artifacts it cannot substitute. |
| A sanctioned, secure path for secrets (11) | Built. The cluster's secret operator is the only secret source, and guests receive none (section 9). |
Attacks from nodes and insiders
| Requirement (external mapping #) | Ember state |
|---|---|
| Node storage access scoped to actors scheduled on it (36, 37) | Built: SigV4 at the gateway, embervm identity only (#4708); a brick receives a five-minute decryption capability for exactly the tuple it is waking (#4691), armed in dev and production. |
| Node API access scoped to its own actors (38) | Built: node reports are authoritative only for instances anchored to that node, wake grants are gated on the anchor (section 4), and the bound token's pod-uid and node-name claims must match the registration. A brick registers only itself. |
| Granular admin access and envelope encryption at rest (39, 40) | Built for mutable principal warmth: derived per-epoch KEKs or a customer KMS oracle (section 9). Immutable rootfs chunk encryption remains Planned (ADR 028, #4182); the op-log shares a Postgres cluster (section 11); payload separation and principal-scoped erasure are Decided direction. |
| Audit logging of all control actions (41) | Built. Every lifecycle and enforcement action is an ordered op-log append (invariant 7), prefix-compacted past 30 days; older audit lives in the observability stack. |
| Containment of a detected-bad actor (43) | Built for one lever: principal cutoff as an admission action (stop minting tokens, 402 at the edge). The volume quarantine is a data-integrity guard, not an adversary control; no brick- or principal-level quarantine exists and automatic containment is undecided. |
Why. Runtime isolation did not protect banked memory from a compromised brick, storage reader, or insider with object-store access (ADR embervm/033). A self-authored threat list was rejected because it could be shaped around existing choices, and storage ACLs alone were rejected because they authorize more than a specific principal, lineage, and generation. The external conformance frame and per-principal envelope encryption make those gaps explicit, accepting restore latency, key-count growth, and cold boot when a key is unavailable (ADR embervm/033, ADR embervm/036).
11. Deployment shape
Minimum platform contract
| Requirement | Why | Fails how if absent |
|---|---|---|
KVM-capable Kubernetes nodes with /dev/kvm |
Firecracker execution | Bricks cannot start guests |
| Privileged noded | Firecracker, tap, and local device access | VM and network setup fails |
Local scratch bind-mount at /var/lib/embervm/scratch |
Warmth cache and local artifacts | Pod fails closed on missing scratch |
| S3-compatible object store | Artifact export and deliberate restore | Archive or restore is unavailable |
| Postgres (or SQLite-WAL single-node) | Durable op-log | Control plane cannot persist facts |
| Gateway API ingress | Edge serving route | Public serving ingress is unavailable |
| CNI permitting tap/DNAT on the Kubernetes node | Serving and broker network paths | Guest network paths fail |
| Secret operator | Platform secret source | Credential setup fails closed |
EmberVM asks for the platform contract above. The reference deployment uses KVM-capable nodes, a local scratch disk per Kubernetes node, and an S3-compatible object store.
Scratch is a node-provisioning contract: every FC-labelled Kubernetes node bind-mounts its real device at
/var/lib/embervm/scratch(hostPath type Directory fails closed if unsatisfied). KarpenterinstanceStorePolicyRAID0 satisfies it on EKS; local NVMe elsewhere. Where no out-of-band bootstrap exists, the chart'sscratchPrepDaemonSet provisions a size-capped ext4 loop file at that path (the GKE hub). Bases under it are node-shared across co-located bricks. Scratch does not survive a Spot node replacement: every guest rootfs rebakes in the brick init containers and the control plane re-drives the dropped bases without a restart, about ten minutes end to end.Why. Scratch is deliberately ephemeral. A per-node persistent disk would have to be named, attached and fenced across Spot replacement in an autoscaling pool, would carry the ext4 loop file's generation marker past the node that wrote it, and would cost more than the Spot saving it protects on a three-node pool. Preserving the filesystem also proves nothing about a database's recovery point: demo-postgres durability rests on its export path, not on scratch surviving. The reference design therefore pays for replacement with reconstruction: the rootfs cache (#5772) shortens the rebake and the control plane re-drives bases without a restart. Persistent scratch is reconsidered (#5773) only against a measured post-cache replacement that a named workload cannot tolerate, or a workload that declares local data must survive with a stated RPO and RTO, and even then reconstruction or export recovery is compared first. The filesystem choice for any such disk follows #5699.
Warmth never crosses a CPU vendor (until CPU templates land): artifacts are keyed per vendor, the gate fails closed at the daemon, and each vendor pool holds its own warmth.
noded.warmRestoreWithVolumeClassesarms uniformly across the size classes, never partially, per the rollback contract; measured load-to-resume is 2.5ms on both vendor pools.The control plane runs one replica with
strategy: Recreate; the reference deployment configures its op-log as a Postgres database (a shared cluster is acceptable: a CP outage is a designed-for state, and CP rolls are the availability events the node-local activator exists to survive).Why. One replica is a choice, not a limitation of the op-log: the Postgres tier is live and the CP's state is a reconciled cache of what bricks report, so a second replica could rebuild the same hot set by observing. What is not safe is the action side.
claimForAssignalready fences duplicate assignment at the node, butDestroy,Bank,StopStatefulandRelightcarry no ownership epoch on the noded RPC surface, andEndpointPublisherversions are boot-epoch prefixed so an older-booted writer is locked out permanently. A CP outage is a designed-for state (pod reschedule plus op-log projection load plus the node-local activator covers admitted work), and no workload or factory operation has yet named an availability or recovery bound that this fails. When one does (#3862), the sequence is fixed: first the fence (a per-brick ownership epoch mirroringwake_grant, and a multi-writer-safe publisher version), then the smallest topology that meets the requirement, which is a leader-elected actor tier on a Postgres advisory lock with observers on every replica. Brick-sharded ownership over libcluster and Horde is a fan-in scaling tool for a fleet of thousands of bricks, not an availability tool, and cells (#3855) were withdrawn. The ADR 007 loop walls (global prime budget, O(V^2) adoption sweep, timer reconciles) each buy more than replicas and are fixed first.Small fleets may co-locate guests with control-plane nodes as an eyes-open risk acceptance, provided the cluster is GitOps-reconstructible and durable state lives in S3: quorum loss is bounded downtime, not data loss, and guests are the first OOM victims. Do not import this clause into a cluster whose etcd is precious.
A hard node taint is a recorded option, not required: co-tenancy runs on honest requests plus a disposable priority class for guests.
The reference deployment's concrete fleet (node roles, current brick mix, shared Postgres) is in deploy/README.md.
Promotion gate
EmberVM promotes the dev chart to production through Kargo: argocd-wait
proves the Application Synced and Healthy, a soak interval catches failures
that appear after reconciliation settles, and the in-cluster conformance
runner's S1 to S4 scenarios (task execution, session sleep and relight,
second-session restart latency, control-plane invariants) must all pass at
/verdict, stamped with the chart version so an older deployment's evidence
is refused. A failed or all-vacuous run blocks promotion; Freight approval is
the explicit operator override. Phase 1 is tracked in #5224.
Known walls and provisional numbers (each states what would move it):
| Item | Value | Status | Confidence |
|---|---|---|---|
statefulTcpPortRange |
10 ports (5400-5409), CRD-validated | Built | asserted; hard cap, remedy constrained to name-based L4 (SNI/PROXY protocol) |
| CPU pivot | 1,024 MiB per vCPU | Decided direction (#5231) | not implemented: vcpus is an explicit CRD input; derive from memMib when the resource-model direction in section 6 lands |
| Active brick utilization target | >90% | Built | asserted; too high if shed events become common |
| Stateful continuity floor | 8h (also the S3 stateful warmth TTL) | Built | asserted; validate against rotation cadence |
| Session lifetime cap | maxLifetimeSeconds (agent lanes 21600s by values, CRD default 86400s) |
Built | asserted; version-convergence bound, not a durability claim; no platform-wide clamp |
| Brick silence timeout | 21600s armed | Built | asserted; the divergence bound for the ownership-arbitration design (ADR 037) |
| Wake-grant gap budget | k=4 (default cadence class) | Decided direction | the Fork B grant; Fork A activator self-advance is what is Built |
| Definitions target | 100k+ | Planned | provisional; owner-set goal, not measured demand |
Why. Per-workload Helm templates fail as an authoring surface well before a large informer relist makes etcd a recurring hazard (ADR embervm/026). A larger etcd with one CR per workload was rejected because it leaves the earlier authoring failure, and a pod or Service per execution was rejected because it restores the object churn EmberVM was created to remove (ADR embervm/001, ADR embervm/026). Kubernetes therefore owns low-churn definitions and brick scheduling while the standalone chart owns the execution plane, accepting KVM-capable nodes, local scratch, and an external durable store as the minimum deployment contract.
12. Direction
Decided and not yet built, each with the issue that tracks it. A row leaves this table when the work ships or the issue closes without it.
| Direction | Decided in | Tracks | State |
|---|---|---|---|
| The Firecracker jailer arms on every brick, closing the direct-root-exec gap between co-resident guests | section 10 | #5255 | not started |
| EmberVM ships a standalone quickstart and packaging boundary independent of the homelab's deployment configuration | Decision history (embervm/009) | #3858 | not started |
| OCI images convert to deterministic EROFS manifests and immutable content-addressed chunks, hydrated through a local-only read-only ublk device | section 8 | #4182 | deferred until EKS metal and per-Account KMS (2026-09-12) |
The brick maxReplicas ceiling itself moves on sustained denial pressure, not only the replica count clamped inside it |
section 7 | #5505 | not started |
| SPIFFE-issued identity moves beyond issuance: mTLS on the CP-to-noded hop, per-principal guest JWT-SVIDs, and GCP federation | section 9 | #5706 | not started |
| A session workspace gets a size budget instead of unbounded growth | Decision history (embervm/027) | #5074 | not started |
| A second Codex account joins the chatgpt.com grant pool once logged in, and quota floors per class and role protect planning capacity | section 9 | #5974 | grant pool built, pool not yet activated |
| A guest-declared transient failure is retried inside EmberVM on a bounded session-invoke loop, so callers outside the monolith transport get it too | section 4 | #6185 | not started |
| A guest reports memory pressure and OOM evidence, and VMM exits are classified | section 7 | #5805 | gated on a diagnostic gap |
| Brick scratch survives Spot replacement | section 11 | #5773 | gated on a measured recovery gap |
| Per-VM host resource gauges on an interval | section 7 | #4773 | gated on a named sizing decision |
| Isolated per-request lane on the shared task library | section 3 | #3864 | gated on #6132 library plus a named workload |
| Control-plane replica loss without a single-active gap | section 11 | #3862 | gated on a named availability requirement; fence first |
13. Decision history
The ADR files were removed on 2026-09-06 (#4667); git log -- docs/decisions/
has the full text.
| ADR | Decision | Status | Disposition |
|---|---|---|---|
| embervm/001 | EmberVM itself: BEAM control plane over a Go node daemon, hit/miss invariant, five classes, isolation model | Accepted; copy-never-rebuild for stateful withdrawn by 025 | deleted |
| embervm/002 | Op-log retention: read-time TTLs, 7 day terminal prune, 30 day journal horizon behind a durable marker | Accepted, Built; shape restructured by 019 | deleted |
| embervm/003 | Control-plane-managed snapshot distribution, Build / Restore / Export / Evict verbs | Accepted, Built; verbs generalized by 009 | deleted |
| embervm/004 | Back kubernetes-sigs/agent-sandbox through a deferred edge adapter, no native session API | Accepted; adapter not built, gated on upstream traction | deleted |
| embervm/005 | EKS scale-out: metal pool, multi-daemon bricks, EmberPool CRD, dial-home | Accepted; EmberPool never built, brick counts are a values knob behind BrickController; decision 3 superseded by 028 (#3849, #3851) |
deleted |
| embervm/006 | TLA+ pilot with three conformance layers | Accepted; six specs run under TLC in the build, trace validation deferred to 034 | deleted |
| embervm/007 | Batched Postgres op-log tier, cells, hot-loop corrections | Accepted; Postgres Built, no cell seams exist (#4753, #3853, #3855) | deleted |
| embervm/008 | Opt-in two-phase interruptible bank | Accepted, Built | deleted |
| embervm/009 | Continuity before tenancy: R6 to R9, spot availability contract, S3 seam | Accepted; quickstart open (#3856, #3858) | deleted |
| embervm/010 | Bazel warm-Skyframe public demo as a stateless query consumer | Accepted, Built | deleted |
| embervm/011 | Vendor-bound warmth, single-writer fencing, CP-sequenced rollouts | Accepted; stateful Longhorn withdrawn by 025; sole-issuer rule amended by 017, 018, 040 | deleted |
| embervm/012 | Co-located fleet, etcd blast radius accepted, grandfather rule, registry survives restart | Accepted; dynamic sizing retired by 013; HA open (#3862) | deleted |
| embervm/013 | Classes are reuse semantics, substrates are lanes; brick sizing; bricks everywhere | Accepted, Built | deleted |
| embervm/014 | Worker-authoritative state, async writes, node-confirmed destruction | Draft, Built (adoption.tla); metering clause amended by 020 |
deleted |
| embervm/015 | Isolated high-throughput lane with data-plane placement | Draft, not built (#3864); fail-closed lease withdrawn by 020 | deleted |
| embervm/016 | Kubernetes scheduling contract: the pod is the ABI, priority projection, session ladder | Accepted; kwok drills never built; placement loop superseded by 020, ladder amended by 025, 027, 029 | deleted |
| embervm/017 | Bounded auto-heal of the checkpoint-abort quarantine | Accepted, Built (generation_issuance.tla) |
deleted |
| embervm/018 | Node-local activator (Fork A), brick-authoritative lifecycle (Fork B) | Accepted; Fork A shipped, Fork B not started (gated, see section 4) | deleted |
| embervm/019 | Op-log payload separation, time partitioning, principal-scoped erasure | Draft, Decided direction | deleted |
| embervm/020 | Admission-only control plane, token routing, peer redistribution, fail-open metering | Draft, Decided direction; decision 3 withdrawn to 023 | deleted |
| embervm/021 | memMib as the only dial, derived CPU, GB-seconds |
Draft, Decided direction | deleted |
| embervm/022 | Composition over bindings, domain seam, three-leg access fabric | Draft, Decided direction; superseded in part by 024 and 026; SPIFFE deferral resolved by 041 (#5072) | deleted |
| embervm/023 | Class-scoped ownership arbitration, silence timeout as the divergence bound | Draft, Decided direction; the timeout is Built (037) | deleted |
| embervm/024 | Identity hierarchy, platform principal, guest identity assertion | Draft, Decided direction; decision 3 mechanism superseded by 041 (#5072) | deleted |
| embervm/025 | Local disk authoritative, S3 an archive, archiveInterval |
Draft, Decided direction; export at bank commit Built | deleted |
| embervm/026 | Templates not stamps, GitOps without per-workload CRs, desired-set registration | Draft, Decided direction | deleted |
| embervm/027 | Snapshot modes as a declared workload property | Draft, Decided direction; retention and the size budget open (#5074, #5075) | deleted |
| embervm/028 | Eager-local rootfs: OCI ref, Account chunk store, ublk | Accepted, Planned (#4182); Phase 0 measured in rootfs/PHASE0-RESULTS.md |
deleted |
| embervm/029 | Parked sessions count as disk, not against concurrency.cap |
Accepted, Built | deleted |
| embervm/030 | Lineage decoupled from session generation; the 6 h cap is a convergence bound | Accepted, Built | deleted |
| embervm/031 | Health signals classified by time-to-impact, both tiers latch /health |
Accepted, Built (#4338) | deleted |
| embervm/032 | Actor / principal / permission split, authentik SSO for the management surface | Draft, Decided direction; SPIFFE seam resolved by 041 | deleted |
| embervm/033 | Substrate threat model as the conformance frame, per-principal encryption at rest, tuple-authorized restore | Accepted, Built and armed; customer KMS mode and digest-verified manifests open (#4691) | deleted |
| embervm/034 | Conformance harness: two lanes, two tiers, anti-vacuity, Freight override | Accepted; SpecTrace runs in dev, harness Planned (#4761, #4763) | deleted |
| embervm/035 | Website snapshotter task guest | Draft, Built (#4994) | deleted |
| embervm/036 | Platform KEK custody derived in the control plane's key service | Accepted, Built | deleted |
| embervm/037 | Brick silence timeout | Draft, Built and armed at 21600 s (#5073) | deleted |
| embervm/038 | Image-lane serving fresh boot | Draft, Built | deleted |
| embervm/039 | Fair-share brick resources, blast-radius ladder, capacity as a signal | Accepted; requests Built, shedding Built and disarmed, jailer suspended, signal Decided direction | deleted |
| embervm/040 | Anchor-loss recovery, preemption budget split | Accepted; restore-onto-peer Built, preemption watcher Built and disarmed | deleted |
| embervm/041 | SPIFFE workload identity on self-hosted SPIRE | Accepted; phases 0 and 1 done, phase 2 in flight (#5706) | deleted |
| embervm/042 | Brick class ceilings move on denial pressure | Accepted, not built (#5505) | deleted |
| embervm/README | Reading order for 019 to 027 and the sparse-facts through-line | folded into section 6 | deleted |
| embervm/brick-program-decisions | 2026-07-19 run log: bricks everywhere, pod UID identity, scratch contract, staged cutover | carried by sections 7 and 11 | deleted |
Agent-platform ADRs whose decisions shipped through EmberVM. Their status column carries the wording the monolith map uses for the same rows.
| ADR | Decision | Status | Disposition |
|---|---|---|---|
| agents/002 | OpenHands sandboxes via agent-sandbox | Superseded by agents/004 | deleted |
| agents/014 | AX + Substrate as the agent runtime | Deprecated | deleted |
| agents/019 | Substrate executor interface, AgentWorkflow over Argo | Accepted, not shipped as designed | deleted |
| agents/021 | Discord-triggered AgentWorkflow with snapshot and resume | Draft, evolved into agent sessions | deleted |
| agents/022 | Firecracker snapshot/restore controller (FC-direct) | Accepted, shipped through EmberVM | deleted |
| agents/023 | Egress secret proxy | Draft; header injection shipped (section 9), placeholder substitution rejected | deleted |
| agents/025 | Three-layer agent stack | Draft, evolved into EmberVM | deleted |
| agents/026 | Fast microVM cold starts, stateful artifact iteration | Accepted, shipped through EmberVM | deleted |
| agents/028 | Elastic agent-microVM capacity and state-preserving reclaim | Draft, shipped through bricks and the scheduler | deleted |
| agents/030 | fc-invoke as one configurable surface | Draft, evolved into EmberVM | deleted |
| agents/031 | Control-plane / data-plane split | Accepted, shipped through EmberVM | deleted |
| agents/032 | Warm-snapshot Bazel worker as an MCP tool | Draft, partially shipped as the public Bazel demo | deleted |
| agents/033 | Golden-template distribution via daemon-pulled OCI | Accepted, superseded by control-plane-managed base builds (003, 028) | deleted |
| agents/034 | Per-tier guest MCP ACLs | Draft, not shipped (#3838) | deleted |
| agents/037 | Label-driven Firecracker node enrollment | Accepted, shipped (section 2) | deleted |
| agents/040 | Caller-provided context injection | Draft, not shipped | deleted |
| agents/041 | Hot git mirror for agent workspaces | Draft; the sidecar was removed on 2026-09-06 (#5825), hydration is a direct clone (section 4) | deleted |
| agents/044 | Code executor sandbox, self-describing guest runtimes | Accepted, shipped (task-class guests, monolith sandbox tools) | deleted |
| agents/045 | FaaS on the fc-invoke sandbox runtime | Accepted, execution migrated to the EmberVM zip lane | deleted |
| agents/046 | MMDS for dynamic per-workload guest env | Accepted, shipped as the metadata seam for stateful and group guests | deleted |
| agents/047 | Per-principal egress credentials, broker identity envelope | Draft; broker grants shipped (section 9), per-principal scoping moves to 041 phase 4 | deleted |
| agents/048 | Codex OAuth single-owner token broker | Accepted, shipped (section 9) | deleted |
| agents/050 | Workspace hydration from the git mirror | Accepted; hydration shipped as a direct clone, the mirror never served one (section 4) | deleted |
| agents/051 | Guest-pushed mid-turn progress | Accepted, shipped | deleted |
| agents/055 | Tool-mediated GitHub access | Superseded by agents/059 | deleted |
| agents/057 | Per-language sandbox guests | Draft, shipped (runtimes/) |
deleted |