EmberVM Architecture
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.
- Not planned: a historical proposal that is not an active delivery promise. Its linked issue records the current decision.
model-checked means the named TLA+ spec in projects/embervm/specs/
satisfies the stated property under TLC in the build. It is not an
implementation claim: adoption.tla's invariants are checked against dev's
live trace by a hand-written checker, and TLC trace validation is Staged
(#6415).
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 documentation in this monorepo covers the always-on GKE hub and a checked-in home overlay whose concrete shape and deployment caveats live 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).
Go protobuf sources
proto/embervm/node/v1/node.proto defines the control-to-node wire contract.
Bazel's node_go_src generates Go build inputs under its generated/ output
directory using the pinned compiler and plugins; nodev1 consumes those
outputs. The two checked-in files, node.pb.go and node_grpc.pb.go, are
copies for ordinary Go tooling. CI copies them after parallel formatting
with update_go_stubs, uses the existing branch autoformat writeback, and
compares both with check_go_stubs on every head. The Elixir generator stays
build-time only. Protocol and runtime behaviour are unchanged.
Why. A fresh checkout needs the Go package for root build, vet and tidy. Keeping the pinned Bazel outputs as build inputs preserves reproducibility; the drift check covers the copies used outside Bazel (#7066).
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; no workspace size budget is planned unless the evidence gate in #5074 is met |
| 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 | Configured at rung full; operational validation outstanding |
Scale-down acceptance remains on #6059 |
| Memory admission | Built: reserved declared claims, no overcommit; Staged per environment (the chart default and both deployments still run observed) |
Arming per stage after the chart deploys: #6965; live acceptance (no brick OOMKill under claude-runtime load) outstanding |
| Firecracker jailer | Built, Staged: disarmed everywhere until embervm-dev arms it | Dev arming, then production after a dev soak: #6965, #5255; conformance S7 gates dev promotion on it once armed |
| S3 archive-at-bank | Decided direction | Archive at bank commit |
| Transport auth CP-to-noded | Built (bearer + ingress policy) | SPIFFE mTLS is Built, default off: noded's second listener (noded.spiffe, #5757) and the control plane's file-based dial (controlPlane.spiffe, #5758) both render inert until flipped; 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 are Not planned (ADR 028, #4182) |
| Cells / multi-cell | Not planned | No cell seams exist; #3855 and #4753 closed without the unmerged PR #6069 |
| Standalone packaging | Built | Quickstart merged (#3858, PR #6070); not yet validated on a clean KVM host |
| 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).
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 | code sandboxes, 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 |
Banked bundle rootfs identity
Every newly banked session, serving, stateful, and composite member bundle
writes bundle.json schema v1 before publishing its snapfile completeness
marker. The provenance is rootfs_identity, the canonical ext4 filesystem UUID
read from the exact rootfs resource recorded in jail-resources.json. This is
the identity a restored guest kernel has cached, so an OCI digest or a byte hash
is not a substitute.
Compatibility is intentionally asymmetric. No bundle.json means legacy v0 and
is always grandfathered, including stateful bundles and every other bankable
class. A present v1 stamp is valid only when its version and identity are well
formed, the persisted rootfs path is readable, and its current ext4 UUID
matches. Malformed or unsupported metadata, missing v1 provenance, an unreadable
rootfs, and a UUID mismatch are invalid-v1 signals. With
EMBERVM_NODED_ENFORCE_BUNDLE_ROOTFS_IDENTITY=false, the daemon logs those
signals and continues the pre-v1 restore path. With the gate true, it refuses an
invalid v1 bundle before launching Firecracker. The chart, production, and dev
values keep the gate false for the initial write-and-observe rollout.
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).
Open direction:
- Not planned, isolated high-throughput lane: each request gets a fresh VM, with admission dependent on reusable implementation and evidence from #6132; #3864 closed without it and records the reopen gate.
- Decided direction, persistence as a declared property: workloads declare persistence flags; memory and filesystem persistence decouple from class.
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 next bounded integration must first use #6132's delivered implementation and evidence to identify reusable task-library, pool, HTTP, admission and cleanup behavior. 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. The closed #3865 and #3866 decisions record the integration and routing boundaries, not requirements for an independent library, pool, router or xDS path. Existing Kubernetes Service and readiness routing is assessed first, and a retry never replays tenant work after an uncertain result. The per-brick fail-closed quota lease remains withdrawn and #3867 stays closed.
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). The historical mediated-bindings alternative is not an active programme: #5813 closed as not planned, while the shipped composite class remains available for multi-kernel groups.
The native EmberVM API, Workload CRD and Helm chart remain the supported
interfaces. The Kubernetes Agent Sandbox adapter
(#5806) and generic
Kubernetes/Helm adapter
(#5807) are not planned.
Their separately dropped migration and binding proposals
(#5808,
#5809,
#5810,
#5811,
#5812, and
#5813) do not remove any
shipped interface or workload.
Composite member spans expose ember.was_relight and carry
ember.clock_delta_ms = -1 because no numeric delta is reported. The sentinel
means unknown, never a successful clock measurement, and a relight-success
boolean does not establish measured clock correctness. The numeric measurement
and new alert-data bridge are not planned
(#3953); historical alert
placeholders are not live coverage.
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)
This internal stateful-bank checkpoint is not a user-facing session checkpoint, fork or rollback API. That programme is not planned (#5700). The separately approved exact-turn interrupt relay and stop control remains bounded work under #4321; it does not create a snapshot or rollback capability.
Why. Duplicate Stop for the same dispatch replays the successful 202 outcome after turn completion on every runtime, until a successor starts. A double click must not become a stale-dispatch failure just because one runtime exits faster.
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).
| Direction | Status | Tracking |
|---|---|---|
On any future pair_broken recurrence, use the existing bounded metadata and log owners to capture the paired volume and snapshot generations together and retain control-plane and noded logs spanning the event before any wake, repair, lease change, export or data mutation |
Standing watch; the 2026-09-06 trigger is unproven and no new defect is confirmed | #5858 |
Why. The known lagging-generation-report mechanism was fixed on 2026-07-20 PDT by PR #3770, merged at 2026-07-21 06:54:11 UTC, about six weeks before the #5856 rollout. Its monotonic pair-key floor is present in the source deployed for that rollout, so it cannot explain the later eviction. The retained observations begin after the eviction and do not establish which other trigger occurred. Recording the evidence gap and the smallest future observation is therefore the resolution, not a speculative code change.
2026-09-06 demo-postgres warm-pair evidence
The direct runtime evidence below is retained in #5858: its issue body and 2026-09-07 follow-up. The follow-up names temporary coordinator evidence files, but they are not repository artifacts. The CP and noded logs from before the first retained read at 2026-09-06 20:20:24 UTC, including the eviction window, are permanently unavailable. Times without seconds in the issue are preserved at their reported precision.
| UTC | Direct evidence |
|---|---|
| 2026-09-06 20:11:13 | #5856 merged as 3878200fa2c7f802949a2e8e5d0149f5557aa2b3; the issue records the Ember 0.70.6 rollout as the event context, but does not provide a rollout-start timestamp. |
| 2026-09-06 20:15:33.743 | Predecessor stf-01M1VR6TPHM7RCDN4H14SHTVAG, stored snapshot generation 534569, was recorded evicted with reason pair_broken. |
| 2026-09-06 20:20:22.956 | Successor stf-01M1W624SCMHZX2386EBK1GS2F was created and later observed healthy. |
| 2026-09-06 20:20:24 | The first of four bounded CP and noded log reads began. They contained no warning or fault match, but none covered the 20:15 eviction or earlier rollout activity. |
| 2026-09-06 20:25:14.936 | The successor was banked with no live VM. Complete diagnostics at 20:36, 20:39 and 20:48 found its volume/snapshot pair valid; #5858 does not supply the paired generation value for those diagnostics. |
| 2026-09-07 00:30:12 and 00:30:23 | Noded logged blessing-lease exhaustion, then an observe-only UNBLESSED asynchronous export warning for volume generation 534623. |
| 2026-09-07 01:03 to 01:06 | The read-only follow-up found unchanged pod identities, image IDs and restart counts, and found the banked successor's snapshot generation 534629 paired with the volume generation. |
The follow-up pins the historical deployed source to
3878200fa2c7f802949a2e8e5d0149f5557aa2b3. The source reviewed for this
record is 7da0236d174ceeaaa22e17807ce2aa92072f2b00 on 2026-09-23. Both contain
the monotonic upsert_volume generation floor from
eb0e6d7f16959440f2f9332b3592bd40c8be4444. At both pins, an invalid warm
pair found by a wake causes immediate instance-bundle eviction with
pair_broken; the ordinary anchored path then plans a cold boot from the
existing volume. The eager sweeper instead requires three consecutive broken
observations. If an anchor is confirmed gone, recovery must exist before
eviction; otherwise the broken bundle is retained and the wake is refused. The
eviction transition does not delete the volume. These source facts establish
the transition semantics, not the historical trigger.
Three trigger candidates remain distinct:
| Candidate | What the retained evidence establishes |
|---|---|
| Expected forward volume activity | Possible, but the predecessor snapshot and later volume generations were not collected together. Their difference does not establish when or why the volume advanced. |
| Stale or rebuilt generation facts | The specific lagging-report regression is ruled out by the deployed monotonic floor. No contemporaneous CP rebuild, raw generation report or paired generation sample survives to decide another stale or rebuilt-fact path. |
| A new regression | Not demonstrated. The pair_broken state and later valid successor pairing are compatible with normal invalid-warmth eviction as well as a defect, so the transition result alone cannot choose between them. |
Generations 534623 and 534629, and the 2026-09-07 lease and export warnings,
all postdate the eviction. At the pinned source those warnings describe the
node-local activator's lease-exhaustion self-bump and observe-only asynchronous
export path. They explain the later warnings, but do not establish causality
for the earlier eviction. The last bounded observation establishes only
pairing consistency at that time. It does not verify application data, prove a
successful data recovery, or establish continuous stability. The older
snapshot_unrestorable entry remains historical evidence of a different
failure; its timing and artifacts are not supplied by #5858, and it is not
evidence for this incident. The closed #4077 and #4146 reports likewise remain
separate.
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. The brick activator only wakes workloads whose
nodeLocalWake is set (the CRD default is false) and answers 503 for the
rest, so EndpointPublisher renders a brick's advertised activator as the
fallback only for those workloads and keeps the CP activator for every other
serving workload; routing a CP-only workload to the brick would 503 its first
request forever and the CP would never see a miss. 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. A control-plane StartServing
call carries a deadline past noded's readiness budget
(EMBERVM_NODED_BOOT_READY_TIMEOUT, 180 s in production, plus a 30 s RPC
margin) and a StartStateful call one derived from the workload's own wake
bound (wakeTimeoutSeconds plus margin, plus the same 30 s), as
StartGroupMember already did: the grpc-elixir default of 10 s cancelled any
slower boot mid-gate and the daemon reaped the healthy VM, while a deadline far
past the stateful wake bound would leave the daemon booting a VM whose callers
were already failed. Every stateful wake outcome carries the token of the wake
that spawned it, so a worker that outlives its wake_timeout cannot complete a
later wake with its VM; its exact StartStateful instance gets an immediate DESTROY retried on reconcile (orphan destroy skips those ids), and the entry stops fencing placement (with an error log) once the owning instance has been continuously absent (missing from NodeCapacity or unresolvable) for the destroying escape interval. The fence is bounded but the retry is not: an escaped entry stays pending and keeps attempting the exact-instance DESTROY every reconcile, so an owner that returns with the stale VM still alive is torn down, and orphan destroy keeps skipping the id. Serving
drain-before-bank bookkeeping (the drain timer, the draining map, the bank
worker's reply address) is sweeper-process state over ServingStore rows that
ServingManager adoption deliberately leaves alone, so every sweep re-adopts
the rows it is not tracking: a drained row gets its window re-armed and a
banking row is settled from the node's report (VM live: bank again; VM gone and
an unclaimed snapshot reported: adopt it; neither: terminal). A sweeper restart
therefore cannot strand a healthy VM out of the fan-out.
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. The one-way evidence sequence is the
completed handoff model
(#4701), then the current
two-waker model (#4702),
then runtime evidence before the separately gated retirement decision in
#4013. No earlier step
authorizes deletion, and the sequence does not imply Fork B.
The two-waker model is projects/embervm/specs/dual_wake.tla, model-checked
under TLC within its stated bounds. It establishes neither implementation
conformance nor authority to retire either wake path.
Base snapshot device compatibility
Base capture writes jail-resources.json before publishing the bundle. That
producer-owned file records the backing resources embedded in the Firecracker
snapshot and therefore establishes whether the captured device table contains
the non-root volume drive. The same file is included by recursive artifact
export, restored into staging, checked against store metadata, and read again
when noded rebuilds its base registry after restart.
A volume request patches and resumes a base only when this captured metadata
proves that volume exists and the receiving node's
WarmRestoreWithVolume safety gate is armed. The gate therefore controls both
placeholder-volume capture and restore: a disarmed node cold-boots even if it
hydrates or adopts a placeholder-bearing base captured by an armed sibling. A
current rootfs-only base is cold-booted with the requested volume, so
Firecracker is never asked to patch a nonexistent device.
A bundle without jail-resources.json predates this contract and is unknown,
not rootfs-only: historical bundles can have either device shape. Such a legacy
bundle remains warm-restorable for volume-less work. A volume request takes the
same safe cold-boot path, while the node-stable placeholder backing file remains
available so a legacy placeholder-bearing bundle can still load for volume-less
work. Malformed or internally conflicting metadata is refused without deleting
the bundle.
The base identity does not include the device set. Publication, store export, and hydration therefore fence same-ref replacements unless both known device sets match. Hydration downloads to an isolated directory and publishes the complete bundle atomically, so a failed or incompatible download cannot partly rewrite an existing base. For legacy store markers, the downloaded bundle file is the available compatibility evidence; absent metadata remains explicitly unknown and is never guessed from the brick's current configuration.
Parked workspaces can rejoin across nodes
Cross-node rejoin (#6663) is unconditional. Drain workers synchronously export the unattached workspace through the owning instance dial, falling back to the draining pod's dial or the pre-park session dial when draining has removed its capacity fact. Export retries are bounded to three attempts.
SessionManager keeps the export fingerprint with the session's volume node, lineage and attachment epoch in memory. Parking and a rejoin success advance the epoch and clear evidence. A worker that issued prime also clears evidence unless prime returned a recognised pressure denial. Pre-prime failures retain evidence for a pressure-wait retry; terminal transitions remove both maps. A CP restart drops all evidence and restores the node pin.
When pinned placement returns capacity and the volume node has no base-ready
brick for that workload, valid evidence permits another fitting brick that
reports store reachability. Targets without a local lineage copy are preferred.
Restore and prime use that same instance dial. Restore must echo the expected
fingerprint; NotFound, a mismatch or an old daemon's empty echo fails before
prime. The durable session_rejoined projection moves volume_node_id to the
landed node. Departure preserves a proved parked session only while another
base-ready, store-reachable target exists. Stale copies on other nodes, of live
rows after a move and of terminal rows, are deleted without exporting over the
current store copy. A relighting row is skipped, since its rejoin target holds
the copy while volume_node_id still names the source.
noded also fences workspace exports on content lineage. Beside each lineage it
records the store fingerprint the local copy descends from (written on a
fingerprint-verified restore and on every successful or checksum-equal export).
Store.Export refuses a workspace whose store content differs from both the
local content and that recorded base with ErrStaleGeneration: the sync RPC
answers skipped and the async path logs, keeps the copy and marks it
unexportable so a drain does not wait on it. A copy with no recorded base
predates the marker and fails open.
Why. A draining brick disappears from capacity before its channel expires, and an asynchronous ArchiveVolume ACK cannot prove the store has current content. In-memory evidence limits that proof to one unattached content epoch. The restore fingerprint guard checks it on the receiving daemon before a VM can attach the workspace. A workspace key always exports at generation 0, so the generation fence cannot order copies and a node that a lineage moved off would otherwise upload its old copy over newer content on its next drain. The lineage fence closes that, and the sweep removes the stale copy. Follow-ups are persisted evidence and noded-reported export state.
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 |
Why. The historical durable-workspace proposal set a 30-day window, but PR
#4319 implemented a narrower seven-day default through sessionWorkspaceTtlMs.
Parked sessions expire earlier at the control-plane layer, so seven days bounds
the S3 storage tier.
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).
Artifact GC keeps only the newest artifact. latest + N remains the planned
retention direction. The CRD exposes no filesystem retention setting: the
former persistence.filesystem.retention field had no runtime reader and was
removed with its chart declarations in #7053. A
workspace size budget is not planned until
#5074's gate identifies a
supported workload whose storage growth, snapshot cost or banking delay is not
adequately controlled by existing limits. That gate does not authorize a cap,
warning, bank refusal, retention change or data deletion.
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.
Why. Repo-backed Claude, Codex and Pi workers use their native AGENTS.md
loaders from the hydrated checkout. Root and ancestor guidance loads at startup;
workers read scoped guidance when their assigned work enters that subtree.
Pi keeps context loading enabled alongside its factory system prompt, while
extension, skill and prompt-template discovery stay disabled. This keeps shared
repository guidance versioned with the checkout. Factory prompts and pinned
contracts still own roles, scope, tool limits and sequencing. The shim does not
copy instructions into CLAUDE.md or inject a second copy into the prompt.
(see: /projects/embervm/runtimes/claude/shim.py, /AGENTS.md)
Why. The Claude runtime ships a stdlib ci-log helper (#6442) so factory
workers share commit-status lookup, bounded log traversal and redaction.
It uses existing GitHub access and the credential-free BuildBuddy log RPC;
guests receive no new CI credentials. Fresh exact-commit status determines
running versus terminal, while chunk coverage and test counts remain separate
evidence. Private bounded caches reuse only redacted terminal logs. Image
publication does not establish guest uptake or measured reductions in repeated
CI calls; the next retro window supplies that operational acceptance.
Banking always writes a Full snapshot. The dormant dirty-page diff path and its merge-at-bank reflink helper were removed in 2026-11: its prerequisite, XFS reflink scratch (#5699), was closed as not planned.
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. Its
recheck-then-delete of a workspace prefix and a restore_lineage create of the
same terminal lineage are mutually exclusive (Built, Embervm.LineageFence,
#6736): the GC claims the lineage before its recheck and releases it after the
delete, the SessionManager claims it before placing the restoring create and
releases it once the heir row exists, each claim one atomic ETS insert, so a
restore admitted inside the window is denied with the retryable
lineage_restore_in_flight reason and a restore in flight holds the prefix for
that sweep. The destructive GC gate stays off by default regardless.
Historical cessation evidence. The management-authenticated, owner-scoped
GET /v1/sessions/:id/cessation-evidence reads an exact original dispatch after
its session projection has been pruned. It accepts only a retained original
creation, one invocation start, positive idled parking/parked commits, and
idle_ttl eviction. Guest, lineage, principal, workload, original node/VM,
generation and timestamps must agree. Creation must be above the compaction
marker; complete bounded creation history must contain no successor. Unknown
transitions, missing fields, malformed payloads and exhausted bounds refuse.
The limits are 256 target events/1 MiB, 32,768 creation events/16 MiB, and 64 KiB
per payload. Neither raw payloads nor credential hashes leave the verifier.
The backend reads marker, history and durable ownership in one read-only snapshot. The serialized session store and manager additionally refuse all hot lineage holders, even terminal ones, registry owners, pending restore authority and unresolved residency aliases. A surviving terminal holder could otherwise authorize a later restore. Successful reads carry a fresh observation timestamp and a stable hash of the exact positive history, independent of unrelated new sessions. They neither recreate a session nor determine its invocation result.
Why. Seven-day projection pruning can remove a stopped guest while its
thirty-day journal still contains positive cessation evidence. A missing row
alone cannot justify recovery. This proof is explicitly scoped to the deployed
single-writer control plane (replicas: 1, strategy: Recreate), not an
unimplemented multi-writer authority. A crashed manager's orphan Restore/Prime
RPC may finish resource work, but cannot register, Assign or dispatch through
its dead owner. The restart regression covers that distinction; the proof does
not assert that every orphan RPC has stopped. Consumers must retain their own
freshness, exact ownership, audited authorization and unknown-outcome fences.
Retained-session recovery fencing. The private factory recovery workflow
uses management-authenticated POST /v1/sessions/:id/recovery-fence, restricted
to the retained principal without a session-token or admin substitution. Preview
binds a request key to generation zero, one exact dispatch and its interrupted
turn marker, positive idled parking, and idle_ttl or node_gone eviction.
Complete bounded original/creation history and fresh durable/hot ownership checks
must exclude every successor, residency alias and dispatch-capable pending
restore; tracked handoff exports also refuse. Missing evidence fails closed.
Apply repeats those checks under the serialized manager and compares the exact
preview digest in one backend transaction. It appends session_recovery_fenced
and writes only immutable sessions.recovery_fence_json; original state,
timestamps, interruption and usage remain unchanged. An identical request can
read back a fresh proof after a lost response. A changed key, identity, history,
claim or digest cannot reuse the fence as recovery authority. Freshness and the
fenced flag are excluded from the stable preview digest.
Why. A retained terminal row remains eligible for workspace restoration, so
proof of prior physical cessation alone cannot safely authorize retry. Restore
admission now reads the durable fence on every restoring create, including after
an uncertain apply, and fails closed on unavailable storage. The field survives
restart; after ordinary projection pruning, the missing durable holder refuses
as unknown_lineage even if ETS still has the old row. The fence never fabricates
an invocation result or recreates a missing native receipt. It shares the existing
single-writer deployment assumption. Orphan non-dispatching export RPCs may finish
uploading bytes, but cannot restore the fenced lineage or authorize execution;
the proof makes no assertion that all resource RPCs have ceased.
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 for callers outside the monolith transport
closed not planned (#6185). The closing note says the monolith ladder covers
the only caller that meaningfully hits transient 422s.
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 |
A direct Registry or ETS shortcut for already-running session invokes is also not planned without evidence. Reconsider it only if the measured queue-wait or stall gate in #4015 is met; the closed broad cleanup programme is not a prerequisite.
Durable capacity waits for existing sessions
Issue #7210 adds two default-off gates: Ember's
session.asyncResidency.enabled and factory's
agents.sessions.asyncResidencyEnabled. Existing synchronous clients retain
their current behavior. New-session capability bootstrap remains separate.
Factory owns logical work, prompts, dependencies, review, quota, costs and
attempts. Ember stores demand for resident capacity using its existing op-log
and Postgres/SQLite projections. The session capability authenticates
POST /v1/sessions/:id/residency, paginated GETs and DELETE of an operation.
POST supplies Idempotency-Key, generation, deadline, task_deadline and
first_wait_at, with times in Unix milliseconds. A replay returns the original
operation. A different fingerprint conflicts. Neither tokens nor prompts enter
the operation record. The deadline is bounded by both task and session expiry.
queued owns no additional VM reservation. admitting references one exact
brick restore claim; interests for the same session share it. ready records
the current generation, VM and brick incarnation. cancelling retains an
unresolved restore while dropping demand. consumed records the winning invoke
handoff; execution completion still belongs to the existing dispatch record.
Cancellation of consumed demand returns already_consumed. Other terminal
states are cancelled, expired and failed. Cancellation never destroys a
shared session.
An opted-in invoke carries X-Ember-Residency-Operation,
X-Ember-Residency-Epoch and the existing exact dispatch ID. SessionStore writes
the consumed operation and session_invoke_started in the same transaction
before SessionAssign. Cancellation, deadline and the current resident tuple
are checked at that boundary. A bank winner returns exact not_dispatched
evidence under the reserved X-Ember-Invoke-Outcome header. Guest responses
cannot supply that origin marker. A lost handoff response stays uncertain; a
consumed operation never authorizes replay, including a crash before the RPC.
Why. A capacity wait can outlive an HTTP caller or a process. The durable pending turn retains the prompt and logical permit while the residency operation retains placement demand. Factory polls without claiming an executor, advancing a dispatch counter, preparing a receipt or starting its execution clock. It rechecks controls and shared admission before POST. Only a proven unused POST can unwind provisional receipt and retry bookkeeping. Unknown outcomes retain existing receipt recovery and cost reservations.
Admission requires a fresh brick report with reserved declared-memory admission
and a positive budget. #6965 owns enabling that prerequisite. Wakes use the
existing scheduler and final noded admission, charging the greater of declared
and snapshot memory plus existing overhead. The finite restore cap defaults to
two; queue caps default to 1024 globally and 128 per principal. A bounded scan
of the capped queue rotates principals and preserves FIFO age while bypassing
requests that do not fit. no_fitting_class, capacity and
safe_memory_unavailable are distinct observations. Existing wake-rate limits,
lineage and workload limits remain in effect. No memory request, CPU ratio or
jailer setting changes here.
A lost restore keeps its exact claim without a lease timeout. Restart rebuilds claims before considering queued work. A known returned VM can be adopted from its exact inventory; an anonymous Prime with a lost response is never guessed or repeated. It stays fenced until existing exact-incarnation cessation evidence resolves it. Resident reporting or committed checkpoint plus confirmed teardown acknowledges the claim durably before it is released. The existing idle-bank lifecycle manages VMs after demand is cancelled.
Status is available on the capability-scoped operation and under
/v1/nodes.async_residency: state/reason counts, oldest queued age, unknown
restores, restore concurrency/limit, declared resident/in-flight MiB, restore
attempts and consumed handoffs. [:embervm, :residency, :decision] emits bounded
state/reason labels and wait/restore durations. Structured logs carry operation
and session IDs; those IDs are excluded from metric labels. Factory emits
factory.capacity_wait.transitions and factory.capacity_wait.invocation_starts
and persists its wait reason/deadline on the pending turn. Existing session
lifecycle telemetry supplies park/relight events. Terminal dedupe projections
and their owning capability records are retained conservatively: this slice has
no evidence that factory has dropped every reference, so automatic deletion of
those projections is disabled. An indexed active/unresolved working set excludes
settled history from periodic reconciliation, admission and claim scans; key lookup
and queue sequence allocation do not scan terminal records. The existing bounded
compactor continues for unrelated data. Retention changes need reference-release
evidence before pruning.
Enable and rollback order for a separately authorized canary:
- Publish and verify chart/image provenance and Kargo's live revision. Satisfy #6965's safe-memory prerequisites. Apply the additive factory migration and deploy readers/reconcilers with both producers disabled.
- Enable Ember admission in dev with finite caps, then enable the factory producer. A first authoritative unsupported response permits legacy fallback. An ambiguous enqueue or an accepted operation never falls back. Older control planes without a typed unsupported response remain fenced until upgraded. There is no production activation in #7210 preparation.
- Roll back the factory producer first, then stop new Ember admissions. Keep operation readers, cancellation and reconciliation running until durable backlog and unknown restores resolve. Replays of accepted keys still work with admission disabled. Do not roll back to a binary that cannot read the operation projection while any accepted work remains.
The canary uses the same fixed workload before and after, with more logical sessions than resident slots. Record completed work per hour and native completed turns; resident GiB-hours and resident-memory-seconds per completed turn; queue latency distributions; restore duration, failures and churn; physical denials; and declared resident plus in-flight peak memory. Include failed and unfinished work in the denominator and report external Spot/preemption events separately. Missing telemetry or no traffic is UNKNOWN. Stop on duplicate dispatch, lost lineage, workload-induced OOM, unplanned eviction or a resident budget breach. Queueing can trade responsiveness for utilization; it supplies no promised throughput or cost improvement. #6781 retains its separate measured experiment.
Cooperative active parking is a separate step
QoS, execution substrate and lifecycle are independent dimensions. Asynchronous
work can be high priority and can be unsafe to park. #6256 owns the existing
interrupt/flush/bank continuation; #6564 owns exact-turn Stop validation. A
follow-on runtime contract should reuse session, lineage and exact-dispatch
identities: request yield with a reason and deadline; receive not_safe or an
acknowledgement naming a flushed checkpoint; reclaim only after actual VM
cessation; resume that identity. Cancellation remains a separate operation.
This issue implements durable demand for existing idle-session wakes.
The completion contract needs separate evidence at each boundary:
| Boundary | Required evidence and owner |
|---|---|
| Turn settled | Runtime confirms provider output, tools and follow-ups for the exact dispatch have reconciled. A quiet socket or heartbeat is insufficient. |
| Checkpoint safe | Runtime acknowledges a flushed checkpoint and its session/lineage/dispatch identity, or returns not_safe. |
| Residency released | Ember confirms the exact VM has ceased and its physical accounting has transferred or released. Lowering a reservation alone reclaims no memory. |
| Logical run complete | Factory confirms task dependencies, final artifacts and review outcomes have settled. A completed turn may still require another turn. |
#6256's dependent contract needs tests for tool/follow-up activity after provider output, unsafe yield, checkpoint acknowledgement lost around a crash, delayed VM exit, cancellation during yield, exact-identity resume and final task completion after multiple turns. A density experiment must show actual resident memory falling after cessation and fitting queued work progressing within the same resident-plus-restore budget. Queue acceptance and an idle heartbeat prove none of these completion boundaries. Guest ballooning or another way to shrink a running VM requires separately supported backend work and measurement; this slice assumes neither ballooning nor reclaim from low observed RSS.
The follow-on must preserve current CPU, RAM, disk, process and output limits. Separate active execution leases from queued/parked time, keep cumulative accounting across resumes, distinguish semantic progress from heartbeats and bound repeated park/restore churn. Missing a safe-yield deadline leads to controlled containment and an uncertain outcome, never blind replay. Numerical limits, CPU sizing (#6966), security policy, jailer activation (#6965) and the human-wait delivery-slot work (#7163/#7180) remain with their owners.
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 (unconditional since 2026-11; armed in production and dev from 2026-08-22, #4758), and reconciliation is fail-closed toward destruction (an unrecognised node VM is an orphan to destroy, unless it carries the
origin: 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 the durable commit (one transaction per append today; group commit is a Decided direction, not built). 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. Lifecycle appends remain synchronous: the durable write succeeds before ETS advances or ownership is acknowledged. The unarmed async lifecycle implementation was removed under #7059, superseding embervm/014 decision 2. Worker-authoritative state and node-confirmed destruction remain unchanged. Forward-only projection guards preserve terminal and generation protections for late records and historical replay. Vocabulary-only conformance remains insufficient because a modeled action can exist in an enum and never be emitted (ADR embervm/034).
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). Built, direct-checker tier: the debug-gated SpecTrace (#4770) recordsadoption.tla-shaped events inembervm-dev(specTrace.enabled; production keeps it off),Embervm.SpecTrace.Checkerevaluates nine of that spec's invariants over them with pass, fail or vacuous plus coverage, andGET /v1/conformanceserves the result. The dev conformance runner (projects/embervm/conformance) drives task, session and destroy scenarios against dev, reads that endpoint as scenario S4, and its/verdictgates the Kargo dev-to-prod promotion on the hub. The checker re-implements the invariants by hand; it does not ask TLC whether a trace is a behaviour of the model. Why.inventory_reconciledcompares the node'slive_vms, its count of every live VM, with the control plane's primed pool. A session VM, or a task VM a worker has already claimed, on a node with an empty pool made it fail on every dev suite, so no chart could promote (#6422). Each checkpoint now records, per node, the VMs live for another reason: the node's own session, serving, stateful and group-member lists (each disjoint from its primed pool) plus claimed task VMs. The checker flags only the live VMs left over. It keepslive_vmsrather than switching toprimed_countbecause a node that stops reporting its pool zeroesprimed_counttoo, which would hide the wedge the invariant exists to catch (#4838). A session's VM is not brief: it stays a primed task VM on the node until the session's first operation adopts it, and a session destroyed unused never leaves the task registry. The live dev lane failed S4 on every suite until checkpoints also counted, per node, the primed ids the control plane holds as durable session claims. Bare session reservations are deliberately not counted: they have no expiry, so one leaked by a failed create must still read red. Brief windows can still read red at a single checkpoint: a primed VM mid-teardown, a session VM between reservation and commit, a finished task VM the node has not yet dropped, and task VMs orphaned by a control-plane restart. Staged: the TLC tier, trace validation ofadoption.tlaagainst dev SpecTrace windows (#6415), ships default-off behindconformance.s6.enabled(adoption_trace.tla plus the S6 runner feeding/verdict, provable in CI on fixture windows with no live cluster). The runner image carries the pinned JRE and TLC under/opt/tla; turning the key on renders theS6_TLC_*env, a/tmpemptyDir and JVM-sized memory (conformance.s6in the chart values). Live embervm-dev validation remains open on the issue. The broader ADR embervm/034 harness beyond that is not planned (#4761, #4763 closed). - Cells are not an active programme: no
cell_id, workload-to-cell assignment or per-cell dial-home address exists. The implementation proposal in PR #6069 closed unmerged with #3855 and #4753. Multiple nodes or clusters alone do not require independently operated cells. This specific disposition does not close #3853 or any other scale-out item; each keeps its own evidence and decision. - 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). - Staged OTLP capacity metrics: the endpoint-less default has no metric
reader. When
OTEL_EXPORTER_OTLP_ENDPOINTis present, the control plane adds a 60 s periodic reader backed byotel_exporter_metrics_otlpfrom the pinned official experimental metrics API and SDK 0.6.0 packages. Release boot fails immediately if that exporter module is missing, and the Linux release-boot smoke exercises this configured path without requiring a live collector. Production delivery and stability checks remain outstanding on #6258. - Health surface tiers (historical embervm/031): #4338 built a detector for sustained artifact-export failure streaks and warmth-GC manifest age. It was never armed. #7058 removes the detector, its HTTP route and its monolith consumer. The store trust probe, readiness and liveness remain. Why. Joe's 2026-10-28 decision removes the unused detector and corrects the claim that its latch protected health. Artifact export, warmth GC and their manifests retain their existing behavior.
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
requests (memory requests at roughly 75% of the limit, moving to equal the
limit wherever reserved admission is armed, CPU request-only, see below), 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 admission (mem_admittable_mib net of in-flight claims); pack-to-empty scoring; best-fit placement across classes; retry on refusal |
| 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 (64, a cushion charged only under
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.
Admission is reserved (#4185; noded.admissionModel: reserved). Staged:
the chart default and both deployments still run observed until #6965 flips
each stage after the chart reaches it, because a value flipped before its
template deploys would run the old daemon in the new mode. A brick admits a boot
only when the declared memMib of every live and in-flight VM, plus
vmOverheadMib per VM, fits its budget (memory.max minus
daemonReserveMib), and the observed guard also holds: cgroup headroom net of
reclaimable page cache, less boots in flight, less memRejectFloorMib. noded
evaluates that predicate in one function under one mutex, against an atomic
ticket ledger: every boot verb holds a ticket from admission until its driver
boot call returns, so concurrent boots cannot each pass against the same
headroom. BuildBase holds its ticket for the whole build, because a build
guest never joins the live map, but the observed guard stops subtracting it
once the build guest boots, because the cgroup headroom then charges that
guest. A restore (a Prime, or a session, serving,
stateful or group-member relight) is charged the larger of its declared
memMib and the size recorded with the snapshot it restores, because the live
map counts the restored VM at the snapshot's size. A refusal is
RESOURCE_EXHAUSTED with a stable token (pressure:mem, pressure:taps,
slots), logged and counted in NodeStatus.admission_refusals. Every
NodeStatus carries mem_admittable_mib, the largest memMib the brick would
admit for one more VM at that instant, computed by the same function; it is the
one memory predicate the control plane trusts.
The control plane keeps its own in-flight ledger (Embervm.Scheduler.Inflight).
Every new-VM placement claims what the chosen brick will charge (its declared
memory, plus vmOverheadMib under reserved) before the boot RPC and releases it
on a refusal. After a successful boot it keeps the claim until the brick's next
report under reserved, which charges the landed VM, and for a 5 second hold
under observed, which does not charge a guest until it faults its memory in.
Scheduler.mem_eligible?/2 admits a need only when it fits
mem_admittable_mib net of the claims that report does not count yet. noded
reports separately the in-flight boots it admitted for a control-plane RPC
still open (mem_inflight_placed_mib), and the control plane nets its unsettled
claims against that figure only, so a node-local activator wake cannot hide one
of its claims. A classed brick reporting a zero budget is not placeable. Two placers racing between reports can still
pick the same brick; the brick's own admission refuses the loser, and with
dispatcher.placementRetry (armed with reserved admission per stage, #6965) task, serving
and session placement moves on to the next candidate instead of failing the
attempt, recording demand once. A serving relight is pinned to the brick
holding its snapshot, so when that brick cannot fit the guest (by its report
net of pending claims, or by refusing the relight) the same wake boots cold on
another brick and the snapshot stays banked; only that cold placement records
demand or fails the wake.
vmOverheadMib and the jailer's ceilings are different numbers. The overhead
is what admission charges each VM for host memory beyond guest RAM. The
jailer's per-VM leaf caps one VM at its memMib plus 192 MiB and charges
nothing; the shared embervm-vms parent caps all guests together at the brick
limit minus daemonReserveMib, with memory.oom.group 0 there and 1 on each
leaf, so an aggregate overrun kills one guest and the container lives. With
the jailer disarmed, as in production today, the container limit is the only
ceiling and the kubelet's memory.oom.group=1 on it takes noded and every
guest down together.
Brick memory requests move to equal their limits with the same values change (#6965). A reserved brick fills its budget with declared memory, so requests below the limit would let kube-scheduler place more brick limit on a node than it holds, and the kubelet would evict co-located bricks together.
Why. On 2026-10-10 the hub's 8gi brick (budget 7936 MiB) ran the observed
model and admitted five to seven claude-runtime guests at 3072 MiB each, 15 to
21 GiB declared. A restored guest is charged only for pages it has touched, so
for minutes after a restore it costs a few hundred MiB of headroom, and creates
scored on one stale report herded onto the fullest brick. As the guests warmed
up the container crossed its limit and was OOMKilled whole, noded and every
guest, three times in about 70 minutes. Reserved admission fits two such
guests per 8gi brick and one per 4gi, so the hub's 8gi ceiling rises from 3 to
6 bricks with the flip (#6965), and demand past a brick now shows up as denials that scale bricks
up. An overcommit ratio was rejected while production runs without the
jailer, because without the embervm-vms cap an overcommit failure still kills
the whole brick. Rollback is noded.admissionModel: observed.
Memory admission across bricks is model-checked (admission.tla): noded's
reserved model admits a boot atomically against the declared memory of its
live VMs, its in-flight tickets and the per-VM overhead, and the control plane
places on each brick's last, possibly stale, mem_admittable_mib net of its
own pending claims and retries a RESOURCE_EXHAUSTED refusal. TLC shows no
brick commits or holds more than its budget as lazily restored guests fault
their RAM in, every ticket is released once, every claim drains, and every
request an empty brick would admit is eventually placed, with or without the
frontier walk. Its negative modes re-find the 2026-10-10 OOMKills (observed
admission), a check-then-act double admit, and a single terminal attempt
stranding a request. The result covers the reserved configuration, which each
stage arms after this chart reaches it (#6965); the specs README records what
the model abstracts away.
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). Memory requests sat at roughly 75% of limit (PR #5519,
merged 2026-09-01, ADR embervm/039) because guest RAM is demand-faulted; the
chart default keeps that for the observed model, and the stages that arm
reserved admission request the full limit (#6965) because it fills a brick to
it. The
limit itself still sets usable_mib.
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 (#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. Production Git values enable the lane. This is configuration evidence,
not a new observation of a live rollout.
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
Built and staged (#5255; section 10 has the containment detail):
embervm-dev arms it with a values-only change once this chart reaches dev, and
production after a dev soak (#6965).
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. Exit classification is shipped as
observation only (#5805), with four reasons: positive per-VM cgroup OOM evidence
first (host_cgroup_oom), observed VMM liveness plus a SIGKILL outcome for
host_requested, a panic marker in the bounded serial tail for
guest_kernel_panic, otherwise unclassified. Guest feedback is
Shipped-but-suspended behind default-off
EMBERVM_NODED_GUEST_MEMORY_FEEDBACK_ENABLED and
noded.guestMemoryFeedback.enabled. After a rootfs rebake, k3s guests serve the
additive memory_status command with available memory, supported PSI and OOM
evidence; other runtimes report unsupported. The host handle owns each
activation and every request carries a fresh host nonce. A bank cancels and
joins its in-flight request before pausing. Restore starts pending with a new
counter baseline and discards pre-restore observations. Only a positive
within-activation oom_kill delta is guest_oom; evidence becomes stale after
three poll intervals. Missing or stale data is neither healthy nor proof of
OOM. Host-observed capacity stays authoritative: a guest report can never raise
capacity, bypass a quota, or relax idle-only banking, in-flight protection,
backoff or hysteresis (#5521). This change adds no admission or banking input.
Any future automatic action needs its own flag and issue, gated separately
from telemetry rollout. The control plane reports fixed-cardinality instance
diagnostics and logs positive counter deltas. Exit and guest OOM counters are
cumulative since daemon start; new instances, boots and backwards counters
establish fresh baselines. The daemon guest OOM total saturates at the uint64
limit. A listener unavailable during guest boot is retried; only an explicit
unsupported-command response suppresses further probes for that activation.
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 is configured
at rung full on the observe -> up -> full ladder: denial-driven scale-up
acts, clamped to the chart's maxReplicas, and drain-aware scale-down may act
after its idle and fully-exported victim rails pass. Because placement is
best-fit across classes, a denial scales the smallest class whose empty brick
would admit the need and that still has replica headroom (never one at an
operative ceiling of 0), and demand for which every fitting class is at its
bound latches the ceiling_exhausted capacity condition with or without a
ceilingBound. This is staged repository
configuration, not proof of a rollout or live behavior. Post-rollout
operational acceptance remains outstanding on #6059. The current brick mix is
deployment state and lives in the fleet section.
Why. Workspace archives acknowledge durable export through each node's
session_volumes[].exported fact. ArchiveVolume returns after enqueueing the
transfer, so its RPC acknowledgement cannot authorize scale-down. The optional
bricks.autoscale.archiveAckGate rail fails closed on a missing or false flag
and requests archives before choosing a victim. A fact whose workspace scan is
incomplete (session_volumes_complete false) is likewise never a safe victim
and requests no archive, since an empty list from a failed scan must never
read as safe. The volume inventory is node-shared but the exported and attached
flags are per brick process, so each request is addressed to the victim
instance (never the node name or first-in-table owner) and its ledger is keyed
{instance_id, lineage_id}; only the victim's own export flips the victim's
fact. The request ignores the workload persistence flag, like workspace
retirement, so a disarmed flag cannot strand a victim behind a silent success.
A blocked victim stays alive;
timeoutMs: 180000 bounds the quiet wait to the drain_node default deadline,
then logs an error once (Logger.error) and keeps the archive_pending
reason on the controller's decision span every tick. It never forces removal.
The [:embervm, :brick, :archive_ack_timeout] and
[:embervm, :session, :archive_result] telemetry events are emitted but no
handler or metrics exporter consumes them, so they are not an exported signal:
the one-time error log and the span reason are the observable ones. The timeout is a reversible alarm threshold. Restarted controllers
re-derive eligibility from node facts, independently of their request ledger.
The gate lands default-off for staging. Enabling it in deploy values and live
acceptance remain on #6533.
Why. Joe chose one brick per node on 2026-10-10 (#6533). The single switch
bricks.autoscale.archiveAckGate.enabled also renders required pod anti-affinity
on kubernetes.io/hostname across every class and node floor. Its release-scoped
selector includes noded-brick and the interim noded DaemonSet: both pod shapes
mount the same host scratch and derive the same node-shared VolumeRoot. No
size-class or floor label narrows that selector. The existing floor nodeSelector
remains intact. Kubernetes' inter-pod affinity filter counts terminating pods
still bound to the node, so with maxSurge: 0 a replacement waits until the old
pod leaves. Required anti-affinity is non-retroactive: already co-located pods
remain until rolled or removed.
The controller therefore holds the whole candidate node whenever another
registered instance has the same node ID and a different Brick.dial_id, even
if the victim reports every volume exported. Missing node identity, unavailable
registry coverage or an absent victim registration also holds. facts_fun reads
only dispatchable NodeCapacity rows; the gate additionally reads every
node_runtime entry through NodeRegistry.status, including draining, starting,
unknown and not-yet-reporting instances. A held candidate gets no archive request,
stays archive_pending under the keep-and-alarm timeout and ranks behind an
archivable single-instance candidate. When its sibling deregisters, normal
per-process export acknowledgement resumes. The earlier per-workload sibling
filter is removed because the whole-node hold covers it.
Archive candidates must also belong to the elastic class Deployment being
shrunk. The pod's controlling ReplicaSet name must equal that Deployment's name
plus its pod-template-hash; a same-class floor or another release's pod cannot
authorize deleting an elastic replica. Missing ownership or a failed pod list
withholds scale-down. The directing lookup checks ownership again before
annotating. Floor facts still participate in the registered-instance node hold.
When that Deployment has pods from multiple ReplicaSets, the gate holds all
candidates without archive requests, under the same keep-and-alarm timeout.
The directing lookup checks this again. A Deployment scale-down during a partial
roll may reduce a different ReplicaSet from the approved pod's; deletion cost
only chooses the victim within one ReplicaSet. Once the old ReplicaSet's pods
leave, normal acknowledgement resumes. Upstream also ranks unassigned pods,
non-Running phases and NotReady pods ahead of cost, so the directing lookup
additionally holds unless every other owned, assigned, non-terminating pod is
Running and Ready, and holds when another owned pod still carries the victim
cost from a prior annotate whose scale write failed. This hold does not stop
Kubernetes from performing its independently configured rollout.
Joe accepted losing brick bin-packing and the extra node spend. If the sum of
class maxReplicas plus floor replicas exceeds eligible FC nodes, pods can stay
Pending. A same-release DaemonSet also consumes those eligible slots. Pending
pods have no dispatchable capacity row: desired replicas above registered count
trigger fleet-full after the configured dwell (default 5 minutes); while flagged,
denial-driven scale-up waits (fleet_full_wait). Floor Deployments remain outside
the controller's scalable class pool. Capacity planning must account for floors
and remove the interim DaemonSet during a staged transition. Multiple bricks per
node would need a separate flock-epoch decision, outside this change.
Residual windows remain between registry/fact reads, asynchronous export and ReplicaSet deletion. An unregistered process, a registration expired while its pod still runs, or a different release sharing the host path is outside this release-scoped placement rule and registered-instance hold. An acknowledged export may become stale before deletion; there is no node-wide lock or epoch. The existing preemption drain remains the backstop for placement races. A controller restart resets the quiet timeout and reconstructs eligibility from current reports. A sibling's readiness can also flip between the victim-directing pod list and the ReplicaSet's own ranking at /scale. Live acceptance still needs placement across classes/floors, zero-surge roll completion, co-location and archive-failure hold/alarm drills, restart mid-drain, acknowledged export then shrink, cross-node export/rejoin and scale-down proceeding after a Spot preemption leaves a Failed brick pod.
Catalog-derived class floors apply only to the scalable, unpinned class
Deployment. Same-class nodeFloors are additive topology guarantees and are
not credited against that derived count: the controller never scales their
separate pinned Deployments, and a topology guarantee cannot silently become
the spare capacity of the demand-driven pool. This is deliberately
conservative when both exist, but keeps the nodeFloors contract orthogonal
and untouched.
Built, hub dev canary staged (ADR embervm/042, Accepted 2026-09-05): 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 ownership boundary is explicit. maxReplicas is the bootstrap ceiling and
the catalog-derived and manual floors still apply inside it. A positive
per-class ceilingBound transfers the ongoing ceiling to the controller; a
missing or zero bound keeps the old fixed-clamp behavior. Chart defaults and
production leave that transfer disabled. The hub embervm-dev overlay stages
only 4gi with ceilingBound: {4gi: 1}, bootstrap maxReplicas: {4gi: 0} and
mode full. Its 2gi min and max both remain 1 to preserve the conformance
brick. Only needs above the 2gi admission capacity and fitting 4gi attribute
denials to the canary; small-workload saturation on 2gi does not open it.
Live acceptance remains pending on #5505. On restart the controller reconstructs
a raised operative ceiling from the live Deployment replica count, never from an
unreadable scale response. Pinned nodeFloors remain separate Deployments and
are neither counted as scalable replicas nor consumed as ceiling headroom.
Sustained workload-attributed denials raise an authorized operative ceiling by
one. Replica growth is a separate decision on a later reconciliation tick. A
raised ceiling retires by one only after the scalable Deployment has remained
at zero replicas for bricks.autoscale.ceilingIdleMs (default 3,600,000 ms);
any denial or nonzero replica count resets that boundary. Replica scale-down
therefore completes first through the existing draining, live-VM,
exported-warmth, pod-resolution, and deletion-cost rails. Capacity conditions
are exposed in /v1/capacity and the Workload Capacity condition, while
/healthz remains process readiness and never changes because of capacity.
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/<architecture-digest>/size-<rootfs-size>/format-<bake-format>/
as their remote namespace. The payload is <payload-sha256>.ext4, with
rootfs.ext4.sha256 written last as the completeness marker. The marker records
and verifies the image digest, rootfs size, bake format, payload key, and payload
checksum. The local cache and the rootfs path pushed to noded carry the same size
and format identity. Changing either input therefore misses once without reading
or deleting legacy digest-only files or objects. A local miss reads the marker
first, downloads that payload, verifies its metadata and bytes, and punches zero
runs back into holes, so bases hydrate across nodes under the identity contract
with a sparse local hardlink cache in front.
Rootfs construction has an opt-in bounded driver. The default
rootfsBuilder.parallelEnabled: false preserves the legacy sequential
per-workload init containers and performs no class filtering. When enabled, one
init container launches at most rootfsBuilder.maxConcurrency independent
builders (default 2), gives each builder its own extraction directory, and keeps
the existing per-identity scratch lock plus the post-lock cache recheck. A child
failure terminates and reaps every remaining process group before the init
container fails. Classed bricks compare each workload's declared memMib with
the same effective ceiling used by scheduling: nameplate memory minus the daemon
reserve and rejection floor. Oversized images are logged and skipped. A missing
or malformed declaration is logged and baked fail-closed. The wildcard legacy
DaemonSet also bakes every image.
With rootfsBuilder.inPodBake.enabled, a long-running rootfs-baker replaces
both init-bake forms. Guest digests and digest-bearing paths live in the
directory-mounted rootfs-builder ConfigMap's tab-separated desired set, not in
brick pod templates. The baker polls every 30 seconds and sends missing roots
through the same bounded driver, class ceiling, content-addressed cache and
lock. ConfigMap updates are projected by kubelet without restarting the pod.
The chart defaults off; values-gke enables it. The first flip rolls bricks once;
removing the enabled line restores init baking and rolls once again. The baker
adds 100m CPU and 128Mi memory requests per pod (512Mi memory limit) to the
scheduled container sum, unlike the old init request's max-with-sum accounting.
Why. Digest-only publishes rolled every brick and drained live sessions. The ReplicaSet history on 2026-10-01 in #6662 identifies guest digest changes as the only pod-template change in several rolls. Moving desired roots out of the template lets existing sessions continue while new images bake in place.
Noded starts before first-boot bakes finish. Before submitting each eligible
missing root, the baker writes <rootfsPath>.pending and refreshes its mtime
each poll while outstanding. Skipped workloads get no marker. Publication or
failure removes it, and failed bakes retry on a later poll. With the flag-only
EMBERVM_NODED_ROOTFS_PENDING_MAX_AGE=1800s, BuildBase returns ABORTED for a
missing root with a fresh marker, without recording a failed base or changing
its backoff. Missing or stale markers, unset env, and other filesystem errors
retain existing behavior. Fresh serving, stateful and group boots and base
restore admission use the same guard. GC and ENOSPC reclaim semantics are
unchanged: .pending ends in neither .ext4 nor a reclaimable .tmp. name,
and the baker never deletes a published rootfs.
Repository delivery is staged under #6662. Operational acceptance still needs
a later digest-only publish to prove no brick ReplicaSet changes, new sessions
use the baked base, and existing sessions survive without node_gone.
Remote baked-rootfs retention is staged separately under
rootfsRemoteRetention. The repository defaults are enabled: false and
ageDays: 30. The control plane uses its existing S3 client to produce an
hourly dry-run plan and audit manifest. Current rendered image refs are protected
without an age limit. Only non-current, identity-qualified prefixes older than
30 days are candidates; legacy layouts, malformed keys, incomplete markers, and
unreadable listings are held. The 30-day horizon exceeds the existing seven-day
warmth windows and provides migration grace for retained bases, but activation
still requires reviewing real candidates and rollback assumptions. Disabling
the gate stops future deletion; it cannot restore deleted objects.
GKE validation store (Staged, #6193): the production Application continues
to use h0melab-ember-bases. This stage defines no production lifecycle
deletion rule, and verifying the live production bucket has none remains an
operator check. The repository carries an unreferenced, default-off dev preset for
h0melab-ember-bases-dev, plus a dev-only seven-day delete lifecycle
definition and an alerts-only USD 15 monthly Cloud Storage budget definition.
The budget filters the whole h0melab project's Cloud Storage service, not one
bucket, and alerts do not cap spending. The bucket, lifecycle, budget,
notification recipient, credential, and activation remain operator work. The
preset cannot become live through either checked-in Application or
kustomization, and its required Secret references fail closed until a verified
credential delivery path is supplied. It also disarms every application-level
retention delete gate inherited from the base dev values, so validation does
not introduce a second deletion path beside the reviewed bucket lifecycle.
Not planned rootfs plane (ADR 028, #4182, closed until its gate is met): 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 (the rootfs/measure_chunks.py harness, raw runs under rootfs/phase0/, results on #4182) 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.
The control plane performs one synthetic metadata fetch through its real S3
client at boot and every five minutes by default. The current TLS result is reported on
the always-on /healthz and authenticated /v1/health/store reads without
changing readiness; plaintext and unconfigured stores are explicitly disabled.
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. Domain grouping and a reserved platform principal
do not exist and are not planned
(#5072). This decision does
not remove or weaken existing principal authentication, authorization or
artifact isolation.
Object-level authorization on the management API (Built): passing the
submit allow-list says a ServiceAccount may use EmberVM, not that it may reach
another principal's objects. A task (GET /v1/tasks/:id, its result, redrive,
the dead-letter list) and a session (list, GET with a management token,
DELETE) are addressable only by the principal that created them; another
allow-listed principal receives the same 404 an unknown id gets, so the surface
never confirms the object exists. The workload-level destructive verbs (serving
force roll, stateful instance destroy, volume delete, handover, group force
roll) have no single owner and are admin-only (403 otherwise). Admins are the
values-configured usageAdmins, the platform's own ServiceAccount in the
reference deployment. A session's own capability token still proves ownership
on its routes. Why. Three principals share the production allow-list,
including the public tier's ServiceAccount, which serves anonymous internet
traffic; without object scoping a compromised public-tier pod could read the
monolith's task results, destroy its sessions, or delete a stateful volume.
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
└ 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.
The egress CA mount remains optional so a brick can start while cert-manager issues its release-local certificate in the same ArgoCD sync. If the initial load fails, CA fetches and permitted credentialed TLS requests retry the fixed configured files at most once every five seconds. A successful load keeps that keypair for the process lifetime; this does not introduce CA rotation. Missing or invalid material yields no CA response, and destination denial still happens before credential resolution or a TLS retry.
Why. A one-shot startup load left S8 permanently unable to fetch its CA when certificate issuance finished after sidecar startup (#7187). Making the mount required had already deadlocked brick rollout. Bounded initial-load recovery lets the optional Secret arrive normally without a restart or a new egress permission, while retaining the trust anchor already fetched by running guests.
Muse 1.0.3-R2198.1 was verified on 2026-09-10 to trust only its bundled
roots (1.4.1-R4503.1 keeps the same settings surface; re-confirm live).
A 2026-09-30 capture run of 1.4.1-R4503.1 under the shim's settings showed
every api.meta.ai request (catalog GET, turn POSTs) carrying the Bearer
Authorization header the presence-keyed swap needs, with no other hosts and
no telemetry traffic, so the api.meta.ai entry needs no injectAlwaysPaths.
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.
Why. The broker observes every pooled Codex grant with GET /wham/usage,
including exhausted grants. The hub refresh age is 600 seconds; a one-minute
tick starts at most one request per grant per refresh interval, with a ten-second
timeout, no redirects, and a 64 KiB body limit. A manual reset can precede the
reported reset by days. Waiting until that timestamp latched B at 100 percent.
Usage polling reads only an existing access token with more than a minute left;
it never rotates OAuth credentials or sends inference. Missing/expired tokens,
authorization failures (401/403), transport failures and malformed usage retain
the previous quota observation without extending its freshness. Ordinary token
serving still owns OAuth refresh. An idle grant with an expired token needs
that existing serving path before polling can resume.
The Codex sidecar excludes grants at 97 percent or explicit exhaustion, and never falls from a fresh usable tier into stale or exhausted quota after a token-fetch failure. The KG gate ignores exhausted accounts beside a fresh permitting account. Unknown inventory, malformed evidence, role floors and no fresh permitting account still defer. Cached sidecar observations age even while the broker is unavailable. Every sidecar carries the exclusion, so an old sidecar can no longer select an account the gate excludes.
Account preference (enabled on the hub for factory and interactive guests): grantPolicy: prefer-first
on a two-member Codex catalog pool chooses its first member whenever fresh usage
proves every reported window usable. The hub order is [codex-b, codex-cluster]. A fresh exhaustion report or the existing 60-second token-failure
cooldown permits fallback to A. Stale, missing or invalid B telemetry pauses
selection until another usage observation; it does not prove B exhausted.
Before enabling that catalog policy, set SWARM_CODEX_PREFERRED_GRANT=codex-b
on monolith using swarm.codexPreferredGrant: codex-b, together with the
verified-sidecar compatibility setting. Preferred
account admission requires current future-reset windows and refuses stale B,
so it defers jobs before egress would deny them. If B's cached token has expired,
quota observation needs ordinary token serving or operator recovery first;
neither observation nor preference rotation initiates OAuth refresh as a probe.
A can displace usable B only when both have fresh observations, all windows
are below 97 percent, all durations and future resets are known, and A's longest
window resets before B's. Its remaining time must be at most
min(12 hours, longest window duration * minimum remaining fraction across windows).
This uses reported headroom and time without assuming account capacities or
subscription weights. A short-window reset alone cannot preempt B's weekly
budget. Once the condition stops holding, B wins again regardless of the
previously selected account. Provider and model routing are separate.
Why. The shared sidecar receives only a destination preamble, and its broker SPIFFE identity identifies noded rather than a factory session. Enabling the policy in the current deployment would affect every Codex guest on that brick, including interactive guests. Joe accepted that shared scope on 2026-10-19, and the hub enables B-first for both factory and interactive guests after compatible sidecars and admission settings are verified. Factory-specific selection would require trusted workload metadata. Guest-supplied headers are not an account authorization boundary. No credentials, access rules or identity plumbing change here.
Why. Quota responses include advisory pacing against the longest reported window, with remaining daily budget and the reported next reset of a shorter window (#7122). A busy short window can coexist with spare weekly capacity. The broker derives this advice at read time from fresh observations; unknown durations stay unknown and passed resets require another report. The broker preserves reported reset timestamps without inferring future cycles. Observation storage, exhaustion, admission, grant selection and refresh keep their existing rules.
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 public Bazel-query demo exercised this pattern with server-controlled
argv, zero egress and a disposable clone per query. Its exhibit retired in
#6937; its unused guest image, Workload and build dependencies retired in #7056.
Why. No deployed stack or caller used the Bazel demo after the exhibit was removed. Building and publishing its runtime no longer served a workload.
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 (phases 0 and 1); platform mTLS
(phase 2) is Built, default off on both sides of the CP-to-noded hop
and awaits staged acceptance on #5757 and #5758; guest identity,
request-scoped injection, and federation (phases 3 to 5) are Planned.
Built, default off (phase 2 of #5706): noded opens a second gRPC
listener on noded.spiffe.grpcTlsPort (9443) that requires a client
X.509-SVID whose URI SAN is in an exact allowlist, derived by the chart from
the control plane's ServiceAccount unless clientSpiffeIds overrides it
(#5757). The control plane, with controlPlane.spiffe.enabled, runs a
spiffe-helper sidecar that writes svid.pem, svid_key.pem, and
svid_bundle.pem to a memory-backed volume in the SPIFFE Filesystem Delivery
layout; with controlPlane.spiffe.dialNoded it dials every noded on the TLS
port with a GRPC.Credential built from those file paths, verify_peer
against the bundle, no partial-chain trust, SNI disabled, and a verify_fun
that accepts only the exact noded SPIFFE ID the chart derives from the noded
ServiceAccount (#5758). Both identity strings come from the same release
fields on both sides, and the chart test pins that they agree. The bearer
header keeps being sent on the TLS dial, so the dual window changes transport
only. Rotation: :ssl reads the files at each dial and never caches their
bytes in the control plane; noded bounds channel age with its gRPC
MaxConnectionAge, after which the cached channel fails its next call, is
invalidated, and the re-dial handshakes with the files on disk at that
moment. Every flag defaults off in the chart and both checked-in overlays.
Why. Files rather than a Workload API client in the control plane: OTP's
:ssl already reads PEMs by path and re-checks them, so the control plane
needs no SPIFFE library, no long-lived source process, and no cached bytes,
and a Kubernetes podCertificate projected volume can replace the sidecar
later with no Elixir change. Exact-ID verify_fun rather than a trust-domain
match: a compromised pod in the namespace holds a valid SVID in the same
domain, so only the noded identity may terminate the dial. SNI disabled
rather than a DNS SAN on the SVID: the dial target is a pod IP and the SVID
carries only a URI SAN, so OTP's default hostname check can never succeed and
must be replaced, not tuned.
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 micro-architectural side channels between co-resident tenant guests. Tenants share bricks and Kubernetes nodes by design, and nothing pins a guest to cores or isolates SMT siblings: noded sets no CPU affinity, and bricks carry no CPU limit, so they are never Guaranteed pods the kubelet could give exclusive cores. Whether the Spot brick nodes run SMT is set in gcloud, not in git (#5255).
- Built, staged (ADR embervm/039, #5255; arming tracked on #6965): with
noded.jailer.enabledevery guest launches through Firecracker's jailer: a per-VM chroot whose inputs are staged readable by the jail uid, a uid and gid from a block of the node's 20000-59999 range that each daemon leases for its lifetime (the lease is unique among one release's bricks, because they share its snapshot root; releases sharing a node pool must lease from disjoint ranges set bynoded.jailer.uidBlockBase: production 0, and embervm-dev 20 before it arms), a cgroup v2 leaf withmemory.maxat the guest'smemMibplus 192 MiB andmemory.oom.group=1under the cappedembervm-vmsparent (section 7), and a PID namespace noded creates withCLONE_NEWPID, so Firecracker is init of its own namespace while noded still waits on, kills and reads it as its own child (the jailer's--new-pid-nsis not used, because it leaves the VMM a grandchild noded cannot supervise). Snapshot outputs written inside a jail are handed back to the daemon, and the guest egress socket in the jail is chowned to the jail uid. It fails closed at startup and per launch: an armed brick that cannot delegate cgroups, find the jailer, or open every READY base rootfs through a jailed preflight exits before it registers; a jailed launch that cannot be contained is refused, never retried unjailed, and so is a legacy bundle whose metadata names no rootfs, which could only run direct; a startup sweep removes jails a dead daemon left.NodeStatusreportsjailer_armedand cumulativelaunch_modes(jailed,direct_exec,refused), and conformance S7, once dev arms it, fails the dev gate on any unjailed or refused launch. Every deployment keepsenabled: falseuntil its stage runs this chart; dev arms first, production after a dev soak, each with that one value. - 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 embervm_serving table accepts established/related replies and drops NEW traffic that enters the inet forward hook from the shared serving bridge (iifname <bridge> ct state new drop: VM-originated forwarding, including external egress). That rule carries no same-bridge exemption, so its effect on two guests sharing the bridge depends on whether bridged frames traverse the inet forward hook on the deployed host at all; whether such guests 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 embervm_group table keeps same-bridge member traffic (iifname <group> oifname != <group> ct state new drop) while dropping NEW traffic that leaves the group bridge toward another group, the serving bridge, or the overlay. 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. Built at source level for tap-bearing serving, stateful and composite guests: both generators (nftRuleset, nftGroupRuleset) register an inet input-hook chain beside the forward chain that accepts only established/related replies and drops everything else entering from a guest bridge, so the bridge gateway, noded's pod IP and every wildcard listener on it (gRPC, the HTTP and L4 activators, health, metrics, the egress sidecar's loopback) are unreachable from a guest while host-originated probes and DNAT'd requests keep working; the forward chains drop INVALID as well as NEW guest-originated traffic; and every tap on the shared serving bridge is attached as an isolated bridge port (bridge_slave isolated on), so serving and stateful guests cannot exchange frames (unicast, broadcast or ARP) at L2 although they share one bridge. What remains unproven at source: the serving table still has no per-tap source IPv4 or MAC match, so a guest can spoof another port's addresses toward the host (an isolated port cannot deliver the spoofed frames to its neighbour). Installed rules, actual listener reachability, 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 input and forward posture denies every guest-originated flow toward the host and the overlay, which is where cluster DNS and the Service ranges live, but it is not a runtime proof about guest DNS configuration, pod or Service ranges or CNI policy. #6159 retains live validation of cluster DNS and topology exposure. |
| 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 inert noded and token-broker Cilium policies were removed in #5816. The hub has no corresponding CRDs; the bearer remains the noded authentication control, not a substitute for network isolation. SPIFFE X.509-SVID mTLS is the additive upgrade the proto reserves, Built, default off on both sides as phase 2 of #5706 (section 9) and awaiting staged acceptance. 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 is Not 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
Why. Noded's image is amd64-only (#7065, Joe's 2026-10-28 decision): the fleet has no arm64 consumer. The daemon and all four Firecracker bundle files are retained in two amd64 layers on Wolfi. Shared archive registration still fetches both architectures.
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 loop file at that path (the GKE hub). ext4 remains the safe default. XFS with reflink is staged behindscratchPrep.filesystem: xfsfor newly created managed images. Destructive replacement of an existing ext4 image requires the separatescratchPrep.migrateExt4ToXfs: truegate. The migration path fails closed unless the exact managed non-symlink image has no hard-link aliases, its ext4 loop fstab entry, host loop device and sole mount target agree, and no active consumer is found. The verified inode stays open across mkfs so a concurrent path replacement cannot redirect the reformat. It never reformats XFS, never force-unmounts, and leaves foreign bind mounts untouched. Both gates remain default-off pending the live checks in #5699. Bases under scratch 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, with the implementation PR closed unmerged. Multiple clusters alone do not reopen them. 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 (two concurrent restored-clone vsock exchanges,
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. S1 sends a distinct marker into each
clone, requires the same marker back, rejects the peer marker, and requires two
15-second guest holds to complete inside a 25-second overlap budget. The budget
includes 10 seconds of explicit restore, admission, and HTTP headroom while
remaining shorter than two serialized holds. The hold is bounded by the sandbox
guest's own 25-second wall-clock cap, which S1 requests explicitly. Two more
scenarios run after the rest once embervm-dev arms them with the jailer
(conformance.requireJailer, conformance.egressProbe; #5255, #6965). S7 reads
/v1/capacity before S1 and after the suite and requires every brick to
report jailer_armed, no direct_exec or refused launch across the suite,
at least one jailed launch on a brick whose counters ran unbroken across the
suite, and a nonzero budget with its own admission predicate on every classed
brick. A brick that registers or restarts mid-suite counts its startup
preflight as a jailed launch and has lost any earlier counts, so it credits
nothing. S7 fails at once when every brick from suite start is listed with its
counters reset. When no listed brick kept its counters but one from suite start
is missing from /v1/capacity, S7 keeps polling inside its budget, since the
registry drops a brick whose stream is unknown or draining and restores it
with its counters. S8 has a task guest dial the host's egress
vsock port and requires the egress sidecar's CA certificate back, which proves
a jailed Firecracker can connect to its egress socket. A
failed or all-vacuous run blocks promotion; Freight approval is the explicit
operator override. Phase 1 is tracked in #5224. The launch-mode, crash-relaunch,
and architecture acceptance matrix for restore-time vsock overrides is a
separate manual readiness gate in
docs/drills/vsock-override-acceptance.md.
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
- #7210: durable existing-session residency demand and factory capacity waits, default-off preparation; dev operational acceptance and production activation remain separately authorized.
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 |
|---|---|---|---|
| Factory guests share credential-free bounded CI log reads | section 4 | #6442 | helper packaged; guest uptake and next-window repeated-call evidence pending |
| Brick scale-down couples durable archive acknowledgement with one brick per node (Joe, 2026-10-10) | section 7 | #6533 | single repository gate default-off; staged enable and live acceptance pending |
| Guest digest-only publishes bake rootfs files in place without rolling bricks or draining live sessions | section 8 | #6662 | repository implementation staged; ReplicaSet and session live acceptance pending |
| The Firecracker jailer arms on every brick, closing the direct-root-exec gap between co-resident guests | section 10 | #5255 | built and staged; dev arming then production after a dev soak (#6965) |
The brick maxReplicas ceiling itself moves on sustained denial pressure, not only the replica count clamped inside it |
section 7 | #5505 | built, hub dev 4gi bound-1 canary staged; live acceptance pending |
| SPIFFE-issued identity moves beyond issuance: mTLS on the CP-to-noded hop, per-principal guest JWT-SVIDs, and GCP federation | section 9 | #5706 | CP-to-noded mTLS built default off on both sides (#5757, #5758), rest not started |
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 historically; adapter not built and now not planned (#5806) | 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); privileged noded now tracked by #5702 |
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 historically; Postgres Built, cell work closed unmerged and is not planned (#4753, #3855, PR #6069); tracker #3853 closed | 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 merged (#3856, #3858), clean-host KVM validation not yet done | deleted |
| embervm/010 | Bazel warm-Skyframe public demo as a stateless query consumer | Retired with the exhibit (#6937); runtime removed in #7056 | 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 not planned (#3862) | deleted |
| embervm/013 | Classes are reuse semantics, substrates are lanes; brick sizing; bricks everywhere | Accepted, Built | deleted |
| embervm/014 | Worker-authoritative state and node-confirmed destruction; decision 2 async writes retired by #7059 | Built (adoption.tla); decision 2 superseded by synchronous lifecycle appends (#7059); metering clause amended by 020 |
deleted |
| embervm/015 | Isolated high-throughput lane with data-plane placement | Draft; not planned (#3864 closed, its #6132 reuse and evidence gate is the reopen criterion); 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 historical rationale; domain and service-binding programmes are not planned (#5072, #5813) | 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 historical rationale; domain/platform-principal work is not planned (#5072), guest identity mechanism superseded by 041 | 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 for persistence modes and retention; size budget superseded by the evidence gate in #5074 | deleted |
| embervm/028 | Eager-local rootfs: OCI ref, Account chunk store, ublk | Accepted, not planned (#4182); Phase 0 measured on #4182 | 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 | Historical export-failure and GC-age detector, never armed | Built (#4338), removed (#7058) | 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; 75% memory requests Built, reversed to equal limits where reserved admission is armed (#4185, #6965); shedding Built and configured on in production; jailer Built and staged (#5255, #6965); 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, built; hub dev canary staged, live acceptance pending (#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 |