Status: Future direction. Outside m80's core scope per CLAUDE.md (m80 captures VM mechanics only). This document specifies a multi-tenant orchestration layer that sits above m80 and consumes m80's primitives. The m80-side primitives this design depends on are tracked by m80-iswt.
Source:
/tmp/m80-conveyor-belt-strategy.md(hermes, 2026-05-07).
The conveyor belt is m80's strategy for serving multi-tenant sandboxed workloads with hardware-level isolation, sub-50 ms allocation latency, and a cryptographic guarantee that no tenant's data can ever leak to another tenant.
The core idea: the pool is a conveyor belt, not a parking lot. VMs enter blank, get used once with a single tenant's data, and are destroyed on the way out. The pool is continuously refilled in the background with fresh generic VMs that have never touched any tenant's data.
The orchestration layer that drives this is called the Bestiary — a separate Rust program/daemon that calls m80's API. m80 stays a pure execution engine; the Bestiary owns pools, tenants, profiles, and strategy enforcement.
In a multi-tenant sandbox system, the worst possible failure is cross-tenant data leakage. If tenant A's data is somehow visible to tenant B — through stale filesystem cache, leftover in-memory buffers, orphaned mount state, or open file descriptors — the isolation guarantee is broken.
This threat exists in any system that reuses execution environments between tenants. The question is not whether cleanup can work, but whether you can prove it always works, even under crashes, timeouts, and edge cases.
A VM that is destroyed cannot leak data.
Tautological but powerful. Instead of trying to perfectly clean a VM between tenants — unmounting, flushing caches, zeroing memory, closing file descriptors, verifying mount-namespace cleanliness — we never attempt reuse. The VM is killed. Its process is gone. Its memory is freed. Its mount namespace ceases to exist. There is no state to clean because there is no process.
This converts a correctness problem (did we clean everything?) into an operational problem (can we refill fast enough?). Correctness problems require exhaustive testing and can still have edge cases. Operational problems are solved by having enough capacity, which is straightforward to verify.
-
Pool VMs are generic. They contain only the base rootfs, guestd, and a kernel. No tenant data has ever been inside them. They are restored from a known-good snapshot image stored on disk.
-
Tenant data enters only at allocation time. When a request arrives for tenant X, the pool manager grabs a generic VM and attaches tenant X's block device via Firecracker's
PATCH /driveAPI. -
Tenant identity is verified. After mounting the block device, guestd reads a caller-specified path on the tenant disk and sends the bytes to the host over the vsock channel (m80 carries opaque bytes; the path is configurable per-attach via the
identity_pathfield on the hot-plug request). The host compares the reported bytes against the expected tenant. Mismatch = immediate kill.What m80 owns vs. what the Bestiary owns. m80 provides the transport and the byte-compare; the threat model and any cryptographic structure of the identity bytes are the Bestiary's responsibility, not m80's. Choices the Bestiary must make:
- Threat model. Is verification defending against (a) Bestiary's own bookkeeping bugs, (b) disk-swap during attach, or (c) guest VM tampering with the workspace? (a) and (b) need only opaque-bytes comparison; (c) requires crypto-signed tokens with a key the workload doesn't have.
- Token shape. UUID, signed JWT, sealed nonce, filesystem-UUID reflection, or virtio-blk serial. m80 doesn't care; it carries bytes.
- Where the identity lives. A file at
/workspace/.tenant-identityis the convention used in the m80 tests, but the path is just bytes coming back over the wire —/tenant/.tenant-identity,/.identity, or any other path works equally. - Pre-attach vs. post-attach population. The Bestiary can write the
identity to the disk file pre-attach (host-side, before PATCH /drive)
or rely on the disk image already containing it. Either way, m80 just
reads from
identity_pathafter the guest mounts the disk.
-
The VM serves exactly one workload for exactly one tenant. No second mount, no second tenant, no cleanup step.
-
The VM is destroyed after the workload completes. Kill the Firecracker process. Address space, fd table, mount namespace, and kernel state are gone. The only surviving artifact is the tenant's own block device, which persists independently.
-
A fresh generic VM replaces it. Snapshot restore creates a brand-new VM from the known-good base image. It has never seen any tenant's data.
| Guarantee | Mechanism |
|---|---|
| No cross-tenant filesystem leakage | VM is destroyed, not reused |
| No cross-tenant memory leakage | Process killed, address space freed |
| No stale mount state | Mount namespace ceases to exist |
| No open file descriptor leakage | File descriptor table destroyed with process |
| No kernel cache leakage | VM kernel state destroyed with the VM |
| Correct tenant always served | Cryptographic identity verification after mount |
If anything goes wrong during allocation — mount fails, identity doesn't match, guestd doesn't respond — the VM is killed and discarded. Grab another from the pool. The failure mode is a latency spike for one request, never a security incident.
| Path | P50 latency | When used |
|---|---|---|
| Cold boot (from scratch) | ~1117 ms | Never on request path — only for initial snapshot capture |
| Snapshot restore + drive attach | ~300-400 ms | Background pool refill |
| Warm pool allocate + drive attach | ~45 ms | Request path |
A warm pool VM is already booted with guestd ready and listening on vsock. The only work at allocation time is:
- Dequeue from pool — grab a VM handle. ~0 ms.
- PATCH /drive — Firecracker attaches the tenant's block device via virtio-blk hotplug. ~5 ms.
- Guest mount — guestd detects the new block device via udev/uevent, mounts it, reads the tenant identity file, sends verification over vsock. ~15-25 ms.
- Host verifies identity — compare reported identity against expected. ~0 ms.
- Signal ready — execute the workload.
Total: ~40-50 ms from request to execution start.
Pool refill happens in a background task. When a VM is consumed from the pool, the refill process immediately starts a snapshot restore to replace it. ~274 ms for the restore itself, plus ~25-100 ms for drive attach and mount verification of a test mount (if desired) — totaling ~300-400 ms.
But this 300-400 ms happens in parallel with workload execution. If the average workload takes 2 seconds, refill has 2 full seconds to complete.
The pool maintains a buffer of N idle VMs above current active concurrency (N = 1 or 2, configurable). Under steady load:
- A request consumes one VM from the buffer (45 ms allocation)
- Background refill restores one VM to the buffer (300-400 ms, but overlapping with the workload's execution time)
- Since workloads take longer than refill, refill stays ahead
Under burst load (e.g., 10 simultaneous requests when the pool has 2 idle VMs):
- First 2 requests get the fast 45 ms path
- Remaining 8 pay the snapshot restore cost (~300-400 ms)
- Refill kicks in immediately; subsequent requests get the fast path again as refill catches up
After a configurable idle timeout (e.g., 30-60 seconds with no requests), the pool stops refilling. The last VMs get consumed by their workloads and destroyed; no new ones are created. Zero VMs running, zero RAM consumed.
When the next request arrives at an empty pool, it pays ~300-400 ms for a snapshot restore. Acceptable for CLI / dev use; the pool immediately starts building the buffer back up so subsequent requests are fast.
┌─────────────────────────────────┐
│ VM PROFILE SNAPSHOTS │
│ (on disk, zero RAM when idle) │
│ │
│ minimal-128.snap (stripped) │
│ minimal-256.snap (stripped) │
│ full-1024.snap (stock) │
└──────────┬──────────────────────┘
│
┌──────────▼──────────────────────┐
│ BACKGROUND REFILL │
│ │
│ snapshot_restore(profile) │
│ → booted VM, guestd ready │
│ → NO tenant disk attached │
│ → enters generic pool │
│ │
│ latency: ~274 ms per VM │
└──────────┬──────────────────────┘
│
┌──────────▼──────────────────────┐
│ GENERIC POOL │
│ │
│ [VM-A] [VM-B] [VM-C] ... │
│ │
│ All blank. All identical. │
│ No tenant state. │
│ Elastic depth: active + buffer │
└──────────┬──────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ REQUEST │ │ REQUEST │ │ REQUEST │
│ tenant X │ │ tenant Y │ │ tenant Z │
│ │ │ │ │ │
│ 1. alloc │ │ 1. alloc │ │ 1. alloc │
│ 2. PATCH │ │ 2. PATCH │ │ 2. PATCH │
│ /drive X│ │ /drive Y│ │ /drive Z│
│ 3. mount │ │ 3. mount │ │ 3. mount │
│ 4. verify │ │ 4. verify │ │ 4. verify │
│ 5. exec │ │ 5. exec │ │ 5. exec │
│ 6. DESTROY│ │ 6. DESTROY│ │ 6. DESTROY│
└───────────┘ └───────────┘ └───────────┘
Each VM serves EXACTLY ONE tenant, then dies.
No reuse. No cleanup. No leakage possible.
t=0ms Request arrives for tenant X
Pool manager dequeues a generic VM
t=0ms PATCH /drive via Firecracker API
→ virtio-blk device hotplug into running VM
~5 ms
t=5ms Guestd detects new block device (udev/uevent)
→ reads device path
~2 ms
t=7ms Guestd mounts block device at /workspace
→ mount(idempotent, 100 ms timeout)
~10-15 ms
t=22ms Guestd reads the caller-configured `identity_path` on the
mounted tenant disk (e.g., /workspace/.tenant-identity)
→ opaque bytes (UUID, signed token, or whatever the
Bestiary chose — m80 doesn't interpret)
→ sends over vsock to host
~3 ms
t=25ms Host verifies identity
→ matches expected tenant X?
→ YES: signal ready
→ NO: kill VM, try next pool slot
~1 ms
t=26ms Execute tenant X's workload
~arbitrary (seconds to minutes)
t=done DESTROY VM
→ kill Firecracker process
→ entire VM state ceases to exist
→ trigger background refill
t=done+1 Background: snapshot_restore begins
→ ~300-400 ms to replace consumed VM
→ overlaps with other workloads, not on request path
Each tenant gets dedicated VM slots pre-mounted with their disk.
- Latency: ~21 ms (just allocate, disk already mounted)
- Security: Good — tenant never shares a VM. But VM is reused between the tenant's own workloads, requiring cleanup between runs.
- RAM cost: Scales with total tenant count, not concurrency. 200 tenants × 2 slots × 256 MiB = ~100 GiB of RAM for idle capacity.
- Flexibility: Changing VM profiles requires draining and rebuilding per-tenant slots.
Shared pool of identical VMs. Tenant disk attached at allocation time.
- Latency: ~45 ms (allocate + drive attach + mount + verify)
- Security: Strictest — VM is destroyed after every workload. No cleanup needed. Cryptographic identity verification. No state can persist.
- RAM cost: Scales with actual concurrency, not tenant count. 10 concurrent workloads × 256 MiB = ~2.5 GiB regardless of total tenant count.
- Flexibility: Different VM profiles coexist as separate snapshot files. Pool manager maintains buffers per active profile.
| Dimension | Sticky pools | Conveyor belt |
|---|---|---|
| Cross-tenant leakage | Requires correct cleanup | Impossible (VM destroyed) |
| RAM at 200 tenants, 10 concurrent | ~100 GiB | ~2.5 GiB |
| P50 allocation latency | ~21 ms | ~45 ms |
| Worst case (empty pool) | ~274 ms (restore) | ~300-400 ms (restore + mount) |
| Profile flexibility | Per-tenant reconfiguration | Instant — different snapshot |
| Correctness burden | High — must prove cleanup | Low — no cleanup to prove |
The 24 ms latency difference (45 ms vs 21 ms) is negligible for code-execution workloads. The RAM savings and security guarantee are decisive.
The conveyor-belt strategy is implemented in a separate Rust program called the Bestiary. m80 provides the primitives — launch, snapshot, restore, exec, destroy. The Bestiary provides the orchestration — pool management, tenant routing, profile selection, and strategy enforcement.
m80 knows nothing about tenants, pools, or strategies. It is a pure execution engine. The Bestiary calls m80's API to perform each step.
- Pool lifecycle — maintain the elastic buffer, trigger background refills, enforce idle timeout for scale-to-zero.
- Tenant routing — receive requests, identify the tenant, select the correct VM profile, allocate a pool VM, attach the tenant's disk.
- Identity verification — compare guestd's reported tenant identity against the expected tenant. Kill on mismatch.
- Profile management — track which VM profiles have recent demand; maintain per-profile buffer sizes.
- Strategy enforcement — ensure VMs are destroyed after use (never reused), enforce mount timeout, handle allocation failures by discarding VMs.
[pool]
buffer = 2 # idle VMs above active concurrency
idle_timeout_secs = 60 # scale to zero after this
max_pool_size = 50 # hard cap on total VMs
mount_timeout_ms = 100 # kill VM if mount takes longer
[profiles.minimal_128]
snapshot = "/var/lib/m80/snapshots/minimal-128mb.snap"
ram_mib = 128
cpu_template = "C3"
[profiles.minimal_256]
snapshot = "/var/lib/m80/snapshots/minimal-256mb.snap"
ram_mib = 256
[strategy]
type = "conveyor_belt" # default and recommended
verify_tenant = true # cryptographic identity verification
one_shot = true # destroy VM after every workload# Without bestiary daemon — direct snapshot restore (~300-400 ms)
$ m80 exec --profile minimal-128 --tenant-x -- ./build.sh
# With bestiary daemon — pooled allocation (~45 ms)
# Daemon auto-detected via Unix socket presence
$ m80 exec --profile minimal-128 --tenant-x -- ./build.sh
# Explicit daemon start (foreground or background)
$ m80-bestiary start --config /etc/m80/bestiary.tomlThe Bestiary lives outside m80 per CLAUDE.md scope boundary. m80's job is to expose the right primitives so the Bestiary (or any equivalent caller) can build this strategy:
- Drive hot-plug — Firecracker
PATCH /drivesupport inm80-firecracker-client; guestd-side udev/mount handler. - Tenant-identity vsock contract — m80-proto envelope for guestd to read
/workspace/.tenant-identityand report it; host-side compare and kill on mismatch. - One-shot lifecycle flag — m80-firecracker mode that destroys the VM after a single exec/stop without further reuse.
- Snapshot restore — already shipped (m80-rrp.3, closed).
- Reliability under saturation — m80-c78m must close before the under-load latency claims are honest.
These primitives are tracked by epic m80-iswt. The Bestiary itself is not tracked in m80 beads; it is downstream work in a separate codebase.
The conveyor-belt strategy works because it converts a hard correctness problem into a simple operational one:
- Security: No cross-tenant leakage is possible because no VM is ever reused. Destruction is a stronger guarantee than cleanup.
- Latency: 45 ms allocation via a pre-booted generic pool. Background refill keeps the pool full without affecting the request path.
- Efficiency: RAM scales with concurrency, not tenant count. Scale-to-zero when idle. Different profiles coexist as snapshot files on disk.
- Simplicity: Each VM has one lifecycle: blank → mount → execute → destroy.
This is the architecture that makes multi-tenant sandboxed code execution both fast and provably safe.