Quoro documentation
Quoro is a self-hosted tool that runs one microVM per application on Cloud Hypervisor and KVM, and deploys, exposes and observes those applications from the same interface. It is built to be operated by AI agents through scoped, time-limited grants, with every action recorded in an append-only, hash-chained audit log.
Quoro is experimental. Read the roadmap for what each milestone delivers before you rely on it.
Where to start
- Install: Installing Quoro covers the one-command installer, its roles (primary and node), what it verifies and the host layout it creates.
- Concepts: the product requirements explain what Quoro is for and who it is for; the glossary defines the terms used everywhere else.
- Architecture: how the pieces fit: control plane, node agents, engine, ingress, state replication and the agent loop.
- Contracts: the interfaces the components are built against: the HTTP API, events, the database schema, the node-agent protocol, the permission model and the audit log format.
- Security: the threat model and how to report a vulnerability.
- Decisions: every accepted design decision is an architecture decision record.
This documentation is built from the Markdown in the repository’s docs/
directory with mdBook. The site at
quoro.dev is published from GitHub Pages each time a
release is published, so it matches the latest release, not the main branch.
Quoro installer
One command installs Quoro on a Linux host: the static binary, the Cloud
Hypervisor runtime it runs apps on, the cloudflared connector it serves app
hostnames through, two systemd units, the data directories and the config file.
curl -fsSL https://raw.githubusercontent.com/OmarAlex24/quoro/main/installer/install.sh | sudo sh
Requirements: Linux on x86_64 (x86_64-unknown-linux-musl is the only published
target for now), root, systemd running, and curl, tar, gzip, sha256sum,
install, mktemp, cmp, systemctl and grep on the host; a primary also
needs useradd for its system account. The engine also needs mkfs.ext4
(e2fsprogs), ip (iproute2) and nft (nftables), and Git builds need git,
which the node agent runs to fetch the app’s commit: when one is missing the
installer runs apt-get update and then installs the four packages from apt
without recommends; without apt-get it stops and names the missing tool.
/dev/kvm is required at run time: the installer warns when it is absent (the
node agent then refuses to start the engine), so the files can still be
inspected on a machine without virtualisation. The installer stops and lists
anything else missing.
The script is POSIX sh, and every statement that touches the host lives inside
a function with a single main "$@" at the end: a truncated download does
nothing.
Environment variables
| Variable | Meaning |
|---|---|
QUORO_VERSION | Install this version instead of the latest release. v0.1.0 and 0.1.0 are both accepted. |
QUORO_INSTALL_FROM=<dir> | Install from local artifacts in <dir> instead of downloading. For testing before a release exists. The version comes from QUORO_VERSION, or from the single quoro-*-x86_64-unknown-linux-musl.tar.gz in that directory. |
QUORO_INSTALL_DRY_RUN_REF=refs/heads/<branch> | Verify a workflow_dispatch dry-run build from that branch instead of a release tag. Only honored together with QUORO_INSTALL_FROM, refused together with the insecure flag, and it prints a loud warning. Never use it for a real installation. |
QUORO_INSTALL_INSECURE_SKIP_SIGNATURE=1 | Skip Sigstore verification. Only honored together with QUORO_INSTALL_FROM, prints a loud warning, and the sha256 check still runs. Never use it for a real installation. |
QUORO_NO_START=1 | Install everything but do not start the units, and do not issue a setup token. On a node the enrollment still runs: it writes identity files and starts nothing. |
QUORO_ROLE=primary|node | What this host runs. primary (the default) installs the control plane and its own node agent; node installs only the node agent and enrolls it with a primary. See Roles. |
QUORO_JOIN_URL | Node only: the primary’s HTTPS URL, for example https://quoro.example.com:8443. |
QUORO_JOIN_TOKEN | Node only: a join token from the primary. It is single use, expires, and the installer never prints it. |
QUORO_JOIN_FINGERPRINT | Node only: the primary’s TLS fingerprint, sha256:<hex>. The installer prints it at the end of a primary install and it is in journalctl -u quoro. |
Example, pinning a version:
curl -fsSL https://raw.githubusercontent.com/OmarAlex24/quoro/main/installer/install.sh \
| sudo QUORO_VERSION=0.1.0 sh
Roles
QUORO_ROLE decides what the host is (ADR 0089):
primary(default) runs the control plane,quoro.service: the API, the panel and the node sessions, as the unprivileged system userquoro-apiwith an empty capability bounding set. The same host also runs its own node agent, so it deploys apps too. Nothing else is needed: the primary’s agent enrolls itself through a join file the control plane writes, so there is no token.noderuns only the node agent,quoro-node.service, as root. It runs the microVMs for one primary and serves no API and no panel. It belongs to exactly one primary and is enrolled during the install.
A node install needs a join token. Create one on the primary, in the panel or
with POST /api/v1/nodes/join-tokens, and pass it with the primary’s URL and
TLS fingerprint:
curl -fsSL https://raw.githubusercontent.com/OmarAlex24/quoro/main/installer/install.sh \
| sudo QUORO_ROLE=node \
QUORO_JOIN_URL=https://<primary>:8443 \
QUORO_JOIN_TOKEN=<token> \
QUORO_JOIN_FINGERPRINT=sha256:<hex> sh
The three values are checked before anything is written, so a missing one fails
before the host is touched. A join token is single use and expires, so create a
fresh one for every attempt. A host that is already enrolled needs no token:
re-running the installer with QUORO_ROLE=node finds its identity and skips the
enrollment.
A node install refuses a host that already runs a control plane (it finds
/etc/systemd/system/quoro.service or /var/lib/quoro/quoro.db): turning that
host into a node would orphan the database, its key files and its panel. It stops
without deleting anything; uninstall the control plane first.
What it verifies, and what it trusts
-
The script itself arrives over TLS from
raw.githubusercontent.com;curl -fsSLfails on an HTTP error and refuses plain HTTP. -
The tarball against
SHA256SUMSfrom the same release. This proves the download is the published bytes (integrity), nothing more. The check happens before anything is extracted, and a missing line for the tarball is a hard failure. -
The tarball against its GitHub artifact attestation: a keyless Sigstore bundle (
quoro-<version>-x86_64-unknown-linux-musl.tar.gz.sigstore.jsonl) verified withgh attestation verify <tarball> --bundle <bundle> \ --repo OmarAlex24/quoro \ --signer-workflow OmarAlex24/quoro/.github/workflows/release.yml \ --source-ref refs/tags/v<version> \ --deny-self-hosted-runnersThat pins the repository, the signing workflow, the tag and the runner kind. Verification uses a GitHub CLI downloaded from
github.com/cli/cliwhose sha256 is pinned ininstall.sh(quoro-install:prints when it fetches it). The pinned CLI runs with an empty config dir and withGH_TOKEN/GITHUB_TOKENunset, so a token in the environment cannot change the result. -
The engine runtime against the pins in the tarball. The tarball also carries
pins.txt(crates/engine/pins.txt): one line per artifact with its pinned sha256. The installer reads the lines for this architecture, requires thecloud-hypervisorandguest-kernelpins, and stages each file only if its sha256 matches. Files come fromQUORO_INSTALL_FROMfirst, then from an existing/usr/local/lib/quoro, then from the URL in the pin; a local candidate that does not match is ignored with a warning and the next source is tried. A download that does not match is fatal. Upstream publishes no signatures for these artifacts, so the integrity anchor is the chain TLS to GitHub → attested tarball →pins.txt→ sha256, and the engine re-verifies the same pins on every start. -
The tunnel connector against its own pins in the tarball. Nodes behind NAT serve app hostnames through Cloudflare Tunnel, which the node agent runs as a
cloudflaredchild process (ADR 0024, ADR 0055). The tarball carriesingress-pins.txt(crates/ingress/pins.txt) in the same format, onecloudflaredline per architecture. The installer reads the line for this architecture, requires it to becloudflared, requires its URL to be underhttps://github.com/cloudflare/cloudflared/releases/download/, and stages the file with the same rules as (4):QUORO_INSTALL_FROM, then an existing/usr/local/lib/quoro, then the pinned URL; a local candidate that does not match is ignored with a warning, a download that does not match is fatal. The integrity chain is the same one: TLS to GitHub → attested tarball →ingress-pins.txt→ sha256. A tarball from an older release has noingress-pins.txt; the installer then warns and installs without a connector, because only the tunnel needs it.
The repository, signer workflow, tag ref and GitHub CLI version/sha256 are
constants in the script. No environment variable overrides them. The one
exception is QUORO_INSTALL_DRY_RUN_REF, which replaces only the attested
source ref, requires local artifacts and cannot be combined with the insecure
flag; it exists so a dry-run build can be verified before a release, and the
attestation check is otherwise unchanged. Changing any other constant is a
reviewed change (ADR 0027).
Any failure in (2), (3), (4) or (5) aborts with
signature verification failed, nothing was installed
and no file is written outside the temporary directory. Host packages are installed only after every check passed.
Host layout created (ADR 0054, ADR 0089)
| Path | Role | Content |
|---|---|---|
/usr/local/bin/quoro | both | Static binary, mode 0755, replaced atomically via quoro.new + mv. |
/usr/local/lib/quoro/cloud-hypervisor-static | both | Cloud Hypervisor, mode 0755, verified against pins.txt. |
/usr/local/lib/quoro/vmlinux-x86_64 | both | Guest kernel, mode 0644, verified against pins.txt. |
/usr/local/lib/quoro/cloudflared | both | Cloudflare Tunnel connector, mode 0755, owner root:root, verified against ingress-pins.txt. The node agent runs it from this exact path for app hostnames (ADR 0024, ADR 0055). |
/usr/local/lib/quoro/quoro-init | both | PID 1 inside every microVM, mode 0755. |
/etc/systemd/system/quoro-node.service | both | Unit from packaging/systemd/quoro-node.service, mode 0644. Runs as root; stopping it leaves the VMs running. |
/var/lib/quoro-node | both | Node agent directory, mode 0700, owner root:root: identity.json, credential and the private-key seed (0600), the local store, and the engine state in engine/. |
/etc/quoro/quoro.toml | both | Config, mode 0640, owner root:quoro-api on a primary and root:root on a node: the API reads it, only root writes it. |
/etc/systemd/system/quoro.service | primary | Unit from packaging/systemd/quoro.service, mode 0644. Runs as quoro-api with no capabilities. |
/var/lib/quoro | primary | Control plane directory, mode 0700, owner quoro-api:quoro-api: SQLite database, the cluster key files (0600) and the local node’s join file. |
user quoro-api (group quoro-api) | primary | System account with no home, no login shell and no password, created only when missing. |
/var/lib/quoro and /var/lib/quoro-node are siblings, never nested, so each
service owns its own tree and the unprivileged control plane cannot read the
agent’s state. A quoro user left by an older installer is no longer used; the
installer logs it and leaves it alone.
The config is a starting point, not a secret. On a primary:
# Quoro configuration (ADR 0054 host layout, ADR 0089 privilege split).
# Edit this file and restart quoro: systemctl restart quoro quoro-node
# One TLS listener for API, panel and MCP. Ingress keeps 80/443.
listen = "0.0.0.0:8443"
# SQLite database, cluster key files (0600) and the local join file.
data_dir = "/var/lib/quoro"
# Engine: "cloud-hypervisor" runs one microVM per app; "fake" runs nothing
# and exists for development only.
engine = "cloud-hypervisor"
# Base URL nodes dial, for example "https://quoro.example.com:8443".
# Unset, it defaults to the Host header of the request that created the
# join token, which is right for most installs.
# public_url = "https://<this host>:8443"
# Engine settings. The values below are the defaults, and the block is
# commented out; uncomment a line only to change it. Guest DNS defaults
# to this host's non-loopback resolvers; set dns to override them.
# [cloud_hypervisor]
# artifacts_dir = "/usr/local/lib/quoro"
# init_binary = "/usr/local/lib/quoro/quoro-init"
# state_dir = "/var/lib/quoro-node/engine"
# bridge = "quoro0"
# subnet = "10.42.0.0/24"
# dns = ["192.0.2.53"]
[node]
# The node agent runs as quoro-node.service, separate from the
# unprivileged control plane (ADR 0089).
local = false
On a node:
# Quoro configuration (ADR 0089 privilege split).
# This host is a node: it runs microVMs for its primary and runs no
# control plane. Edit this file and restart with:
# systemctl restart quoro-node
# Engine: "cloud-hypervisor" runs one microVM per app; "fake" runs nothing
# and exists for development only.
engine = "cloud-hypervisor"
# Engine settings. The values below are the defaults, and the block is
# commented out; uncomment a line only to change it. Guest DNS defaults
# to this host's non-loopback resolvers; set dns to override them.
# [cloud_hypervisor]
# artifacts_dir = "/usr/local/lib/quoro"
# init_binary = "/usr/local/lib/quoro/quoro-init"
# state_dir = "/var/lib/quoro-node/engine"
# bridge = "quoro0"
# subnet = "10.42.0.0/24"
# dns = ["192.0.2.53"]
[node]
# The identity, local store and engine state of the agent on this host.
# data_dir = "/var/lib/quoro-node"
An existing config is never rewritten with a new one. When it has no engine
line, the installer logs that Quoro will run the fake engine and how to switch.
On a primary, a config with no [node] table gets [node] and local = false
appended, so the control plane stops trying to run the node agent itself; a
config whose [node] table has no local = false is left alone with a warning,
because quoro serve would then run the agent unprivileged and refuse.
After installing, the script prints the binary’s quoro version output. On a
primary it also prints a one-time setup token from quoro setup-token (single
use, expires after one hour, needed by POST /api/v1/setup, which the panel
serves at https://<host>:8443/ with a self-signed certificate) and this host’s
TLS fingerprint, read from journalctl -u quoro. The token is printed to the
terminal only; it is never written to a file. Get a fresh one any time with:
sudo quoro setup-token
On a node it prints the node id from /var/lib/quoro-node/identity.json; the
node then shows as connected on the primary’s Nodes page.
Back up the cluster key files under /var/lib/quoro separately from the
database (ADR 0050): a database copy does not contain them, and losing the
data-encryption key loses every stored secret.
Privileges (ADR 0089)
Two processes, two privilege levels, and neither can borrow the other’s:
quoro.service(the control plane: API, panel and node sessions) runs asquoro-apiwith an empty capability bounding set andAmbientCapabilities=empty, so no capability can be gained;NoNewPrivileges=yes,ProtectSystem=strictand/var/lib/quoroas its only writable path. Port 8443 needs no capability. It signs desired state and never touches a hypervisor, a bridge or a firewall.quoro-node.service(the node agent) runs as root, because the engine creates the bridge, TAP devices and nftables rules and converts images with ownership preserved. It is the only privileged Quoro process.
The installer never hands /var/lib/quoro to a person: if a quoro-api account
already exists with a uid of 1000 or above, it stops before writing anything.
Hosts installed by the M1 installer have /var/lib/quoro owned by root. The
installer stops quoro.service before it changes that ownership, gives every
file to quoro-api except engine/, and leaves engine/ owned by root: it is
the M1 engine state, which the control plane must not read. The node agent keeps
its engine state in /var/lib/quoro-node/engine from then on, and VMs that M1
created are not adopted.
Idempotency
Re-running the installer is safe:
quoro.tomlis created only when absent; an existing config is kept as is and reported askept existing /etc/quoro/quoro.toml. On a primary the only edit is the appended[node]table when it is missing.- The binary, the units and the engine runtime files are compared with
cmp -sbefore replacement; when unchanged they are reinstalled identically and the services are left running. The connector at/usr/local/lib/quoro/cloudflaredis compared the same way. /var/lib/quoro-nodeis created or confirmed asroot:root0700, never recreated./var/lib/quorois the same forquoro-api:quoro-api0700, and the ownership change is skipped when the directory already has that owner.- A node that is already enrolled is not enrolled again, so no join token is needed on a re-run.
- Each unit is restarted only when the binary changed, its unit changed, an
engine runtime file changed, the connector changed, or it is not active; the
installer then waits up
to 20 s for it to stay active for five consecutive seconds (so a refusal right
after start is caught) and fails with that unit’s
journalctl -u <unit> -n 50hint otherwise. On a primaryquoro.servicestarts beforequoro-node.service, because the control plane writes the join file its own agent enrolls with.
Virtualisation and the engine
The engine needs /dev/kvm and hardware virtualisation (vmx/svm), and it writes
/proc/sys/net/ipv4/ip_forward. It runs inside quoro-node.service, which is
root because it manages a bridge, TAP devices and nftables rules; the unit keeps
hardening that the engine tolerates (NoNewPrivileges, ProtectHome,
PrivateTmp, ProtectSystem=full). When the host cannot run the engine,
quoro node exits 78 and the unit stays failed (RestartPreventExitStatus=78)
instead of restarting in a loop; read journalctl -u quoro-node.
KillMode=process is deliberate: stopping or restarting the agent does not stop
the VMs, and the next agent adopts them from its engine state.
Uninstall
A primary runs both units, a node only the agent. Stop and remove what the host has:
sudo systemctl disable --now quoro-node
sudo systemctl disable --now quoro # primary only
sudo rm -f /etc/systemd/system/quoro-node.service
sudo rm -f /etc/systemd/system/quoro.service # primary only
sudo systemctl daemon-reload
sudo rm /usr/local/bin/quoro
sudo rm -rf /usr/local/lib/quoro
systemctl stop quoro-node leaves every VM running on purpose
(KillMode=process): stopping the agent is not stopping the workloads, and the
next agent adopts them. To stop the VMs as well, stop them from the primary
first, or stop the hypervisors directly:
sudo pkill -f /usr/local/lib/quoro/cloud-hypervisor
The engine leaves its network devices behind; remove them too:
sudo ip link delete quoro0
sudo nft delete table ip quoro_quoro0
The table is named quoro_<bridge>, so quoro0 (the default bridge) gives
quoro_quoro0.
/var/lib/quoro-node (identity, credential, private key and engine state) and
/etc/quoro (config) are kept on purpose on both roles, and /var/lib/quoro
(database and cluster key files) is kept on a primary: remove them deliberately
if that is what you want, and back up the key files under /var/lib/quoro first.
Stop the VMs before deleting a node’s directory, otherwise the hypervisors keep
running without their state:
sudo pkill -f /usr/local/lib/quoro/cloud-hypervisor
sudo rm -rf /var/lib/quoro-node
A node that is removed this way holds a credential the primary still knows about; remove the node on the primary too. On a primary the installer’s system account goes with it:
sudo userdel quoro-api
Hosts installed by an older installer also have a quoro system user that this
installer no longer creates or uses; remove it, after the services are stopped,
with:
sudo userdel quoro
Quoro: Product Requirements (v2)
Status: draft for review. Source of truth: the decision ledger; where this document and an accepted decision disagree, the decision wins.
1. Summary
Quoro is one self-hosted tool that unifies a hypervisor with application deployment [D-0002]. One engine, built on Cloud Hypervisor and KVM, runs one microVM per app, and the same interface deploys, exposes and observes those apps: push-to-deploy, domains, TLS, logs [D-0002, D-0008]. The agent is not an add-on: agents are first-class operators through MCP and events from day one, reading the same state as the panel, acting through the same API and being logged the same way [D-0002].
Quoro starts as a self-use and experimenter tool: anyone can install it on a box they have and experiment [D-0003]. Enterprise and managed offerings come later [D-0003].
The core is AGPL-3.0. Enterprise code, when it exists, lives in ee/ under a separate license, and a CLA is required from the first external contributor [D-0004].
2. Problem
A self-hoster who wants both virtual machines and application deploys today stacks two tools: Proxmox for the hypervisor, then a Debian VM inside it running Dokploy or Coolify for the apps [D-0002]. The result is two dashboards, two mental models and two upgrade paths, and neither of them was designed with agents in mind [D-0002].
The tools that do cover both layers are Kubernetes-heavy and aimed at hosting providers rather than at one person with a few machines: Harvester with Rancher, and Cozystack [D-0002, F-0006]. They solve a different problem at a different cost.
What is missing is a single tool that owns the VM layer and the deploy layer, keeps the operational model small, and treats an AI agent as an ordinary, auditable operator rather than as an external script with root [D-0002, D-0029, D-0031].
3. Users and personas
Primary user now: the self-use / experimenter. Someone who has a machine or a VPS, installs Quoro, and tries to run a real app on it [D-0003].
Three personas follow from that [D-0003]:
(a) Indie developer / advanced homelabber. Runs a home server, often behind NAT, and wants deploys, domains and TLS without opening ports or fighting certificates. For this user the ingress answer is Cloudflare Tunnel [D-0024].
(b) Small startup on its own hardware or VPS. Has a public IP and wants a Railway/Vercel-like experience on infrastructure it controls [D-0003]. For this user the ingress answer is a reverse proxy with ACME [D-0024]. They care about reliability: app replicas for stateless apps, a warm copy of stateful data, and an honest statement of what each policy costs [D-0020, D-0021].
(c) Team already operating with AI agents. Already has agents doing operational work and wants those agents to act on infrastructure with scoped permissions, human approval for destructive steps, and a complete audit trail [D-0029, D-0030, D-0031].
Enterprise and managed customers are explicitly later [D-0003]. Nothing in the MVP is designed around them.
4. Goals
- One interface for VMs and app deploys. Create and manage VMs, and deploy services with push-to-deploy, domains, TLS and logs, from the same place [D-0002].
- Agents as first-class operators from day one. MCP and events are part of the product, not a later integration; the agent reads the same state, uses the same API and is logged the same way as a human [D-0002, D-0029, D-0030, D-0031, D-0032].
- Apps keep running when the control plane dies. Nodes are autonomous, store their desired state locally and reconcile it; they dial out to the control plane and never the reverse [D-0010, D-0011].
- One binary, one-command install. API, panel and engine ship in the same binary; the installer is a single command, and
POST /api/v1/setuprequires a one-time setup token [D-0012, D-0054, D-0033]. - Works across VPS from different providers over the internet. Independent nodes, not a tightly coupled cluster that needs low-latency links [D-0010].
- Honest, explicit reliability trade-offs per app. Each app carries an HA policy with its cost shown, and the system states when it cannot promise something [D-0020, D-0021].
5. Non-goals (for now)
The following are out of scope and each of them could eat the project [D-0034]:
- Live migration of running VMs [D-0034].
- Distributed storage such as Ceph [D-0034].
- Complex networking: SDN, VLANs [D-0034].
- Multi-tenancy with billing [D-0034].
- Writing our own VMM [D-0034, D-0007].
Also out of scope for now:
- Docker on nodes. Apps run in microVMs, not containers on the host [D-0008].
- Running our own image registry. A curated catalog in the repo points at pinned upstream images instead [D-0026].
- A container fallback engine in the MVP. That is a later
Engineimplementation [D-0009]. - Automatic failover before M6 [D-0016, D-0033].
- Persistent app data before M5a: local, non-replicated volumes ship in M5a [D-0052]; until then a deployed app has no persistent disk.
6. Product principles
Agent-operable. The agent is one more operator. It reads the same state as the panel, acts through the same service layer and API, and appears in the same audit log. MCP has no separate path and no separate permissions model [D-0002, D-0031].
Least privilege, never forever. Every agent action runs inside a grant: a scope, a resource selector and a TTL, optionally a maximum number of uses. Destructive or irreversible actions are off by default and need a human just-in-time approval that issues a short-lived extra grant [D-0030].
Everything auditable. The audit log is append-only and hash-chained, can be copied off-site to object storage with object lock, which ships in M5a [D-0031, D-0053], and records who acted, on whose behalf, triggered by what, through which tool or endpoint, with which parameters, with which result, and with the agent’s stated justification [D-0031].
Minimal, auditable dependencies. Rust for everything that touches infrastructure, with every dependency justified [D-0056]. We write our own engine on Cloud Hypervisor and KVM rather than depending on Incus or writing a VMM; the engine sits behind an Engine trait so other backends stay possible [D-0007].
Reliability honesty. The data plane never depends on the panel [D-0011]. HA needs at least three votes, and Quoro says so instead of pretending a two-node setup is safe [D-0017]. The panel shows the cost of each HA policy [D-0020] and an N+1 capacity indicator [D-0021]. Failback is never automatic [D-0022].
Self-hosted for real. The static panel can be hosted anywhere, but the stateful API stays self-hosted [D-0012]. There is no vendor lock-in: DNS, LB and object storage sit behind provider interfaces, and Cloudflare is the first implementation, not the only one [D-0025].
Supply-chain safety. Binaries are signed and builds reproducible, because the installer is a pipe to a shell and the tool controls infrastructure [D-0027]. The catalog pins upstream images with verifiable signatures [D-0026], and its format, owner, location and signature scheme are decided at M4 planning, before the first template ships [D-0070].
7. Feature set mapped to milestones
Every milestone below is exactly as recorded in the roadmap decision; nothing is moved between milestones [D-0033].
| Milestone | Features | Key decisions |
|---|---|---|
| M0 | Contracts: repo, CI, cargo workspace, OpenAPI spec, DB schema, event schema, node-agent protocol including self-fencing; spike “boot a microVM from an OCI image” | [D-0033, D-0019, D-0056] |
| M1 | Skeleton: one-command installer, engine create/start/stop/delete, basic panel | [D-0033] |
| M2 | Deploy: node agent, deploy an OCI image with domain and TLS, live logs | [D-0033, D-0024] |
| M3 | AI-native: MCP read plus restart/redeploy, scoped grants, audit log, event webhooks | [D-0033, D-0029, D-0030, D-0031, D-0032] |
| M4 | Full experience: Git builds (Dockerfile only, in an ephemeral BuildKit builder microVM), metrics, web terminal, VM console | [D-0033, D-0060] |
| M5a | Stateless app replicas + edge LB + backups to S3/R2 (cold) + local non-replicated volumes + off-site audit-log copy | [D-0033, D-0020, D-0023, D-0052, D-0053] |
| M5b | Witness, leases, fencing, replicated volumes (warm), capacity indicator | [D-0033, D-0017, D-0019, D-0020, D-0021] |
| M6 | Automatic failover + hot | [D-0033, D-0016, D-0020] |
HA and backups are out of the MVP, but the MVP architecture prepares them [D-0033]. For example, self-fencing is part of the node-agent protocol from M0 so it is not redesigned later [D-0019].
M3 tags v0.1.0 as an experimental preview with signed builds, not for production; M4 complete is the first general release and the broad announcement [D-0064]. The domain and trademark checks (Q-0008) must be done before the M3 tag [D-0064].
Definitions of Done for each milestone live in docs/product/roadmap.md.
8. The AI-native loop
The product is built around one loop: detect, notify, wake, act [D-0032].
Detect. Each node agent plus health checks report state and metrics [D-0032]. Because telemetry stays on the node and is queried through the primary on demand, detection does not depend on shipping logs to a central place [D-0014].
Notify. Webhooks are configurable per event. Examples from the decision: VM down, build failed, disk full, certificate expiring [D-0032].
Wake. The webhook starts an agent, for example one built on the Claude Agent SDK, with Quoro’s MCP server and the incident context attached [D-0032]. The reference runner lives in examples/agent-runner/, outside the quoro binary: it verifies the webhook signature, holds the agent’s own key and acts through MCP within the incident grant [D-0049]. The agent is a registered identity with a name, a model and an owner [D-0029].
Act. The agent can do only what its grant allows. A destructive step requires human approval in the panel, after which the agent receives a short-lived extra grant [D-0030]. Every call lands in the audit log through the same service layer a human uses [D-0031].
Worked example
The demo that explains the product in one sentence: “I told an agent the app was down, it restarted it, and it is in the audit log” [D-0033].
Grant: app:restart on project=blog, TTL one hour [D-0030]. Note that this is an incident-scoped grant: when an event woke the agent it received only the scopes needed, and the grant expires on its own [D-0030].
The resulting audit entry contains [D-0031]:
| Field | In this example |
|---|---|
| Who | the agent identity (name, model) [D-0029] |
| On behalf of whom | the human who authorized the agent [D-0029] |
| Triggering event | the “VM down” webhook that woke it [D-0032] |
| Tool / endpoint | the MCP tool or API endpoint for restart [D-0031] |
| Parameters | project=blog, target app |
| Result | success with the new app state |
| Agent’s stated justification | free text the agent supplied [D-0031] |
Revoking the grant locks the agent out within seconds, and because the log is hash-chained any later tampering with the entry is evident [D-0030, D-0031].
9. Reliability, HA and the witness
9.1 Architecture recap
Nodes are independent and orchestrated by Quoro, not clustered Incus-style, so that a deployment can span VPS from different providers over the internet [D-0010]. Nodes are autonomous and dial out to the control plane; each stores its desired state locally and reconciles it, so apps keep running and self-restart if the control plane disappears [D-0011].
Every node runs the same binary containing API, panel and engine. Roles are node, witness and edge, and they are combinable [D-0012]. Exactly one node is the primary: the single source of truth for state. The other nodes are replica nodes: they hold a copy, serve reads and forward writes to the primary [D-0012]. State moves as a continuous, append-only, numbered, hash-chained log; a replica node asks for everything after N and verifies the chain, which makes tampering evident [D-0013].
Telemetry — app logs and metrics — stays on each node and is not replicated; the panel queries it through the primary on demand, with optional archive to object storage [D-0014].
9.2 Leader election roadmap
Single node in the MVP; manual promotion of a replica node to primary ships in M5b, alongside leases and fencing [D-0016, D-0033, D-0067]; automatic failover with Raft and at least three votes later, in M6 [D-0016, D-0033]. Manual promotion means a command or a button, or an agent doing it with human approval [D-0016]. Automatic election with only two nodes causes split-brain, because after a network cut both sides can believe they are primary [D-0016].
9.3 Why three votes, and the witness
Majority voting needs an odd number of voters. With two nodes Quoro tells the user that automatic failover is not safe, and offers a witness [D-0017]. The witness only breaks ties and stores no data [D-0017].
Witness options [D-0017]:
- Self-hosted lightweight daemon, free, on any machine in a different failure domain. The failure domain matters: a home PC sitting next to node A fails together with node A and is not a witness.
- Optional external lease arbiter: a Cloudflare Durable Object (single-threaded actor with strongly consistent storage and alarms; needs a Cloudflare account and a deployed Worker, and fencing tokens are still required) or S3/R2 conditional writes (atomic
If-Match/If-None-MatchPUT with one winner, 1 write/s per key, no TTL and fencing tokens required; seedocs/research/durable-objects.mdanddocs/research/r2-conditional-writes.md).
Free-witness rule. The self-hosted witness is free and first-class [D-0017]. A user with two nodes, a self-hosted witness and nothing else must be able to run a correct HA setup, and the panel must not hint otherwise.
9.4 Edge as the third voter
The edge/LB can be the third voter by combining the witness and edge roles in the same binary [D-0018]. It sits in a third location, sees both nodes, and decides where traffic goes, so its health view is the one users actually experience [D-0018]. The natural shape is two VPS plus one small LB VPS [D-0018].
Caveat: a single edge is a traffic single point of failure [D-0018]. Mitigations are Cloudflare as the traffic edge with the small VPS acting only as witness, or two edges with DNS failover [D-0018].
9.5 Fencing
Fencing is mandatory and lease-based, and the node agent self-fences from M0 [D-0019]. A lease is a node’s right to run a stateful app. expiring leases (warm/hot) end when they are not renewed in time; local leases (none/cold) end only by an explicit revoke or an operator declaring the node lost [D-0047]. When the arbiter is unreachable an expiring lease first becomes uncertain (keep running, start nothing new) and only then expired (self-fence); see docs/contracts/node-agent-protocol.md §8. The reason is concrete: starting a stateful app on node B while it still runs, isolated, on node A produces two diverging copies and therefore data corruption [D-0019]. The same lease mechanism serves both primary election and per-app failover, which is why it has to be in the node-agent protocol from the beginning instead of being retrofitted later [D-0019].
9.6 HA policies
Each app carries one policy, and the panel shows what it costs [D-0020].
| Policy | What exists on other nodes | RPO | RTO | Cost |
|---|---|---|---|---|
none | Nothing | Not applicable | Not applicable | No reserved resources [D-0068] |
cold | Backups only | Since the last backup | Restore time | Backup storage and restore time [D-0023, D-0068] |
warm | Data continuously replicated, image pulled, instance stopped | Seconds | MicroVM boot | Disk |
hot | Instance running and replicating | Near zero | Near zero | Disk + RAM + CPU |
Replication is asynchronous by default; synchronous is optional for nearby nodes and adds latency to every write [D-0020].
Suggested defaults [D-0020]: stateless apps get app replicas behind the edge LB when there are at least two nodes; stateful apps get warm. For stateless apps, the edge LB stops sending traffic to the dead node, so the user sees zero downtime [D-0020].
The warm RTO is the microVM boot time. Cloud Hypervisor’s own claim of a boot “under 100 ms” is unbenchmarked and stays a Hypothesis (see docs/research/cloud-hypervisor.md); the design assumes fast boot makes warm nearly as good as hot [D-0008].
The N+1 capacity indicator answers a different question in plain language: “if node A dies, do its apps fit on the others?” For example, “yes”, or “no, you are missing 6 GB of RAM” [D-0021]. No self-hosted tool does this well today, and it is an ideal signal for an agent to watch [D-0021].
9.7 Sync engine
The sync engine is ours: control-plane log replication plus a content-addressed chunk engine for volumes [D-0023]. A volume is snapshotted, split into content-addressed chunks — deduplicated and encrypted — and only new chunks are shipped; to another node this is warm replication, to S3 or R2 it is a cold backup [D-0023]. The first implementation uses incremental snapshots every few seconds, so RPO equals the interval [D-0023].
Databases are the exception: they use their native replication orchestrated by Quoro — Postgres streaming for Postgres — offered as catalog templates, because block-level copies of a running database are only crash-consistent [D-0023]. SQLite databases and catalog templates are backed up by Quoro’s own chunk engine with client-side encryption, taken from consistent snapshots (the SQLite backup API or VACUUM INTO), not by Litestream, whose 0.3 line is unmaintained; Litestream with SSE-C may be added later as an optional backend for a lower RPO (see docs/research/rust-api-and-mcp-tooling.md) [D-0069].
The snapshot primitive underneath the chunk engine is, for now, reflink snapshots plus a full content-defined chunk scan for M5a cold backups; a spike comparing lvm-thin and btrfs before M5b chooses the primitive for warm, and the Engine trait’s snapshot_volume hooks keep it swappable [D-0062].
9.8 Failback and planned switchover
Failback is never automatic or immediate [D-0022]. When node A comes back it does not have the data B collected while A was down, and A may also hold writes B never received, so there are two histories [D-0022].
The sequence [D-0022]:
- A returns fenced, checks its lease, and starts nothing stateful.
- A rejoins following B, which is now the source of truth: for control-plane state A becomes a replica node, and for each stateful app A holds the
warmcopy while B is the lease holder. - A’s divergent writes are quarantined in a separate snapshot and recorded in the audit log. They are never silently dropped.
- Incremental resync from the last common point; A becomes
warm. - Default: no failback. The roles simply swap and B stays the lease holder.
- If A is preferred, the operator runs a planned switchover (“relevo inteligente”): wait for replication lag near zero, wait for a low-traffic window chosen by the user or proposed by an agent with approval, freeze writes for seconds, run the final sync, move the lease, shift traffic at the LB, drain connections.
A planned switchover loses no data. Data loss of seconds happens only in surprise failures [D-0022]. For stateless apps the LB slow-starts traffic back to A [D-0022].
10. Ingress and providers
Two ingress paths cover the two main users [D-0024]: a reverse proxy with ACME for hosts with a public IP, and Cloudflare Tunnel for homelabs behind NAT, which avoids opening ports and fighting TLS.
The proxy is an embedded Rust reverse proxy — hyper, rustls and an ACME client crate — behind the ingress interface that the Cloudflare Tunnel backend also implements; Caddy driven through its admin API stays as a fallback backend if the M2 spike shows ACME is too costly [D-0059]. The maturity of the ACME crate is a research task before M2 [D-0059]. Both sit behind a common interface [D-0024].
Multiple cloudflared replicas on the same tunnel give host-level failover only; whether they expose any app health check is a Hypothesis to be confirmed, and per-node failover needs one tunnel per node behind a load balancer. Cloudflare Load Balancing is a paid add-on from $5/month, its HTTPS monitors with the app hostname test the app, there are no TCP monitors for tunnels, and an LB sees all replicas of one tunnel as one endpoint; see docs/research/cloudflare-lb-and-tunnel.md [D-0024].
Providers sit behind interfaces: DNS, load balancing and S3-compatible storage, with Cloudflare as the first implementation and not the only one [D-0025]. Backups can target any S3-compatible store, including R2, B2 and MinIO [D-0025].
11. Platform requirements
The MVP requires a KVM-capable host: bare metal, or a VPS that exposes nested virtualization [D-0009]. Many VPS do not expose nested virtualization, which is exactly why the requirement is stated up front rather than discovered at install time [D-0009]. A container fallback is a later Engine implementation and is more work with weaker isolation, so it is not a way around the requirement [D-0009].
Nested virtualization is the exception on cheap VPS: it is not available on Hetzner Cloud, Hostinger, Contabo VPS or netcup VPS, while it is available (some opt-in) on AWS 7i/8i families, GCP Intel, Azure Dv3/Dv4, OCI Intel VMs, OVHcloud Public Cloud, Contabo VDS, Vultr VX1 and DigitalOcean. Bare metal is the reliable path, and the installer must always probe /dev/kvm with a real microVM boot rather than trusting the provider’s documentation; see docs/research/vps-nested-virt.md.
Cloud Hypervisor is v54.0 under Apache-2.0 AND BSD-3-Clause, driven through an HTTP/JSON API over a UNIX socket, and the host kernel must be 6.8 or newer; snapshots hold RAM and device state but not disks, with no incremental API, virtio-fs needs an external virtiofsd, and consoles are text-only (see docs/research/cloud-hypervisor.md). The console is therefore a text console over the microVM’s serial or virtio-console, shown with xterm.js (Cloud Hypervisor has no VNC or framebuffer); consoles are text-only and there is no noVNC in the stack [D-0056].
12. Managed offering
An optional managed offering may come later [D-0003]; nothing in it is designed yet and no feature in this document depends on it. The self-hosted witness is free and first-class [D-0017].
13. Competitive landscape
Approximate stacks; verify before any pitch [F-0006].
| Product | Layer | Stack (approx.) | Gap Quoro addresses |
|---|---|---|---|
| Proxmox VE | Hypervisor | Perl + Rust, ExtJS UI | No app deploy, no modern UI, no official MCP [F-0006] |
| Incus | Hypervisor | Go, React UI | No app deploy, no official MCP [F-0006] |
| Openship | Deploy | Bun/Hono, Next.js | No own VMs, no agent-native design [F-0006] |
| Coolify / Dokploy | Deploy | PHP / TypeScript | No own VMs, no agent-native design [F-0006] |
| Harvester + Rancher | Both layers | Kubernetes + KubeVirt | Heavy, hosting-provider focused [F-0006] |
| Cozystack | Both layers | Kubernetes + KubeVirt | Heavy, hosting-provider focused [F-0006] |
| Ubicloud | Both layers | Ruby | Aims to be a public cloud alternative [F-0006] |
Proxmox and Incus MCPs exist, but they are small community projects [F-0006].
Where Quoro sits: it covers both layers — hypervisor and app deploy — without Kubernetes, and it is agent-native by design rather than by integration [D-0002, F-0006]. The closest shape is “Proxmox plus Dokploy in one tool”, with a safety model for agents built into the core [D-0002, D-0029].
14. Risks
| Risk | Why it matters | Mitigation |
|---|---|---|
| Scope creep from HA, storage and networking | Each area could consume the project on its own [F-0007] | Milestones with fixed contents [D-0033]; HA out of the MVP; explicit non-goals [D-0034] |
| Rust iteration speed for CRUD and auth | Slower product iteration than starting those parts in TypeScript [F-0007] | Open, to be settled in the architecture doc; the original “Rust for infra, TS for the rest” position was later replaced by Rust and minimal dependencies everywhere [F-0007, D-0056] |
| Engine integration maintenance | An external engine brings ongoing integration and upgrade work [F-0007] | The engine sits behind an Engine trait, so backends can be swapped or added [D-0007] |
| Agents with excess permissions deleting real infrastructure | An over-scoped agent can destroy production | Scoped TTL grants, JIT approval for destructive actions, audit log, and microVM isolation [D-0008, D-0030, D-0031] |
| Two-node split-brain | Both nodes can believe they are primary after a network cut [D-0016] | No automatic election below three votes; Quoro tells the user and offers a witness [D-0016, D-0017] |
| Dependency on KVM-capable hosts | Many VPS do not expose nested virtualization [D-0009] | State the requirement up front and probe /dev/kvm with a real microVM boot rather than trusting provider docs (docs/research/vps-nested-virt.md); container fallback deferred [D-0009] |
15. Open questions
All of the following are open. This document does not choose between the options.
| Id | Question | Options (if known) |
|---|---|---|
| Q-0008 | Domain registration and trademark check (IMPI/USPTO) | Before the M3 v0.1.0 tag (D-0064) |
The ingress proxy, Git build method, OCI-to-rootfs conversion and init, snapshot primitive, node mesh, first public release and manual promotion of a replica node are decided in D-0059 to D-0064 and D-0067; docs-site timing in D-0065, the HA policy costs in D-0068, encrypted SQLite and template backups in D-0069, and when the curated catalog is decided in D-0070.
Realtime transport is decided: SSE carries one-way streams (event stream, live logs, deployment progress) and WebSockets carry bidirectional sessions (terminal, VM console); see docs/architecture/architecture.md §13 [D-0057].
Answered verification questions
| Id | Topic | Answer in |
|---|---|---|
| Q-0010 | Cloud Hypervisor license, REST API surface, boot time, snapshot/restore, virtio-fs, console | docs/research/cloud-hypervisor.md |
| Q-0011 | Cloudflare R2 conditional writes usable as a lease arbiter | docs/research/r2-conditional-writes.md |
| Q-0012 | Cloudflare Durable Objects as external lease arbiter (consistency, cost) | docs/research/durable-objects.md |
| Q-0013 | Cloudflare Load Balancing pricing and health checks; cloudflared multi-replica failover | docs/research/cloudflare-lb-and-tunnel.md |
| Q-0014 | rmcp maturity; Litestream replication to R2; utoipa + orval/openapi-ts pipeline | docs/research/rust-api-and-mcp-tooling.md |
| Q-0015 | Which common VPS providers expose nested virtualization / KVM | docs/research/vps-nested-virt.md |
16. References
docs/product/roadmap.md— milestones and Definitions of Done.docs/glossary.md— terms such as primary, replica node, app replica, lease holder, HA policies and node roles.- Decision ledger (private) — the source of truth for every id cited here.
Quoro roadmap
Status: draft for review. Source: D-0033 (milestones), D-0034 (out of scope), D-0036 (done = runs on the lab).
This turns D-0033 into a gated plan and adds no features: where another decision explains how an item behaves, the decision is cited next to the item, and every claim that rests on an unverified external fact is either answered in docs/research/ or marked as a hypothesis.
Release callout: first public release (D-0064). M3 tags v0.1.0 as an experimental preview with signed builds, not for production; M4 complete is the first general release and the broad announcement [D-0064]. The domain and trademark checks (Q-0008) come before the M3 tag [D-0064].
1. How to read this roadmap
Gating rules
- A milestone starts only after the previous milestone’s demo ran on the lab and the maintainer approved it [D-0036].
- “Done” means it runs on the lab, not that it compiles [D-0036].
- Contracts (OpenAPI, DB schema, event schema, node-agent protocol, audit-log format, permission model, Engine trait) freeze at the end of M0. After that, a contract change is a standalone PR, never mixed into feature work [D-0036].
- Every PR closes a GitHub issue, and issues plus milestones are the task source of truth [D-0036].
- Every Definition of Done below is a functional check a person can run over SSH on the lab VMs. No performance figures appear here because the lab is nested virtualization and is not valid for benchmarks.
The lab
The test lab is three nested-virtualization Debian 13 VMs (lab-1, lab-2 and lab-3) on a single physical host, so they share one failure domain; it is not valid for performance benchmarks. Each VM is 2 vCPU / 4 GB RAM / 30 GB on a local disk, with CPU type host and nested KVM. Every agent and contributor tests over SSH.
All three VMs sit on one physical host and therefore share a failure domain. The lab can simulate separate failure domains by stopping VMs or cutting the network between them; the real requirement that a witness sits in a different failure domain than the nodes [D-0017] is documented by the lab, not proven by it.
Workstreams
After M0 the work splits into workstreams, each in its own crate or package to keep merges small: engine (Cloud Hypervisor, OCI to rootfs, disks and snapshots, networking, console), control-plane (Axum, auth, services, OpenAPI), panel (React against a mock generated from OpenAPI), node-agent (protocol, local reconciliation, self-fencing), ingress (proxy plus Cloudflare Tunnel behind one interface), mcp (rmcp over the same service layer as the API), and installer/releases/docs site (see docs/architecture/architecture.md §17). The later workstreams are the sync/chunk engine and witness/edge (see docs/architecture/architecture.md §17).
Where HA sits
High availability and backups are not part of the MVP, but the MVP architecture prepares them [D-0033]: the node-agent protocol carries self-fencing from M0 so it is not redesigned later [D-0019], the control plane replicates an append-only numbered hash-chained log from the start [D-0013], and one sync engine serves both node replication and backups [D-0023].
2. Overview
| Milestone | Name | One-line goal | Depends on |
|---|---|---|---|
| M0 | Contracts | Freeze the contracts and prove a microVM boots from an OCI image | — |
| M1 | Skeleton | One-command install, engine lifecycle, basic panel | M0 |
| M2 | Deploy | Deploy an OCI image with a domain and TLS; nodes survive control-plane loss | M1 |
| M3 | AI-native | Agents act through MCP under scoped grants, everything audited | M2 |
| M4 | Full experience | Git builds, metrics, web terminal, VM console | M3 |
| M5a | Stateless HA and backups | App replicas behind the edge, cold backups to object storage | M4 |
| M5b | Witness, leases and warm replication | Three votes, lease-based fencing, warm volumes, capacity indicator | M5a |
| M6 | Automatic failover and hot | Raft election with a majority, hot policy keeps apps serving | M5b |
3. Milestones
M0 — Contracts
Scope
- Repository, CI and cargo workspace, plus the OpenAPI spec, DB schema and event schema [D-0033].
- Node-agent protocol, including self-fencing [D-0019]. A lease is a node’s right to run a stateful app.
expiringleases (warm/hot) end when they are not renewed in time;localleases (none/cold) end only by an explicit revoke or an operator declaring the node lost [D-0047]. When the arbiter is unreachable anexpiringlease first becomesuncertain(keep running, start nothing new) and only thenexpired(self-fence); see docs/contracts/node-agent-protocol.md §8. - The spike “boot a microVM from an OCI image” [D-0033], on lab-1, using Cloud Hypervisor and KVM [D-0007].
- The bases documents: PRD, ADRs, architecture, contracts, threat model, roadmap, workstream briefs, glossary, OSS files [D-0040].
- The Engine trait, so other backends can be added later without rewriting the control plane [D-0007].
External facts behind this milestone are verified in the research files. Cloud Hypervisor: v54.0, Apache-2.0 AND BSD-3-Clause, an HTTP API over a UNIX socket, snapshots that hold RAM and device state but not disks, virtio-fs that needs an external virtiofsd, text consoles only (serial, virtio-console; no VNC or framebuffer) and a host kernel of 6.8 or later (docs/research/cloud-hypervisor.md); boot under 100 ms remains a Hypothesis. The OCI-to-rootfs conversion and the minimal init are ours: a minimal Rust init and our own OCI-to-ext4 pipeline, with layer paths resolved inside the rootfs (openat2 with RESOLVE_IN_ROOT), zstd layers, and config delivered through a config disk or the kernel command line [D-0061]. utoipa 6 with orval or openapi-ts generates usable client code from the spec, with one known orval break (docs/research/rust-api-and-mcp-tooling.md).
Definition of Done
- On lab-1, a clean clone builds the whole workspace and the CI validation script (OpenAPI, DB schema, event schema, node-agent protocol) exits 0. The same validation runs in CI.
- Without KVM, two tests pass against an in-process primary and a fake engine: revoking a
locallease fences its VM, and a test-only shortexpiringlease expires and fences within the bound in node-agent-protocol.md §8.4 (both tests are required by §8.7). - The node’s lease state machine implements
held->uncertain->expiredandrevokedas in node-agent-protocol.md §8.4;uncertainnever stops a stateful app. - On lab-1, the spike boots an OCI image converted to a root disk inside a Cloud Hypervisor microVM and the guest’s serial console prints the expected marker [D-0033].
- On lab-1, the contracts directory contains OpenAPI, DB schema, event schema, node-agent protocol, audit-log format, permission model and Engine trait, each listed in the contracts index [D-0040].
- Every contract element tied to an accepted decision is written as decided; the only remaining open items are listed in the contracts README and none blocks the freeze.
Demo script
- On lab-1, run the workspace build and the contract validation. Expected: exit code 0 with each schema reported as valid.
- On lab-1, run the OCI spike: convert the image, start the microVM, attach to the serial console. Expected: the guest boots and prints the marker line.
- Run the lease tests from the node-agent protocol. Expected: both pass — revoking a
locallease fences its VM, and a test-only shortexpiringlease expires and fences within the bound in node-agent-protocol.md §8.4. - Show the contracts index and confirm that contracts freeze at the end of M0. Expected: the maintainer approves the freeze.
Dependencies
- Nothing precedes M0. The rootfs pipeline (
mkfs.ext4 -dplus an OCI registry client) is verified by the M0 spike. - Q-0003 (rootfs conversion and minimal init) is answered by the M0 spike: our own conversion pipeline and init, validated there [D-0061].
- Realtime transport, lease modes, text-only consoles, the OpenAPI source of truth, provider credentials, cluster keys and the host layout with the one-time setup token are decided [D-0057, D-0047, D-0056, D-0048, D-0051, D-0050, D-0054]. Custody of the cluster keys on promotion stays open and is decided with promotion in M5b [D-0067]; it does not block the freeze.
- Contracts freeze at the end of M0; any later change is a standalone PR [D-0036].
Explicitly out of scope
- Any deploy flow: no node agent talking to a control plane, no domain, no TLS. That is M2.
- Any MCP work: agents appear in M3.
- CI running on a self-hosted runner: deferred, because a self-hosted runner would let fork PRs on a public repository run arbitrary code on the project’s infrastructure.
M1 — Skeleton
Scope
- One-command installer [D-0033].
- Engine lifecycle: create, start, stop and delete a VM [D-0033].
- Basic panel [D-0033].
- Signed release binaries and a reproducible build, because the installer is
curl | shand the tool controls infrastructure [D-0027]. - The panel is built against a mock generated from the frozen OpenAPI spec (see docs/architecture/architecture.md §17).
Definition of Done
- On a freshly reinstalled lab-1, the one-command install completes and
quoroanswers a version command. - On lab-1, creating a VM through the API returns an id, starting it makes it appear as running, and stopping then deleting it leaves no VM behind.
- On lab-1, the same create/start/stop/delete sequence works from the panel in a browser, with the state shown in the list after each step.
- On lab-2, the release binary’s signature verifies against the published signer identity and the Sigstore trust root [D-0027, D-0081].
- On lab-2, rebuilding from the same source produces a byte-identical artifact to the published one [D-0027].
Demo script
- Reinstall lab-1 from the base image, log in over SSH, run the one-command installer. Expected: it finishes without manual steps and prints the version.
- Open the panel, create a VM, start it, watch it become running. Expected: the list shows the new state.
- Stop the VM and delete it. Expected: the list is empty again.
- On lab-2, verify the signature of the downloaded binary and rebuild from source. Expected: signature valid, hashes match.
Dependencies
- M0 contracts, frozen.
- D-0027 shapes the release step.
- Cloud Hypervisor’s HTTP API over a UNIX socket is the engine’s start/stop path (docs/research/cloud-hypervisor.md).
Explicitly out of scope
- Deploying an application image with a domain and TLS: M2.
- Live logs: M2.
- Any HA behaviour: M5a onwards.
M2 — Deploy
Scope
- Node agent: it stores desired state locally and reconciles it, dialing out to the control plane and never being dialed into [D-0011].
- Deploy an OCI image with a domain and TLS [D-0033].
- Live logs in the panel, streamed over SSE [D-0033, D-0057].
- Ingress: a reverse proxy with ACME for hosts that have a public IP, or Cloudflare Tunnel for homelabs behind NAT, both behind one interface [D-0024]. The lab has no public IP, so ACME HTTP-01 cannot run there; lab TLS goes through Cloudflare Tunnel, with ACME exercised separately through DNS-01 [D-0055].
- The control plane stays the single writer; on a single node that node is the control-plane primary [D-0012].
The ingress backend is an embedded Rust reverse proxy — hyper, rustls and an ACME client crate — behind the ingress interface that the Cloudflare Tunnel backend also implements; Caddy driven through its admin API stays as a fallback backend if the M2 spike shows ACME is too costly [D-0059]. The maturity of the ACME crate is a research task before M2 (Hypothesis until it is verified) [D-0059]. The node mesh is not needed here: the control-plane session is TLS dial-out (D-0011), and the mesh is first needed in M5a for cross-node replica traffic [D-0063]. Cloudflare Tunnel behaviour: the load balancer sees all replicas of one tunnel as one endpoint and cloudflared replicas have no app health check, so per-node failover needs one tunnel per node behind a load balancer (docs/research/cloudflare-lb-and-tunnel.md).
Definition of Done
- Roles: lab-1 runs the control plane (control-plane primary); lab-2 runs a node agent that dials out to lab-1 [D-0011, D-0012].
- On lab-2, deploying an image through the panel yields an app that answers HTTP 200 on its domain.
- On lab-2, the app is served over TLS for its domain through Cloudflare Tunnel with a test domain the maintainer controls [D-0024, D-0055].
- On the lab, the ACME path issues a certificate through DNS-01 against a test zone (separate ingress task) [D-0055].
- On lab-2, the panel’s live log view shows new lines within the same session while the app writes to stdout; live logs are streamed over SSE [D-0057].
- Stop the control plane on lab-1; the app on lab-2 keeps answering HTTP 200 and the node agent keeps reporting its desired state locally [D-0011].
- On lab-2, kill the app’s process while the control plane on lab-1 is still stopped; the node agent restarts it and the domain answers 200 again [D-0011].
Demo script
- On lab-2, install Quoro and let the node agent connect out to the control plane on lab-1. Expected: the node appears as connected in the panel.
- Deploy an OCI image with a domain assigned; then open live logs and trigger a request. Expected: the app answers 200 on that domain with a valid certificate, and the request line appears in the log view.
- Stop the control plane on lab-1. Expected: the app on lab-2 still answers 200.
- Kill the app process on lab-2. Expected: the node agent restarts it and the domain answers 200 again.
- Start the control plane again and check the node list. Expected: no duplicate app.
Dependencies
- M1 lifecycle and installer.
- An ACME/TLS crate spike before M2 (obtain and renew one certificate).
- D-0011 (dial-out and local reconciliation) and D-0024 (ingress).
- Provider credentials (Cloudflare API and tunnel tokens, DNS zones, S3 keys) are entered and stored through the human-only providers API (
/api/v1/providers, scopeprovider:manage) with encrypted secrets [D-0051]. - Needed before or during M2: an ACME crate maturity check, with the Caddy admin-API backend as the fallback [D-0059].
Explicitly out of scope
- Agents and MCP: M3.
- Several nodes acting as replica nodes of the control plane: the manual promotion of a replica node arrives in M5 (D-0016), and automatic election is M6.
- Git builds: M4.
M3 — AI-native
Scope
- MCP server exposing read plus restart and redeploy [D-0033], built on rmcp over the same service layer as the API (see docs/architecture/architecture.md §17).
- Agents as first-class identities, separate from humans, recording who, on whose behalf and why [D-0029].
- Grants with scope, resource selector, TTL and optional maximum uses; incident-scoped grants that auto-expire; just-in-time approval for destructive actions [D-0030].
- Audit log, append-only and hash-chained, written by the same path as the API; destructive actions and refusals included [D-0031].
- Event webhooks per event, so a webhook can wake an agent with incident context [D-0032].
- Documentation stays Markdown in the repository; the docs-site tooling is picked during M3 planning together with the v0.1.0 preview [D-0065].
rmcp 3.8 is the official Rust MCP SDK, with three semver-major releases between March and July 2026, so the MCP surface is pinned to a checked version (docs/research/rust-api-and-mcp-tooling.md).
Release callout (D-0064). This milestone ends with the v0.1.0 tag: an experimental preview with signed builds, not for production [D-0064]. M4 complete is the first general release and the broad announcement [D-0064]. The domain and trademark checks (Q-0008) must be done before this tag [D-0064].
Definition of Done
- On lab-1, tell an agent the app on lab-2 is down; the agent restarts it through MCP, the app answers 200, and the audit log holds an entry naming the agent, the human who authorized it and the reason [D-0033, D-0029].
- On lab-1, an agent holding a read-only grant attempts a restart and is refused; the refusal appears in the audit log [D-0030, D-0031].
- On lab-1, an agent whose grant TTL has passed is refused on the next call, with the reason shown as grant expired [D-0030].
- On lab-1, revoke an agent’s grant in the panel; the agent’s next call is refused [D-0030].
- On lab-1, an agent attempts a delete without approval; instead of executing, the panel shows a pending approval request, and approving it lets the action run under a short-lived extra grant [D-0030].
- On lab-1, the audit chain verifies from the first entry to the last; after one entry is edited in the local copy, verification reports the exact entry as broken [D-0013, D-0031].
- On lab-1, an event webhook fires on an app-down event and an agent is woken with the incident context [D-0032].
- On lab-1, the reference agent runner in
examples/agent-runner/receives the wake webhook, verifies its signature, mints an access token with its own agent key and acts through MCP within the incident grant, passingincident_idandjustification[D-0049, D-0032].
Demo script
- On lab-2, stop the app’s process and disable the node agent’s restart for the demo, so the app stays down; then wake the agent through the app-down webhook. Expected: the agent reads state over MCP and restarts the app.
- Load the app’s domain. Expected: HTTP 200.
- Open the audit log in the panel. Expected: one entry with agent name, model, owner, on-behalf-of human, triggering event, tool, parameters and result [D-0029, D-0031].
- Try the same restart with a read-only grant and again with an expired grant. Expected: both refused and both audited.
- Ask the agent to delete the app. Expected: a pending approval request appears instead of a deletion.
- Run audit verification, then edit one audit line in a copy and verify again. Expected: clean pass, then a reported break at that entry.
Dependencies
- M2 (a working deploy and node agent to operate on).
- D-0029 to D-0032 define the model; D-0013 supplies the replicated log the audit chain rides on.
- Axum SSE and WebSocket routes verified before the M3 event stream.
- The agent runner that receives the wake webhook and starts an agent against Quoro’s MCP endpoint is a reference example in
examples/agent-runner/, outside the quoro binary and owned by the MCP workstream [D-0049]. It verifiesQuoro-Signatureon the delivery, reads the event,WebhookWake.agent_idandincident_id, holds that agent’s own agent key configured by the operator at the runner (Quoro never sends credentials in a webhook), mints a 5-minute access token throughPOST /api/v1/auth/agent-token, and acts through Quoro’s MCP endpoint within the incident grant, passingincident_idandjustificationon every mutating tool [D-0049]. - Realtime transport is decided: SSE for one-way streams (event stream, live logs, deployment progress) and WebSockets for bidirectional sessions (terminal, VM console) [D-0057].
Explicitly out of scope
- Metrics dashboards and the web terminal: M4.
- Off-site copy of the audit log to S3/R2 with object lock [D-0031]: ships in M5a with the S3-compatible path [D-0053].
- Agents approving their own destructive actions: never allowed by design [D-0030].
M4 — Full experience
Scope
- Git builds, starting with Dockerfile-only builds [D-0033, D-0060]. A BuildKit daemon runs inside an ephemeral builder microVM (Hypothesis: not yet tried under Cloud Hypervisor) and its output goes to a node-local OCI layout, which then enters the normal OCI-to-rootfs path [D-0060]. Railpack and Nixpacks are re-verified before M4 (Hypothesis: Nixpacks is in maintenance and Railpack is its successor), and a zero-config builder may be added later as a second Builder implementation [D-0060].
- Metrics per app and per node, surfaced in the panel; telemetry and app logs stay on each node, only state is replicated [D-0014].
- Web terminal into the panel [D-0033].
- VM console for a microVM: a text console over the microVM’s serial or virtio-console socket, shown with xterm.js (docs/research/cloud-hypervisor.md: no VNC or framebuffer); consoles are text-only and there is no noVNC in the stack [D-0056].
- Decide the curated catalog’s format, owner, location and signature scheme at M4 planning, before the first template ships [D-0070].
Release callout (D-0064). M4 complete is the first general release and the broad announcement [D-0064]; the experimental v0.1.0 preview is tagged at the end of M3. This milestone still needs its demo and approval before M5a [D-0036].
Definition of Done
- On lab-2, point an app at a Git repository with a Dockerfile; the build runs, produces an image and the app answers 200 on its domain.
- On lab-2, a build that fails shows the failing step and its log output in the panel.
- On lab-1, the panel shows at least CPU and memory for lab-2 and for one app on it, refreshed without a page reload [D-0014].
- On lab-2, open the web terminal, run a command and see its output; the session is recorded in the audit log [D-0031].
- On lab-2, open the VM console of a running microVM and see the guest’s boot output on the serial console.
Demo script
- Create an app from a Git repository with a Dockerfile. Expected: the build runs and the app answers 200.
- Push a commit that breaks the build. Expected: the panel shows the failing step and its log output.
- Open the metrics view for lab-2 and for the app. Expected: CPU and memory values appear and update.
- Open the web terminal, run
uname -ain the app’s context, then open the VM console. Expected: the terminal output appears and is in the audit log, and the console shows guest boot or login output.
Dependencies
- M3 (audit and grants, so terminal and console access are attributable).
- D-0014 constrains where telemetry lives.
- Standalone contract PRs for the web terminal, VM console and metrics endpoints, merged before the feature work (D-0036).
- Needed before or during M4: Dockerfile-only builds, with Railpack and Nixpacks re-verified before M4 [D-0060].
- This milestone is the first general release [D-0064].
Explicitly out of scope
- App replicas and edge load balancing: M5a.
- Any backup or restore UI: M5a.
- A docs site: documentation stays Markdown in the repo until the first public release, and the docs-site tooling is picked during M3 planning together with the v0.1.0 preview (Q-0009, D-0065). It belongs to the installer/releases/docs-site workstream, not to a product milestone (see docs/architecture/architecture.md §17).
M5a — Stateless HA and backups
Scope
- Stateless app replicas: extra running instances of a stateless app, placed on several nodes and put behind the edge load balancer, so the edge stops sending traffic to a dead node [D-0020].
- Edge load balancing, with the edge as its own role of the same binary and able to combine with witness [D-0012, D-0018].
- Backups to S3 or R2, at the
coldpolicy: backups only [D-0020, D-0023]. - The content-addressed chunk engine, used for backups first [D-0023].
- Backups of the control-plane SQLite database and of catalog templates go through that same chunk engine with client-side encryption, taken from consistent snapshots (the SQLite backup API or
VACUUM INTO), not through Litestream [D-0069]. - HA policies are named
none,cold,warmandhot, and the panel shows what each costs [D-0020]. - Local, non-replicated persistent volumes: create, attach to an app, survive redeploy [D-0052].
- Off-site copy of the audit log to an S3-compatible bucket with object lock [D-0031, D-0053].
Cloudflare Load Balancing is a paid add-on from $5/month; an HTTPS monitor on the app hostname tests the app; the load balancer sees all replicas of one tunnel as one endpoint; cloudflared replicas have no documented app health check (Hypothesis); per-node failover needs one tunnel per node behind a load balancer (docs/research/cloudflare-lb-and-tunnel.md).
D-0016 places manual promotion of a replica node to primary in M5, and it ships in M5b alongside leases and fencing [D-0016, D-0067]. Promotion is manual: a command or a button, or an agent doing it with human approval [D-0016].
Definition of Done
- With lab-1 and lab-2 as nodes and lab-3 as the edge, a stateless app runs two app replicas, one on each node, and the edge sends traffic to both.
- Power off lab-1; the app still answers 200 on its domain through the edge on lab-3, served by the app replica on lab-2 [D-0020].
- On lab-1, a volume is created and attached to an app; data written to it survives a redeploy [D-0052].
- With lab-1 back, take a
coldbackup of a stateful app running on lab-1 to an S3-compatible bucket, then restore it onto lab-2 and confirm the restored app serves the data that was present at backup time [D-0020, D-0023]. - On lab-1, the panel shows the
coldpolicy’s cost for the app, and switching the policy changes the shown cost [D-0020]. - On lab-3, an edge health check removes lab-1 from rotation after lab-1 is powered off, and adds it back after lab-1 returns.
- On lab-1, one audit segment is exported with object lock to a bucket; an overwrite or delete of it is refused; truncating the tail of the local audit log is detected against the exported copy [D-0031, D-0053].
Demo script
- Set up lab-1 and lab-2 as nodes and lab-3 as edge.
- Deploy a stateless app with two app replicas and request its domain several times. Expected: the panel lists both replicas on different nodes, and responses come from both (each instance identifies itself).
- Power off lab-1. Expected: the domain keeps answering 200, requests land on lab-2 only.
- Power lab-1 back on and wait for the node to reconnect. Expected: the edge sends traffic to it again.
- Take a
coldbackup of a stateful app to the bucket, then restore it on lab-2. Expected: the restored app serves the backed-up data. - Create a volume, attach it to an app and write a file to it; redeploy the app. Expected: the file is still there after the redeploy [D-0052].
- Export one audit segment with object lock to the bucket, then try to overwrite or delete it. Expected: both are refused; truncating the tail of the local audit log is detected against the exported copy [D-0031, D-0053].
Dependencies
- M4.
- D-0020 (policies), D-0023 (chunk engine), D-0018 (edge as voter, relevant only from M5b), D-0012 (roles in one binary).
- Briefs for the sync/chunk engine and the witness/edge workstreams are written and approved before M5a starts.
- Local, non-replicated volumes ship in M5a [D-0052]; a standalone post-freeze contract PR adds the volume endpoints before M5a.
- A standalone contract PR adds the HA policy cost and N+1 capacity fields.
- The node mesh is kernel WireGuard configured by Quoro over netlink (no wg-quick), with a per-node keypair and peers plus allowed IPs in the desired state; boringtun in userspace is only a fallback and our own implementation is rejected [D-0063]. It is first needed in M5a for cross-node replica traffic; the control-plane session is TLS dial-out (D-0011), so M2 needs no mesh, and a publicly reachable peer acts as the relay hub for NAT [D-0063].
- The snapshot primitive the chunk engine reads from: reflink snapshots plus a full content-defined chunk scan for the M5a
coldbackups [D-0062]. - The off-site audit-log copy [D-0031] ships in M5a with the S3-compatible path [D-0053].
- Object lock in compliance mode must be verified per provider (R2, S3, B2, MinIO) before M5a (research task).
- Object storage choice: S3-compatible; R2 offers an atomic conditional PUT at 1 write/s per key with no TTL, and is only needed as a lease arbiter from M5b (docs/research/r2-conditional-writes.md).
Explicitly out of scope
warmandhot: M5b and M6.- Witness, leases and fencing for stateful apps: M5b.
- Automatic failover of the control plane: M6.
- Distributed storage such as Ceph [D-0034].
M5b — Witness, leases and warm replication
Scope
- Witness role: a lightweight daemon that only breaks ties and stores no data, in a different failure domain than the nodes [D-0017].
- Quoro reports that HA needs a third vote when only two nodes exist, and offers a witness [D-0017].
- Leases and lease-based fencing [D-0019]. A lease is a node’s right to run a stateful app.
expiringleases (warm/hot) end when they are not renewed in time;localleases (none/cold) end only by an explicit revoke or an operator declaring the node lost [D-0047]. When the arbiter is unreachable anexpiringlease first becomesuncertain(keep running, start nothing new) and only thenexpired(self-fence); see docs/contracts/node-agent-protocol.md §8. - Replicated volumes at the
warmpolicy: data continuously replicated, image pulled, instance stopped [D-0020], shipped by the content-addressed chunk engine [D-0023]. - The N+1 capacity indicator: “if node A dies, do its apps fit on the others?” [D-0021].
- Failback behaviour: a returning node rejoins fenced, starts nothing stateful, quarantines its divergent writes as a snapshot recorded in the audit log, resyncs incrementally, and by default no failback happens [D-0022].
- A planned switchover is available when the user prefers the original node: wait for lag near zero, wait for a low-traffic window, freeze writes, final sync, move the lease, shift traffic, drain connections; it loses no data [D-0022].
- The edge can also be the third voter, as the witness+edge combination of the same binary [D-0018].
External arbiters are optional and their behaviour is verified. R2 conditional writes as a lease arbiter: an atomic conditional PUT, 1 write/s per key, no TTL, and fencing tokens are still required (docs/research/r2-conditional-writes.md). Cloudflare Durable Objects as the arbiter: fits on the Workers Free plan, needs a Cloudflare account and a Worker, and fencing tokens are still required (docs/research/durable-objects.md). The snapshot primitive under the chunk engine is reflink snapshots plus a full content-defined chunk scan for the M5a cold backups; a spike comparing lvm-thin and btrfs chooses the primitive for warm before M5b, and the Engine trait’s snapshot_volume hooks keep it swappable [D-0062].
Failure domains: all three lab VMs run on one physical host. The lab therefore simulates separate failure domains by stopping VMs or cutting the network between them. The real requirement that the witness sits in a different failure domain [D-0017] is documented here, not proven by the lab.
Definition of Done
- Roles: lab-2 is the control-plane primary and a node; lab-1 is a node hosting the stateful app; lab-3 is added as witness during the test [D-0012, D-0017].
- With only lab-1 and lab-2 as nodes, Quoro reports that HA needs a third vote and offers to add a witness [D-0017].
- Add lab-3 as witness; Quoro then reports three votes, and lab-3 stores no application data.
- A stateful app runs at
warmon lab-1 with its data shipped to lab-2; the panel shows the last shipped snapshot on lab-2. - Cut lab-1 off from lab-2 and lab-3; lab-1 self-fences its stateful apps and they stop on lab-1 [D-0019].
- While lab-1 still holds the app’s lease, Quoro refuses to start the warm copy on lab-2 [D-0019].
- After lab-1’s lease is lost, an operator (or an agent with human approval) starts the warm copy on lab-2, and it contains the data of the last shipped snapshot [D-0016, D-0020, D-0023]. Automatic per-app failover is M6.
- Bring lab-1 back; it starts nothing stateful, its copy of the app becomes the
warmcopy following lab-2 (lab-2 is now the lease holder and source of truth), and its divergent writes appear as a quarantined snapshot recorded in the audit log [D-0022]. - Write a known record to the app, then run a planned switchover back to lab-1; it completes, the app serves from lab-1, and the record is present [D-0022].
- The N+1 indicator answers “if lab-1 dies, do its apps fit?” with yes, or with no plus the missing resources [D-0021].
- An operator promotes a replica node to primary; the promotion is refused while the old primary may still hold expiring leases unless the operator acknowledges the fence [D-0067].
- Before expiring leases ship, the concurrent-fence and mid-fence crash tests pass [D-0075].
- A held expiring lease survives an agent restart within the same host boot [D-0072].
Demo script
- Start with lab-1 and lab-2 as nodes only and open the HA view. Expected: Quoro says a third vote is missing and offers a witness [D-0017]. Add lab-3 as witness. Expected: Quoro reports three votes and lab-3 shows no volumes.
- Deploy a stateful app at
warmon lab-1. Expected: the panel shows the data being shipped and lab-2 holding the last shipped snapshot. - Cut the network between lab-1 and the other two. Expected: lab-1’s app stops itself (self-fence). Try to start the warm copy on lab-2 before the lease is lost: refused. After the lease is lost, start it: it serves the last shipped data.
- Reconnect lab-1. Expected: it starts nothing stateful, its copy becomes the warm copy following lab-2, and a quarantined snapshot of its divergent writes is in the audit log [D-0022].
- Start a planned switchover to lab-1 in the panel, then open the N+1 indicator and ask “if lab-1 dies?”. Expected: the switchover completes with no data loss and the app serves from lab-1; the indicator answers yes, or no plus the missing resources [D-0021].
Dependencies
- M5a (edge, app replicas, chunk engine, bucket).
- D-0017, D-0018, D-0019, D-0020, D-0021, D-0022, D-0023.
- A standalone contract PR adds the HA policy cost and N+1 capacity fields.
- Needed before or during M5b: the lvm-thin versus btrfs spike that chooses the
warmsnapshot primitive [D-0062]. - This milestone does not deliver automatic promotion of the control plane; that is M6 [D-0016].
Explicitly out of scope
- Automatic control-plane election: M6.
- The
hotpolicy: M6. - Automatic failback: never, it is always planned and approved [D-0022].
- Automatic per-app failover: M6 [D-0033].
M6 — Automatic failover and hot
Scope
- Automatic election of the control-plane primary with a majority, using Raft semantics; automatic failover only with at least three votes [D-0016].
- The
hotpolicy: the instance runs on another node and replicates, so a node loss is covered by a running copy [D-0020]. - Per-app failover rides on the same lease mechanism as the election [D-0019].
- Failback remains never automatic [D-0022].
All three lab VMs are on one physical host. The lab simulates a split by cutting the network between VMs and a node loss by stopping a VM. The requirement that voters sit in different failure domains [D-0017] is documented, not proven, by the lab.
Definition of Done
- With lab-1, lab-2 and lab-3 as voters, powering off the primary node triggers an automatic election and a new primary takes writes without any human action [D-0016].
- Cut the network into a minority partition; the minority side never acts as primary, and no node in it accepts writes [D-0016].
- In the majority partition, writes still succeed and the audit chain still verifies after the partition heals [D-0013, D-0031].
- A
hotapp keeps answering 200 on its domain across the loss of the node that was serving it, with a running copy on another node [D-0020]. - Bring the failed node back; it returns as a replica node and no automatic failback happens; a planned switchover is the only way to move the workload back [D-0022].
Demo script
- Run three voters: lab-1, lab-2, lab-3. Note the current primary.
- Power off the primary. Expected: a new primary is elected automatically, the panel shows it without a manual step, and a change made through the panel succeeds on it.
- Cut the network so one node is alone. Expected: that node refuses writes and does not claim to be primary.
- Deploy a
hotapp and stop the node serving it. Expected: the domain keeps answering 200 from the running copy. Power the stopped node back on. Expected: it rejoins as a replica node, nothing moves automatically, and a planned switchover is available in the panel [D-0022].
Dependencies
- M5b (witness, leases, replication).
- D-0016, D-0019, D-0020, D-0022.
- Needed before or during M6: snapshot consistency of
hotvolumes with the chosen primitive.
Explicitly out of scope
- Managed hosting: Later.
- Synchronous replication as the default: async is the default, synchronous is optional for nearby nodes [D-0020].
4. Later
- An optional managed offering may come later [D-0003]; nothing in this roadmap depends on it. The self-hosted witness is free and first-class [D-0017].
- A container-fallback Engine implementation is a later backend behind the Engine trait, because many VPS do not expose nested virtualization [D-0009]. It is not part of the MVP.
- A self-hosted GitHub Actions runner stays deferred, because a self-hosted runner would let fork PRs on a public repository run arbitrary code on the project’s infrastructure.
5. Out of scope for now
The following are explicitly excluded, because each could eat the project [D-0034]:
- Live migration [D-0034].
- Distributed storage such as Ceph [D-0034].
- Complex networking: SDN and VLANs [D-0034].
- Multi-tenant operation with billing [D-0034].
- Writing our own VMM; Quoro depends on KVM and Cloud Hypervisor instead [D-0007, D-0034].
6. Open questions affecting the roadmap
Open questions are open. This table lists them with options where the ledger gives options, and never picks one.
| Q-id | Question | Needed by milestone |
|---|---|---|
| Q-0008 | Domain and trademark check | before the M3 v0.1.0 tag (D-0064) |
Decided and no longer open: the ingress proxy [D-0059], the Git build method [D-0060], the OCI-to-rootfs conversion and init [D-0061], the snapshot primitive [D-0062], the node mesh [D-0063], the first public release [D-0064], manual promotion of a replica node [D-0067], docs-site timing [D-0065], encrypted SQLite and template backups [D-0069] and when the curated catalog is decided [D-0070].
Answered verification questions
| Q-id | Topic | Answer |
|---|---|---|
| Q-0010 | Cloud Hypervisor capabilities | v54.0, Apache-2.0 AND BSD-3-Clause, HTTP API over a UNIX socket; snapshots hold RAM and device state, not disks; virtio-fs needs an external virtiofsd; text consoles only (serial, virtio-console), no VNC or framebuffer; host kernel 6.8 or later. Boot under 100 ms remains a Hypothesis. docs/research/cloud-hypervisor.md |
| Q-0011 | R2 conditional writes as a lease arbiter | Atomic conditional PUT, 1 write/s per key, no TTL; fencing tokens still required; B2 appears to lack it (Hypothesis). docs/research/r2-conditional-writes.md |
| Q-0012 | Durable Objects as an external lease arbiter | Fits as arbiter on the Workers Free plan; needs a Cloudflare account and a Worker; fencing tokens still required. docs/research/durable-objects.md |
| Q-0013 | Cloudflare Load Balancing and cloudflared failover | LB is a paid add-on from $5/month; an HTTPS monitor on the app hostname tests the app; the LB sees all replicas of one tunnel as one endpoint; cloudflared replicas have no documented app health check (Hypothesis); per-node failover needs one tunnel per node behind an LB. docs/research/cloudflare-lb-and-tunnel.md |
| Q-0014 | rmcp, Litestream and utoipa/orval maturity | rmcp 3.8 official, three semver-major releases between March and July 2026; Litestream 0.5 has no client-side encryption (Q-0026); utoipa 6 with orval/openapi-ts works, one known orval break. docs/research/rust-api-and-mcp-tooling.md |
| Q-0015 | VPS providers exposing KVM and nested virtualization | Nested KVM is the exception on cheap VPS; bare metal is the reliable path; probe /dev/kvm with a real boot. docs/research/vps-nested-virt.md |
Every “to verify” claim is researched before it enters a document; the answered ones above cite their research file, and unconfirmed ones stay marked as hypotheses [D-0040].
Glossary
Terms used across Quoro’s documents. Bracketed ids point to the decision ledger; where a definition and an accepted decision disagree, the decision wins.
ACME: The protocol the ingress uses to obtain and renew TLS certificates automatically for hosts that expose a public IP. [D-0024]
ADR: Architecture decision record, one per accepted decision; an accepted ADR is law and changing it requires a new ADR approved by the maintainer. [D-0036, D-0040]
agent: An AI actor with its own identity (name, model, owner), separate from humans; every action records who acted, on behalf of whom, and why. [D-0029]
AI-native loop: The cycle detect (node agent and health checks report state) -> notify (configurable webhooks per event) -> wake (a webhook starts an agent with Quoro’s MCP and incident context) -> act (only within the agent’s grant). [D-0032]
app replica: An extra running instance of a stateless app on another node, behind the edge load balancer, so the edge can stop sending traffic to a dead node and keep the app available. [D-0020]
approval request: A request an agent files for a destructive or irreversible action, which a human approves in the panel; approval releases a short-lived extra grant. Also: JIT approval. [D-0030]
audit log: An append-only, hash-chained record of every action, holding who, on behalf of whom, the triggering event, the tool or endpoint, the parameters, the result and the agent’s stated justification. It can be copied off-site to S3/R2 with object lock, and MCP writes through the same service layer and the same log. [D-0031]
catalog: Curated templates kept in the repository, pointing to pinned upstream images with verifiable signatures; Quoro runs no image registry of its own for now. Its format, owner, location and signature scheme are decided at M4 planning, before the first template ships. Also: template. [D-0026, D-0070]
change log: The numbered, append-only, hash-chained sequence of state changes that a replica node asks for (“give me everything after N”) and verifies by hash. Also: state log. [D-0013, D-0015]
chunk engine: The volume sync mechanism: a snapshot is split into content-addressed, deduplicated, encrypted chunks and only new chunks are shipped. Shipping to another node is warm replication; shipping to S3/R2 is cold backup. [D-0023]
Cloudflare Tunnel: The ingress option for homelabs behind NAT, where cloudflared dials out to Cloudflare so no port has to be opened. Load Balancing is a paid add-on from $5/month and an LB sees all replicas of one tunnel as one endpoint; cloudflared replicas give host-level failover only, per-node failover needs one tunnel per node behind an LB, and whether replicas expose any app health check is a Hypothesis (see docs/research/cloudflare-lb-and-tunnel.md). [D-0024]
Cloud Hypervisor: The Rust virtual machine monitor, driven through its HTTP/JSON API over a UNIX socket, that Quoro’s engine builds on together with KVM. It is v54.0 under Apache-2.0 AND BSD-3-Clause; snapshots hold RAM and device state but not disks, with no incremental API; virtio-fs needs an external virtiofsd; consoles are text-only (serial, virtio-console), with no VNC or GPU; the host kernel must be 6.8 or newer (see docs/research/cloud-hypervisor.md; its “under 100 ms” boot claim is unbenchmarked and stays a Hypothesis). [D-0007]
cold: HA policy where other nodes hold backups only, so RPO is the time since the last backup and RTO is a restore. No copy of the app runs elsewhere, so a failure means restoring from backup. Its cost is backup storage and restore time (D-0068). [D-0020]
console: A terminal in the panel for a microVM, text-only, rendered with xterm.js; there is no graphical console and no noVNC. [D-0056]
contract: A frozen interface: OpenAPI spec, event schema, DB schema, node-agent protocol, audit-log format, permission model, Engine trait. Contracts freeze after M0 and any change is a standalone PR reviewed on its own. [D-0033, D-0036, D-0040]
control plane: The stateful layer (API and state) that holds Quoro’s state and accepts writes through the primary; if it dies, apps keep running and only changes are blocked. [D-0010, D-0011]
data plane: The apps and node agents that keep running and reconciling their local desired state when the panel or the control plane is unreachable. [D-0010, D-0011]
desired state: The locally stored description of what a node should run, written by the control plane and applied by the node agent without needing the control plane to be reachable. [D-0011]
dial out: The rule that a node opens the connection to the control plane and never the reverse, so the control plane can live anywhere and nodes work behind NAT. [D-0011]
edge: Node role that is the traffic entry point and load balancer; it can serve as the third voter, and a single edge is a traffic single point of failure. [D-0012, D-0018]
Engine trait: The boundary behind which the Cloud Hypervisor engine sits, so other backends such as Firecracker or containers can be added later. [D-0007, D-0009]
external lease arbiter: An optional off-cluster service that grants leases, either a Cloudflare Durable Object (single-threaded actor with strongly consistent storage and alarms; needs a Cloudflare account and a deployed Worker, and fencing tokens are still required) or S3/R2 conditional writes (atomic If-Match/If-None-Match PUT with one winner, 1 write/s per key, no TTL and fencing tokens required; not portable, since AWS S3 and MinIO have it while Backblaze B2 appears to lack it, a Hypothesis; see docs/research/durable-objects.md and docs/research/r2-conditional-writes.md). [D-0017]
failback: Returning an app to its previous node after a failure; it is never automatic or immediate, and the default is no failback, with roles simply swapped. [D-0022]
failure domain: A set of machines that fail together, for example a home PC next to node A; a witness must sit outside the nodes’ failure domain. [D-0017]
fencing: A node’s mandatory, lease-based act of stopping its own stateful apps when it loses its lease — for an expiring lease only once it is expired, not while it is uncertain; the node agent must self-fence from M0 so the protocol is not redesigned later. [D-0019]
grant: A permission envelope made of scope, resource selector, TTL and optionally a maximum number of uses, for example app:restart on project=blog for one hour. Tokens are short-lived and refreshed against the grant, and revoking the grant locks the agent out within seconds. [D-0030]
HA policy: The per-app reliability choice among none, cold, warm and hot, with its cost shown in the panel. Replication is asynchronous by default (synchronous is optional for nearby nodes and adds latency to every write), and suggested defaults are app replicas behind the load balancer for stateless apps on two or more nodes and warm for stateful apps. [D-0020]
hot: HA policy where the app runs and replicates on other nodes, giving near-zero RPO and RTO at the cost of disk, RAM and CPU. [D-0020]
incident grant: A grant with just the scopes needed for the incident that woke an agent, issued when an event wakes it and expiring automatically. [D-0030]
ingress: The entry layer for app traffic: a reverse proxy with ACME for hosts with a public IP, Cloudflare Tunnel for hosts behind NAT, both behind a common interface; the proxy is an embedded Rust proxy (hyper + rustls with an ACME client crate), with Caddy driven through its admin API kept as a fallback backend (D-0059). [D-0024, D-0059]
JIT approval: See approval request. [D-0030]
lab: The test lab is three nested-virtualization Debian 13 VMs (lab-1, lab-2 and lab-3) on a single physical host, so they share one failure domain; it is not valid for performance benchmarks. [D-0036]
lease: A lease is a node’s right to run a stateful app. expiring leases (warm/hot, from M5b) end when they are not renewed in time; local leases (none/cold) never expire when the arbiter is unreachable and end only by lease_revoke or an operator declaring the node lost. When the arbiter is unreachable an expiring lease first becomes uncertain (keep running, start nothing new) and only then expired (self-fence); see docs/contracts/node-agent-protocol.md §8. [D-0019, D-0047]
lease holder: The node currently holding an app’s lease; only it may run that stateful app. [D-0019]
majority: See quorum. [D-0016, D-0017]
manual promotion: The M5b action (D-0067) that promotes a replica node to primary, either as a command or button or by an agent with human approval. [D-0016, D-0067]
MCP: Model Context Protocol server exposing Quoro to agents; it is first-class from day one and uses the same service layer and audit log as the HTTP API. [D-0002, D-0031]
mesh: The WireGuard network connecting nodes across providers, configured by Quoro over netlink against kernel WireGuard with no wg-quick, per-node keypairs and peers in desired state (D-0063). [D-0010, D-0063]
microVM: The unit of app isolation, one per app, booted with a minimal init; nodes run no Docker. [D-0008]
milestone: A roadmap stage (M0, M1, M2, M3, M4, M5a, M5b, M6, then Later) that ends with a demo on the lab and the maintainer’s approval before the next one starts. [D-0033, D-0036]
minimal init: The small init process that boots inside a microVM in place of a full distribution init; Quoro writes its own minimal Rust init (D-0061). [D-0008, D-0061]
N+1 capacity indicator: The panel answer to “if node A dies, do its apps fit on the others?”, given as yes or as no plus what is missing, for example 6 GB of RAM. [D-0021]
node: (1) A machine running the Quoro binary; (2) the node role, as opposed to witness or edge. [D-0012]
node agent: The per-node component that stores desired state locally, reconciles it, dials out to the control plane and self-fences when it loses its lease (for an expiring lease, only once it is expired). [D-0011, D-0019]
none: HA policy where nothing runs on other nodes, so no resources are reserved elsewhere (D-0068) and no RPO or RTO is promised for the app. [D-0020]
OCI image: The container image a deploy is built from; Quoro converts it into a root disk and boots it in a microVM, through its own OCI-to-ext4 pipeline with layer paths resolved inside the rootfs (D-0061). [D-0008, D-0061]
planned switchover: A deliberate handover of an app to another node: wait for replication lag near zero and a low-traffic window, freeze writes briefly, do a final sync, move the lease, shift traffic at the load balancer and drain connections. It loses no data, unlike a surprise failure, which can lose seconds. Also: relevo, “relevo inteligente”. [D-0022]
primary: The one control-plane node that is the single source of truth; all writes go through it. [D-0012]
provider: The credentials of one external provider, stored as a resource with a prv_ id and one kind (dns, tunnel, lb or object_store). The secret is write-only, encrypted with the cluster data-encryption key, never returned by a read and redacted from audit parameters; managing providers needs the human-only scope provider:manage, and there is no MCP tool for it. The credential for the off-site audit copy is a separate put-only object_store provider. [D-0051]
provider interface: The pluggable boundary for DNS, load balancing and S3-compatible storage, with Cloudflare as the first implementation rather than the only one. [D-0025]
quarantined snapshot: The separate snapshot in which a returning node’s divergent writes are kept and recorded in the audit log, so they are never silently dropped. [D-0022]
quorum: The majority of votes needed for automatic decisions; HA requires at least three votes and an odd number of voters. Also: majority, vote. [D-0016, D-0017]
Raft: The consensus algorithm planned for automatic failover with a majority of at least three votes, in M6. [D-0016]
reconcile: See desired state. [D-0011]
relevo: See planned switchover. [D-0022]
replica: (1) replica node: a node that holds a copy of control-plane state, serves reads and forwards writes to the primary; (2) app replica: an extra running instance of a stateless app behind the edge load balancer. A replica node is not an app replica. [D-0012, D-0020]
reproducible build: A build that can be rebuilt bit for bit from its sources; reproducible builds and signed binaries are mandatory because the installer is curl | sh. Also: signed binary. [D-0027]
role: What a node does in the cluster (node, witness, edge), combinable in the same binary. [D-0012]
rootfs: The root disk a microVM boots from, produced from an OCI image at deploy time. [D-0008]
RPO: Recovery point objective, the amount of data an app may lose on failure; each HA policy sets it, from “since the last backup” for cold to near zero for hot. [D-0020]
RTO: Recovery time objective, how long an app takes to serve again after failure; each HA policy sets it, from a restore for cold to a microVM boot for warm and near zero for hot. [D-0020]
scope: The verb inside a grant, for example read, restart, redeploy or delete. [D-0030]
self-fencing: See fencing. [D-0019]
setup token: A one-time secret required in the Quoro-Setup-Token header by POST /api/v1/setup, which claims the owner on first run. It is single use and expires after one hour; a missing or invalid token answers 401 unauthenticated. [D-0054]
signed binary: See reproducible build. [D-0027]
split-brain: The state where both sides of a network cut become primary and diverge, which is why automatic election with only two nodes is not allowed. [D-0016]
stateful app: An app that keeps data of its own, so it may run only on its lease holder and is fenced off when the lease is lost. [D-0019, D-0020]
stateless app: An app that keeps no local state, so it can run as app replicas on several nodes behind the edge load balancer for zero downtime. [D-0020]
state log: See change log. [D-0013, D-0015]
sync engine: Quoro’s own replication layer, made of control-plane log replication and the chunk engine for volumes; databases instead use their native replication, offered as catalog templates (Litestream 0.5 has no client-side encryption, so SQLite backups use Quoro’s own chunk engine from consistent snapshots instead, D-0069; a Litestream-based lower-RPO backend may be added later as an option). [D-0023, D-0069]
telemetry: App logs and metrics, which stay on each node and are queried through the primary on demand, with optional archive to S3/R2; only state is replicated. [D-0014]
template: See catalog. [D-0026]
TTL: Time to live, the expiry attached to a grant after which it no longer authorizes anything. [D-0030]
vote: See quorum. [D-0016, D-0017]
voter: See quorum. [D-0016, D-0017]
warm: HA policy where data is continuously replicated and the image is pulled on another node but the microVM stays stopped, giving RPO in seconds and RTO equal to a microVM boot at the cost of disk. [D-0020]
warm copy: The stopped, continuously replicated copy of a stateful app on another node under the warm policy; it may start only once the previous lease holder has lost the app’s lease. [D-0019, D-0020]
witness: Node role that only breaks ties and stores no data, run as a self-hosted daemon in a failure domain different from the nodes. The self-hosted witness is free and first-class [D-0017].
workstream: A parallel line of work after M0, each in its own crate or package and owning its paths: engine, control-plane, panel, node-agent, ingress, mcp, installer/releases/docs site, and later sync/chunk engine and witness/edge (see docs/architecture/architecture.md §17). [D-0036]
Quoro architecture
Status: M0 draft for review. Where an accepted decision settles a choice, the text cites its D-id; a recommendation is marked pending maintainer decision only where its question is still open.
Quoro is one self-hosted binary that unifies a hypervisor with application deployment: it runs one microVM per app on Cloud Hypervisor and KVM, and the same interface deploys, exposes and observes those apps. This document describes how the pieces fit together: the roles of the binary and the control-plane statuses, the split between control plane and data plane, how nodes stay autonomous when the control plane is unreachable, how state is replicated through a hash-chained change log, where telemetry lives, the engine layer, networking, ingress, providers, identities and grants, the realtime transport (D-0057), the high-availability groundwork laid in the MVP, the key flows, the confirmation of the baseline stack (D-0056, superseding D-0028), the workspace layout, and every open question that this document depends on.
1. Scope and reading guide
This document is the architecture reference named in D-0040 and the M0 architecture deliverable of D-0033. It covers component boundaries, process model, data flow, failure behaviour and the interfaces between workstreams. It does not restate contracts: those are separate files that freeze at the end of M0 (D-0036).
In scope:
- The components inside the single
quorobinary and which of them run under which role and control-plane status (D-0012). - The control-plane and data-plane split, and what still works when the control plane is down (D-0010, D-0011).
- State storage, the change log and replication (D-0013, D-0015).
- Telemetry locality (D-0014), the engine layer (D-0007, D-0008, D-0009), networking (D-0010, D-0034), ingress (D-0024) and provider interfaces (D-0025).
- Identities, grants, audit and the AI-native loop (D-0029 to D-0032).
- The realtime transport (D-0057), the HA groundwork (D-0016 to D-0023, D-0033), the key flows, the stack confirmation (D-0056, superseding D-0028) and the workspace layout.
Out of scope: product requirements (see docs/product/roadmap.md), the threat model (see ../architecture/threat-model.md), and the contents of each contract, which are linked but not copied.
Contracts this document depends on
| Contract | What it fixes |
|---|---|
| ../contracts/openapi.yaml | The HTTP API, its operations, scopes and error codes |
| ../contracts/events.schema.json | Event envelope and every event type |
| ../contracts/node-agent-protocol.md | Enrollment, the dial-out session, messages, reconciliation, leases and self-fencing |
| ../contracts/audit-log-format.md | Audit record format, chaining, verification, off-site copy |
| ../contracts/permission-model.md | Identities, scopes, selectors, grants, approvals |
| ../contracts/db-schema.sql | Control-plane SQLite schema: change log and derived tables |
| ../contracts/engine-trait.md | The Engine trait, its types and semantics |
Vocabulary is fixed by ../contracts/README.md; terms follow ../glossary.md. Every name used here for roles, statuses, ids, scopes, event types, errors, headers, channels, node-agent messages and tables comes from those two files.
Labelling convention
Every design statement below carries one of these labels:
- Decision (
D-xxxx): accepted and binding. An accepted decision is law; changing one requires a new decision approved by the maintainer (D-0036). - Finding (
F-xxxx, or adocs/research/<file>.mdpath): a sourced fact about external software, gathered before it entered a document (D-0040). - Pending maintainer decision: a recommendation or an undecided design choice. Written as “recommended, pending maintainer decision”; it is not a decision and must not be implemented as one.
- Open (
Q-xxxx): an unanswered question. Options are listed and the recommendation, if any, is marked pending maintainer decision; the question stays open. - Hypothesis: a claim about external software, or about behaviour this project has not yet run, that is not established by the findings. Each one is collected in section 19 with what would verify it.
Anything stated without one of these labels and without a citation is a Hypothesis.
2. System overview
There is one binary, quoro, on every machine (D-0012). The binary bundles the components below; which of them run depends on the role (node, witness, edge) and, for a node, on its control-plane status (primary, replica) (D-0012).
| Component | What it does | Runs on (role; milestone for M5 items) |
|---|---|---|
| HTTP API | The REST surface of ../contracts/openapi.yaml, served by Axum | node |
| MCP server | Streamable HTTP at /mcp on the same server, plus a stdio mode in the CLI; calls the same service layer as the API (D-0031) | node |
| Embedded panel assets | The React panel, embedded with rust-embed | node |
| State store | SQLite: the change log and the tables derived from it (D-0015) | node (writable on the primary) |
| Scheduler | Chooses the node for a deployment and writes desired state (D-0011, D-0012) | primary |
| Event bus | Turns state changes and node reports into events (D-0032) | primary |
| Webhook dispatcher | Delivers signed events to subscribers, with retries and delivery records (D-0031, D-0032) | primary |
| Audit log | Append-only, hash-chained record of every action (D-0031) | primary writes; replicas hold a copy |
| Node agent | Holds desired state locally, reconciles it, dials out, self-fences (D-0011, D-0019) | node |
| Engine | The Engine trait and its Cloud Hypervisor implementation (D-0007) | node |
| Ingress | The Ingress interface: reverse proxy with ACME, or Cloudflare Tunnel (D-0024) | node |
| Witness | Breaks ties; stores no data (D-0017) | M5b |
| Edge | Traffic entry point and load balancer; can also be the third voter (D-0012, D-0018) | M5a |
humans (panel, session cookie) agents (MCP, access token)
\ /
\ /
+------------------------------------------+
| primary (role: node) |
| HTTP API ---- MCP server (/mcp) |
| | | |
| service layer (auth, grants, audit) |
| | | |
| state store (SQLite: change_log + |
| derived tables) audit log scheduler |
| event bus webhook dispatcher |
+------------------------------------------+
^ ^ ^ |
dial-out | | | | providers (D-0025)
WebSocket | | | v
sessions | | | +----------------------+
(TLS, node | | | | DNS LB object |
opens them)| | | | provider store |
| | | +----------------------+
+---------+ | +---------+
| | |
+----------+ +----------+ +----------+
| node | | node | | node |
| agent | | agent | | agent |
| engine | | engine | | engine |
| ingress | | ingress | | ingress |
| telemetry| | telemetry| | telemetry|
+----+-----+ +----+-----+ +----+-----+
| | |
Cloud Hypervisor microVMs (one per app, KVM)
- - - - - - - - - - - - - - - - - - - - - - - - -
witness (M5b: tie-break vote, no data)
edge (M5a: traffic entry, load balancer;
can combine with witness, D-0018)
- - - - - - - - - - - - - - - - - - - - - - - - -
The arrows into the primary are inbound sessions, not inbound connections: each node agent opened that session outward (D-0011). Nothing in the design dials into a node.
Process model
There is one OS process per machine, not one per component (D-0012). Inside it:
| Concern | How it is arranged |
|---|---|
| HTTP and MCP | One Axum server, one listener; /mcp is a Tower service mounted beside the REST routes (F-0041) |
| Long-running work | Tokio tasks: the reconciler, the session reader and writer, the scheduler tick, the webhook dispatcher |
| Blocking work | Image pulls, rootfs builds and snapshot copies run on a bounded blocking pool, so a slow pull cannot starve the API |
| MicroVMs | One VMM child process per microVM, owned by the engine; Quoro never shares memory with a guest |
| Licensing boundary | The VMM is a separate process under its own license (F-0036). That a separate process keeps AGPL-3.0 Quoro (D-0004) and Apache-2.0/BSD-3-Clause Cloud Hypervisor cleanly separated is a legal reading, not a reviewed conclusion: Hypothesis (docs/research/cloud-hypervisor.md) |
| Panel | Static assets compiled into the binary with rust-embed; no Node process at runtime |
| State | One writer connection to SQLite on the primary; readers get their own connections |
The process model follows from two constraints. Nothing that can block a request may run on the request path, because a deploy that waits on a registry pull must not make the panel unresponsive. And nothing that runs a guest may share an address space with the control plane, because a compromised app must not reach Quoro’s memory (D-0008).
Privileges are split the same way (ADR 0089, which replaces the M1 root service of ADR 0088). The control plane (quoro serve, unit quoro.service) runs as the quoro-api system user with an empty capability bounding set, NoNewPrivileges and a read-only system except its data directory /var/lib/quoro: it signs desired state and never touches a hypervisor, a bridge or a firewall. The node agent (quoro node, unit quoro-node.service) is the only privileged process: it runs the engine as root, keeps its state in /var/lib/quoro-node, and dials the control plane like any other node, over loopback on a primary. Its unit uses KillMode=process, so restarting the agent leaves the VMMs running and the next agent adopts them. A development host may still run both in one process with [node] local = true, which then needs root.
Host layout, ports and CLI
Decided (D-0054):
- One listener. One HTTPS listener, default port 8443 and configurable, serves the API, the panel and MCP.
ingressowns 80 and 443 on the same host, so the control plane never competes with a customer’s domain for those ports. - Certificate. The primary’s certificate is self-signed at install, and the join command carries its SHA-256 fingerprint, which is the pin model of section 5. Rotating that certificate therefore invalidates every outstanding join command. An operator may later replace it with an ACME certificate.
- Paths. Control-plane data lives in
/var/lib/quoro, node-agent data in/var/lib/quoro-node(siblings, never nested), configuration in/etc/quoro/quoro.toml(TOML), and logs go to journald. - CLI.
quoro serve --role node|witness|edge,quoro version,quoro join <url> --token --fingerprint,quoro node,quoro mcp stdio,quoro setup-token.quoro joinonly enrolls;quoro noderuns the node agent. - First run. The installer prints a one-time setup token that
POST /api/v1/setuprequires in theQuoro-Setup-Tokenheader: single use, valid for one hour. The primary keeps only the token’s SHA-256 hash and its expiry in the data directory.quoro setup-tokenissues a fresh token, invalidating the previous unused one, and refuses once an owner exists.
Alternatives not chosen: ACME for the listener from install, which needs a public name and fails behind NAT; and binding /setup to localhost, which breaks remote installs.
3. Roles and control-plane status
Roles are what a machine does; statuses are what a node’s control-plane component is. They are different axes (D-0012).
Roles (combinable, D-0012, D-0018):
| Role | What it runs | Since |
|---|---|---|
node | Node agent, engine, ingress, local telemetry, and — when it is not a replica — the control plane | M1 |
witness | The tie-break vote only; stores no data (D-0017) | M5b (D-0033) |
edge | Traffic entry point and load balancer; can combine with witness (D-0018) | M5a (D-0033) |
Combining roles is a deployment choice, not a code branch: a machine that is witness+edge runs the witness vote and the load balancer from the same binary (D-0018). A machine can be node and edge at once, which is what a two-machine cluster with a load balancer on one of them looks like.
A single edge is a traffic single point of failure (D-0018). With the edge also a voter, losing it removes both the traffic path and one vote. D-0018’s mitigations are Cloudflare as the traffic edge with the small VPS acting only as witness, or two edges with DNS failover. Verify in M5b that the chosen mitigation keeps both traffic and quorum when the edge dies.
Control-plane status of a node:
| Status | Runs | Accepts |
|---|---|---|
primary | API + MCP + panel assets, state store, scheduler, event bus, webhook dispatcher, audit log, node agent | All reads and all writes; it is the single source of truth (D-0012) |
replica | API + MCP + panel assets (read paths), a verified copy of the change log and state tables, node agent | Reads; writes are forwarded to the primary, and fail with not_primary (503) when forwarding is not possible (D-0012) |
What exists per milestone:
- M1 to M4 have exactly one primary, which is also a node, plus nodes that run the node agent and dial out to it (D-0016, D-0033).
- Witness, edge and leases with expiry arrive with M5 (D-0033):
witnessin M5b,edgein M5a. - Replica nodes serve reads and forward writes to the primary (D-0012). Turning that serving on requires promoting a replica to primary; manual promotion ships in M5b, alongside leases and fencing, and refuses while the old primary may still hold expiring leases unless an operator acknowledges the fence (D-0067).
Design choice, stated as such: from M2 every enrolled node already follows the change log over its session and keeps a verified copy, so a later manual promotion needs no protocol change (D-0013). The M2 node agent therefore implements the follow path even though nothing else in M2 uses a replica. This costs storage on each node and buys the ability to promote without a redesign.
4. Control plane and data plane
The control plane is the API, the state store and the audit log on the primary: the layer that decides what should run and records who asked for it. The data plane is the node agents, the engine, the ingress and the apps themselves: the layer that actually runs things (D-0010, D-0011, D-0012).
The principle is that the two fail independently. If the control plane dies, apps keep running and the node agents keep restarting them; only changes are blocked (D-0010, D-0011, D-0012).
| Without the control plane | State |
|---|---|
| Running apps | Keep running (D-0011) |
| Local restarts, health checks and crash backoff | Keep working on each node (D-0011, D-0019) |
| Ingress routing | Keeps serving the last applied route table (D-0024) |
| Certificates already issued | Keep being served (D-0024) |
| Local logs and metrics | Keep being written and kept on the node (D-0014) |
| Deploys, config changes, new grants and access tokens | Blocked (D-0012, D-0030) |
| Approvals | Blocked: a human cannot approve in the panel, and the API that records the decision is down (D-0030) |
| Panel and MCP actions | Blocked (D-0012) |
| Webhooks | Not dispatched (D-0031, D-0032) |
One row depends on the ingress backend (D-0024): a reverse proxy on a public-IP host renews certificates locally with ACME, so renewal continues while the primary is down; a host behind a Cloudflare Tunnel needs nothing renewed locally because the tunnel terminates TLS at the provider.
The data plane never infers intent. A node with no session to the primary applies the last desired state it persisted and nothing else. This is what makes “the control plane is down” a change freeze rather than an outage (D-0011).
Failure behaviour by component
| If this fails | What happens | What does not happen |
|---|---|---|
| The primary | Apps keep running; replicas serve reads and refuse writes they cannot forward (not_primary) | No automatic election before M6 (D-0016) |
| A replica | Nothing user-visible; the primary keeps serving | No state is lost, because the primary is the only writer (D-0012) |
| A node | Its apps stop; its session drops and node.disconnected fires; the app is reported down or degraded | Nothing is started elsewhere automatically before M6 (D-0016, D-0033) |
| The engine on one node | That node cannot create or restart microVMs; running microVMs continue unless the VMM died with it | The cluster does not rebalance; placement is explicit |
| The ingress on one node | Its apps are unreachable through that node; certificate.failed or app.down may fire | Traffic is not moved automatically before M5a, and never without a healthy endpoint to move to |
| The witness | Losing it removes the third vote: with two remaining voters no automatic decision (M6) can be taken safely, and expiring leases stay renewable while the authority itself is reachable (D-0017) | Nodes do not self-fence merely because the witness is down (D-0047) |
| A provider (DNS, LB, object store) | The operation that needed it fails with a clear error; state stays consistent | Backups do not half-write: a failed upload leaves no chunk referenced by a manifest |
The third column is as important as the second. Every failure mode that could be “helpfully” automated is left manual until the milestone that owns it, because the alternative in a partition is two writers (D-0016, D-0019).
5. Nodes: autonomy and dial-out
Enrollment is initiated by a human: node:enroll creates a one-time join token, and the node redeems it to receive its identity (D-0011; scope and prefix from ../contracts/README.md). The node generates its own keypair; the private key never leaves the machine.
The sequence:
- A human with
node:enrollcallsPOST /api/v1/nodes/join-tokensand gets a one-timenjt_token (1 h default) and a join command. - The join command carries the primary URL, the token and the SHA-256 fingerprint of the primary’s TLS certificate, which the node pins: a certificate whose digest differs is refused, so a self-signed certificate is safe to accept.
- The node generates an Ed25519 keypair, keeps the private key locally, and calls
POST /api/v1/nodes/enrollwith the token, its name, its public key, its roles, its capacity and the KVM probe result. - The primary marks the token used (it is one-time), records the node with status
pending, emitsnode.enrolled, and returnsnode_id,cluster_id,primary_url, a node credential (shown once, stored hashed) and the cluster public key that signs desired state. - The node stores these and opens the session with
GET /api/v1/nodes/sessionusing the credential;helloandwelcomefollow on that session. pendingbecomesconnectedwhen the first session starts, andnode.connectedis emitted.
Reconnects reuse steps 5 and 6 only and never re-enroll: a lost credential is recovered by removing and re-enrolling the node by a human. That is deliberate: enrollment and reconnect must not be two implementations of the session. The flow is fixed by ../contracts/node-agent-protocol.md section 2.
After enrollment the node opens a long-lived WebSocket-over-TLS session to the primary and keeps it open. The direction is fixed: the node dials out, the control plane never dials in (D-0011). That is what lets the control plane live anywhere — including behind NAT itself — and lets nodes work from networks that accept no inbound connections.
On its side the node keeps:
- Desired state in a local SQLite store, written from
desired_statemessages and persisted before it is applied (D-0011). - A reconciler that compares desired state with observed state continuously, not only on message arrival. This is what makes a node converge after a crash, a reboot or a long disconnect.
- Health checks per instance, whose results become
status_reportmessages and, on failure,eventmessages (D-0032). - Crash restart with backoff: a VM that exits without being asked to stop is restarted locally (D-0011). Restarts are requested by bumping
restart_generationin desired state, so a repeated request is idempotent and a disconnected node still performs the restart it was last told to perform. - An event buffer, so events raised while disconnected are replayed on reconnect; the control plane deduplicates them by their event ids.
Desired state carries a monotonic rev. A node applies a desired_state only if its rev is newer than the one it holds, which makes re-delivery harmless and makes the “everything after N” catch-up in section 6 well defined.
Message types on the session, named exactly as in ../contracts/README.md and specified in ../contracts/node-agent-protocol.md:
| Direction | Messages |
|---|---|
| Node to control plane | hello, heartbeat, status_report, event, lease_renew, log_ack, stream_data, stream_end, command_result |
| Control plane to node | welcome, desired_state, lease_grant, lease_revoke, log_entries, stream_open, stream_close, command, error |
| Either | ping, pong |
The session is a WebSocket. That is a transport choice for a machine-to-machine protocol and is independent of the panel realtime split in section 13 (D-0057).
6. State: SQLite and the change log
Control-plane state lives in SQLite on the primary (D-0015). SQLite is chosen because “one binary, one command” rules out a database server, and because the MVP has a single writer anyway (D-0015).
Every state change is one numbered, hash-chained entry in the change log, appended in the same SQLite transaction that updates the state tables (D-0013, D-0015). The state tables are derived: they can be rebuilt by replaying the log from genesis. That property is what makes replication, verification and recovery one mechanism instead of three.
Change-log entry fields (schema in ../contracts/db-schema.sql):
| Field | Meaning |
|---|---|
seq | Gap-free sequence number, starting at 1 |
term | Leader epoch; always 1 until elections exist, prepared for D-0016 |
time | Timestamp of the change |
actor | The identity that caused it: human, agent, or system for reconciliation |
entity_type, entity_id | What changed |
op | The operation on that entity |
payload | The change itself, as JSON |
prev_hash | Hash of the previous entry; 64 zeros for genesis |
hash | SHA-256 over the RFC 8785 canonical JSON of the entry without hash |
A replica asks “everything after N” and verifies the chain as it applies entries (D-0013). Verification is the same check the audit log uses for its own chain (D-0031), so tamper evidence on state comes for free.
How replication runs:
- The replica sends its last verified
seqN when it connects, and after that as it advances. - The primary streams entries after N in order, each with its hash and
prev_hash. - The replica verifies each hash before applying it, and stops at the first mismatch rather than applying past it. A mismatch is an incident, not a retry.
- Entries are applied in the same order and in transactions, so a crash mid-batch leaves a consistent state at some
seqrather than a torn one. - The replica records its verified
seq, which is what it reports on reconnect.
Because state tables are derived, recovery from a bug in a state table’s logic is “fix the code, replay the log”, not “restore a backup and lose the tail” (D-0015). The log is the durable artifact; the tables are a materialised view of it.
term is part of the entry from M0 even though it is constant at 1 while there is one primary: it is prepared for the election work of D-0016 and cannot be added later without rewriting history.
Some tables are local and are not replicated: sessions, access_tokens and webhook_deliveries (marked local in ../contracts/db-schema.sql). api_tokens and agent_keys are replicated, but only as hashes, so a replica holds no usable credential. Two reasons:
- Secrets. Session tokens and agent access tokens are the local secrets. Copying them to every replica node multiplies the places they can leak from, and a replica does not need them to serve reads on behalf of a caller who authenticated to that replica.
- Churn. Webhook delivery attempts change on every retry and are of interest only to the node that dispatched them. Replicating them would put the noisiest table on the replication path.
Backing up the primary’s database off-site is not in the MVP (D-0033), but the design has to name a path. Litestream was suggested in D-0015; Litestream 0.5 has no client-side encryption, which conflicts with the encrypted-backup expectation of D-0023 (F-0040). D-0069 settles the default: SQLite and catalog-template backups go through Quoro’s own chunk engine with client-side encryption, taken from consistent snapshots via the SQLite backup API or VACUUM INTO. Litestream is not used. The rejected and optional paths, kept for the record:
| Option | What it means | Consequence |
|---|---|---|
| 1. Accept SSE-C | Litestream encrypts nothing; the bucket does it, with a key Quoro holds and sends per request | Provider stores ciphertext at rest, but the key travels with each request |
| 2. Pin Litestream 0.3.x with age | The older line still has client-side encryption | Its 0.3 line is unmaintained (hypothesis); not used (D-0069) |
| 3. Quoro’s own chunk engine | Periodic consistent snapshots via VACUUM INTO or the SQLite backup API, encrypted client-side like every other chunk (D-0023) | Loses Litestream’s ~1 s RPO. This is the decided path (D-0069) |
| 4. Encrypting S3 proxy | An encrypting proxy in front of the bucket | One more component on the backup path |
Litestream with SSE-C may be added later as an optional backend for a lower RPO (D-0069).
Cluster keys
Decided (D-0050), three keys:
- Cluster signing key (Ed25519). Signs
desired_stateand lease messages; nodes pin its public half at enrollment, so losing or rotating it is a cluster-wide re-pin. - Cluster data-encryption key (DEK, 256-bit XChaCha20-Poly1305). Encrypts every
*_enccolumn; a*_enccolumn stores the key id, the nonce and the ciphertext, so a row records which key wrote it. - Node-local key. Encrypts a node’s local copy of the secrets for the apps placed on it.
Storage and rotation: the keys are generated at first start and stored as 0600 files in /var/lib/quoro, outside the SQLite file, and backed up separately, because losing the data-encryption key loses every secret. The signing key rotates through the node-agent message signing_key_rotate, signed by the old key. The data-encryption key rotates by re-encrypting rows under a new key id. Custody of both keys when a replica is promoted is decided together with manual promotion in M5b (D-0067); the open part is whether the keys are replicated wrapped to each replica’s node key or re-keyed with an operator-confirmed re-pin.
7. Telemetry locality
App logs and metrics stay on the node (D-0014). Shipping them all to the primary would make the primary the bottleneck and the single point of failure for observability, which is exactly where it is least affordable.
Logs. The minimal init forwards the app’s stdout and stderr over the microVM’s virtio-console to the node agent, which writes size-capped local files (F-0034 covers which console types Cloud Hypervisor offers). The panel and MCP read logs through the primary, which opens a stream_open on the node’s session on demand and relays stream_data back; the primary never stores app logs (D-0014). A bounded pull of app logs is a command of kind logs_query answered by command_result; a live view is stream_open (kind app_logs), stream_data, and stream_end / stream_close. log_entries and log_ack are not app-log reads: they are change-log follow messages (section 6).
Metrics (M4). Per-VM counters come from the engine, which reads them from Cloud Hypervisor (docs/research/cloud-hypervisor.md); host metrics come from the host. They are sampled and kept on the node, and the panel reads them through the primary on the same on-demand path as logs (D-0014).
Optional archiving of telemetry to S3-compatible storage arrives later (D-0014) and is not assigned to a milestone.
If the node is unreachable when a read is attempted, the API returns node_unreachable (504), which says what happened: the state is known to the primary, the telemetry is not reachable right now (../contracts/README.md).
Log pipeline and retention
- The minimal init is the app’s parent process inside the microVM and forwards stdout and stderr over the virtio-console (F-0034). Line boundaries are preserved; partial lines are flushed after a short timeout so a chatty app cannot hold the last line forever.
- The node agent writes size-capped local files and rotates them. The cap is what keeps a log flood from filling the node’s disk, which would take the node down and its apps with it.
- Reads are on demand. A pull of a bounded range is a
commandof kindlogs_queryanswered bycommand_result; a live view usesstream_open(kindapp_logs),stream_dataandstream_end/stream_closeon the node’s existing session, so a log view needs no inbound connection to the node (D-0011, D-0014). - The primary relays and forgets. It holds no copy after the stream ends, which is why the primary’s disk usage does not scale with app traffic (D-0014).
- Archiving to S3-compatible storage is optional and later (D-0014). When it exists, it is a node-side upload from the node that holds the logs, not a transfer through the primary.
8. Engine layer
The engine sits behind an Engine trait (D-0007, D-0009), specified in ../contracts/engine-trait.md. The trait is the only way the node agent touches virtualization: create, start, stop, delete, and — for M5 — snapshot and volume diff hooks. Cloud Hypervisor is the first implementation; a container runtime is a possible later implementation (D-0009); a fake in-memory engine ships for tests on machines without KVM.
Cloud Hypervisor specifics:
- One VMM process per microVM, driven over its HTTP/JSON API on a UNIX socket (
docs/research/cloud-hypervisor.md). The engine owns the process lifecycle; a crashed VMM surfaces asvm.crashedin the node’seventstream. - License: Apache-2.0 AND BSD-3-Clause (F-0036). Cloud Hypervisor is Apache-2.0 AND BSD-3-Clause; compatibility with an AGPL-3.0 Quoro that runs it as a separate process is a Hypothesis (section 19).
- Host requirements: a KVM-capable host (D-0009) and host kernel >= 6.8 (F-0036).
- Nested virtualization: rare on cheap VPS. Hetzner Cloud, Hostinger, Contabo VPS and netcup VPS state that they do not support it (F-0044,
docs/research/vps-nested-virt.md). The installer therefore probes/dev/kvm, the vmx/svm CPU flag and a real microVM boot instead of trusting provider marketing. A host that fails the probe is refused during install rather than at first deploy. - Image path: OCI image -> root disk -> boot with a minimal init (D-0008). Quoro ships its own minimal Rust init and its own OCI-to-ext4 pipeline (D-0061), validated by the M0 spike (
spikes/oci-microvm); the pipeline resolves layer paths inside the rootfs withopenat2andRESOLVE_IN_ROOT, and also covers zstd layers and config delivery through a config disk or the kernel command line. The guest kernel is built by Quoro and pinned reproducibly (D-0027). - Consoles are text-only. Cloud Hypervisor has no VNC, virtual GPU or framebuffer device (F-0034). The microVM console is the serial or virtio-console UNIX socket, relayed by the node agent, so consoles are xterm.js over serial or virtio-console only.
- Disks and snapshots. Cloud Hypervisor snapshots hold guest RAM and device state, not disk contents, and there is no incremental or dirty-bitmap API (F-0035). The chunk engine (D-0023) therefore needs its own disk snapshot primitive. The interim primitive is reflink snapshots plus a full content-defined chunk scan for M5a cold backups, and a spike comparing lvm-thin with btrfs before M5b picks the primitive for warm backups at few-second intervals (D-0062); the
Enginetrait’ssnapshot_volumehooks keep it swappable. - virtio-fs needs an external virtiofsd (
docs/research/cloud-hypervisor.md). The engine therefore does not depend on it: apps boot from a disk image, and virtio-fs is at most an optimisation for a later milestone. - No live migration (D-0034). Moving a running microVM between nodes is not a goal; moving an app is a stop/start with a lease, not a migration.
The engine is the component with the largest surface for a mistake that costs data, which is why its contract is frozen at M0 and why the fake engine exists: engine behaviour must be testable without KVM, and CI must be able to run the node agent’s reconciliation logic on an ordinary machine.
9. Networking
Networking is deliberately simple: no SDN overlay, no VLANs, no distributed switch (D-0034). Each node is its own L2 island with a small amount of local plumbing, and cross-node traffic is carried by a mesh VPN (D-0010).
Per node:
- A Linux bridge that carries the node’s microVMs and the ingress.
- One TAP device per microVM, created by the engine and attached to the bridge.
- A private subnet per node, carved from one cluster-wide range. No two nodes hand out the same addresses, which keeps routes unambiguous when traffic crosses the mesh.
- nftables rules in two groups: egress NAT, so a microVM can reach the internet through the node’s address; and isolation between microVMs, so no microVM reaches another project’s microVMs by default. Flows into a microVM exist only from the ingress on the same bridge.
- Ingress forwarding to the microVM’s private address. The ingress is the only component that talks to the public side.
The bridge, the subnet carving and the address plan are this document’s proposal for the engine workstream, not a decision: D-0011 and D-0008 fix that microVMs exist and are isolated, but not how the host network is laid out. Two properties are requirements rather than taste: a microVM must not reach another project’s microVMs by default, and the address plan must survive a node being added or removed without renumbering existing microVMs.
A worked proposal for the address plan: one cluster range (for example 10.42.0.0/16), one /24 per node, the bridge on the first address of that /24, and microVMs allocated upward from it. The point of writing it down is that the mesh needs stable peer addresses, and a per-node /24 gives them without a central allocator after enrollment.
| Object | Scope | Purpose | Removed when |
|---|---|---|---|
| Bridge | One per node | Carries microVMs and the ingress on one L2 segment | Node removed |
| Node subnet | One /24 per node | Stable private addresses without a central allocator | Node removed, and never reused while any microVM from it is recorded |
| TAP device | One per microVM | Attaches the guest’s virtio-net to the bridge | MicroVM deleted |
| nftables NAT rules | One per node | Egress from microVMs to the internet through the node’s address | Node removed |
| nftables isolation rules | One per node | No microVM reaches another project’s microVMs by default | Node removed |
| Forward rules | One per domain | Ingress to the microVM’s private address, per route table entry | Domain removed or instance replaced |
Two rules keep this from drifting. Addresses are recorded in state, so a reinstall of a node does not hand out an address that a recorded volume or VM still refers to. And isolation is a default that a project can widen for itself, never one that another project can widen for it.
Node mesh. Nodes are independent machines, often at different providers (D-0010), so cross-node traffic — edge-to-replica traffic in M5a, node-to-node volume replication for warm in M5b, the chunk engine, and any future node-to-node call — runs over WireGuard (D-0010). The mesh is kernel WireGuard configured by Quoro over netlink, with no wg-quick, per-node keypairs and peers plus allowed IPs in desired state; userspace boringtun is only a fallback, and an own implementation is rejected for crypto risk (D-0063). It is first needed in M5a, for cross-node replica traffic, and M2 does not need it; the control-plane session does not depend on it, because that is TLS dial-out (D-0011), so a single-node M2 works with no mesh at all.
Two nodes behind NAT cannot reach each other directly. The mesh therefore assumes at least one publicly reachable peer — a public node or the edge — as a relay hub, or falls back to the dial-out path (D-0063). This is a limitation of the mesh, not of the control plane.
10. Ingress
Ingress is one Ingress interface with two backends (D-0024):
| Backend | For | How TLS happens |
|---|---|---|
| Reverse proxy with ACME | Hosts with a public IP | ACME (HTTP-01 or TLS-ALPN-01) obtains and renews certificates locally |
| Cloudflare Tunnel | Hosts behind NAT | cloudflared dials out; TLS terminates at the provider |
The reverse proxy is an embedded Rust proxy — hyper + rustls + an ACME client crate — behind the same interface, which keeps one binary and full control of health-aware routing and slow start at the cost of owning the ACME edge cases (D-0059). Caddy driven through its admin API stays a fallback backend, to be used if the M2 spike shows that owning ACME is too costly; crate maturity is a research task before M2.
The route table is derived from desired state: each domain maps to the app instances that may serve it (D-0024). It is written by the control plane and applied by the node, so a route change is a state change with a change-log entry, and a node that cannot reach the primary keeps serving the last applied table (section 4).
Findings to design around (docs/research/cloudflare-lb-and-tunnel.md):
- cloudflared replicas of one tunnel are identical copies, routed to the nearest replica, with no app health check.
- The load balancer treats all replicas of one tunnel as a single endpoint, and has no TCP monitors for tunnel origins (F-0046).
- An HTTPS monitor whose
Hostis the app hostname does check the app through the tunnel; it is proxied by cloudflared to the app (F-0046, which corrects the broader reading of F-0043).
The consequence is a design rule: per-node failover needs one tunnel per node behind a load balancer, not several replicas of one tunnel. Replicas of one tunnel fail over the connector, not the node: if the node behind the tunnel dies, every replica of that tunnel dies with it, and the load balancer cannot see the difference because it sees one endpoint. One tunnel per node turns each node into a distinct endpoint that a monitor can mark down.
Load balancing with health monitors is a paid add-on of the tunnel provider (docs/research/cloudflare-lb-and-tunnel.md); the tunnel backend without it gives connector redundancy only, which is why the tunnel path is documented as best-effort until M5a.
Certificates live on the node that serves them (D-0024). There is no central certificate store to synchronise and no certificate secret in the replicated state: an ACME certificate stays in the node’s own storage, and a tunnel host has no local certificate at all.
11. Providers
Anything Quoro asks of an external service goes through a provider interface (D-0025). Cloudflare is the first implementation, not the only one: the interface exists so the community does not read Quoro as vendor lock-in.
| Interface | Used for | Since |
|---|---|---|
DnsProvider | Creating and updating the records that point a domain at the cluster | M2 |
LoadBalancerProvider | Registering and deregistering endpoints, health monitors | M5a |
ObjectStore (S3-compatible) | Cold backups (M5a), telemetry archive, off-site audit copy from M5a (D-0053) | M5a |
LeaseArbiter | Leases from M5b when an external arbiter is used | M5b |
ObjectStore probes capabilities at setup instead of assuming them:
- Conditional PUT (
If-Match/If-None-Match). Not portable: R2 and AWS S3 support it, MinIO AIStor documents it, Backblaze B2 apparently does not (F-0038). Anything that depends on it — the conditional-write lease arbiter in section 14 — must be probed per provider at setup. - Object lock. Not yet researched: whether a given provider supports it, and in which mode, is a Hypothesis per provider. The off-site audit copy depends on it (D-0031) and is gated by an object-lock probe per provider (D-0053); a research task verifies compliance-mode object lock for R2, S3, B2 and MinIO before M5a.
- SSE-C. Presence and behaviour are recorded by the probe, because an optional Litestream backend with SSE-C would depend on it (D-0069, F-0040).
R2 specifics: one write per second per key, and no conditional delete (F-0037). Those two limits rule out using a single object as a high-frequency lease register and rule out a delete-as-lock-release pattern; any arbiter built on R2 must renew at a rate below the write limit and must treat lease release as an overwrite, not a delete.
Backups target any S3-compatible store — R2, B2, MinIO (D-0025) — because a self-hosted tool that only backs up to one vendor is not self-hosted in the part that matters when a machine dies.
Provider credentials
Provider API tokens, tunnel tokens and S3 keys reach Quoro through a human-only providers resource (D-0051):
| Method and path | Purpose |
|---|---|
POST /api/v1/providers | Create a provider entry (kind dns, tunnel, lb or object_store) |
GET /api/v1/providers | List entries; secrets are never returned |
GET /api/v1/providers/{provider_id} | Read one entry; secrets are never returned |
DELETE /api/v1/providers/{provider_id} | Remove an entry |
POST /api/v1/providers/{provider_id}/rotate | Replace the secret of an entry |
The resource needs the human-only scope provider:manage. Entries live in the replicated providers table, with the secret in secret_enc under the data-encryption key (D-0050). Secrets are write-only and are redacted from audit parameters, so neither a read call nor an audit record returns one. The audit-copy credential is a separate, put-only object_store entry, so backup credentials cannot delete the audit copy.
12. Identities, grants, audit and the AI-native loop
Identities
Humans (usr_, roles owner, operator, viewer) and agents (agt_: name, model, owner) are separate identities (D-0029). They are separate because the questions are different: for a human, “who”; for an agent, “which agent, on whose behalf, and because of what” (D-0029, D-0031). Role bundles fix which scopes a human has; the full mapping is in ../contracts/README.md and ../contracts/permission-model.md.
These are kept apart on purpose:
| Thing | Id | Authorizes | Lifetime | Stored where |
|---|---|---|---|---|
| A human | usr_ | What the role bundle allows | Until the user is removed | Replicated |
| A human’s API token | tok_ | What that human’s role allows | Until revoked | Replicated (hash only) |
| An agent | agt_ | Nothing; it is only an identity | Until disabled | Replicated |
| An agent key | akey_ | Nothing by itself; it can only be exchanged for an access token (D-0030) | Until rotated | Replicated (hash only) |
| An agent access token | atk_ | Whatever the agent’s active grants allow at request time | Minutes | Local (hash only) |
| A grant | grt_ | Scopes on selected resources until TTL or uses run out (D-0030) | TTL, optional max uses | Replicated |
The split is what makes revocation immediate: the access token carries identity, not permission, and permission is read from the grants on every call. A token that is still valid but whose grant is gone authorizes nothing (D-0030). It is also why access tokens and sessions are local and unreplicated (section 6): they are short-lived secrets, and a replica does not need them to answer a read for a caller who authenticated to that replica. The token and key tables that do replicate carry hashes only, never a usable secret.
Grants
Agents hold an agent key (akey_) that authorizes nothing by itself. They exchange it for a short-lived access token (atk_), and every request is checked against the agent’s active grants at request time, so revoking a grant locks the agent out on its next call rather than when a token happens to expire (D-0030).
A grant (grt_) is: scopes + resource selector + TTL + optional max uses (D-0030). Selectors are a set of projects, apps and nodes, matched by “any”; {"all": true} matches everything and {} matches nothing (../contracts/README.md).
Three properties of the model matter architecturally:
- Incident grants. When an event wakes an agent, the grant issued with it carries only the scopes the binding names, selects only the affected resource, and expires with the incident or its TTL (D-0030, D-0032). The agent cannot widen it.
- Destructive scopes need JIT approval. The agent files an approval request (
apr_), a human approves in the panel, and a short-lived extra grant is issued (D-0030). Approvals are checked in the service layer, not in the MCP layer, so no channel can bypass them. - Human-only scopes are never grantable.
node:enroll,node:remove,agent:write,grant:write,approval:decide,webhook:writeanduser:writecannot appear in an agent’s grant, so an agent cannot widen its own power, approve its own request, or create another agent (../contracts/README.md).
Refusals are part of the contract, not an afterthought: a refused request carries forbidden with a reason (scope_missing, selector_mismatch, grant_expired, grant_revoked, grant_exhausted, human_only, agent_disabled), and an action that needs approval carries approval_required with the scope and target so the agent can file the request itself (../contracts/README.md).
Audit
The audit log is a separate append-only hash chain, with each record appended in the same transaction as the action it records (D-0031). Refusals are recorded too: a denied restart is as interesting as a granted one, and the M3 demo relies on it (D-0033). A record holds who, on behalf of whom, the triggering event, the tool or endpoint, the parameters, the result and the agent’s justification (D-0031); the format and its verification are fixed in ../contracts/audit-log-format.md. The log can be copied off-site to object storage with object lock, out of reach of whoever operates the server (D-0031).
| Record part | Answers | Source |
|---|---|---|
| Actor | Who acted | The authenticated identity: usr_, agt_, nod_ (node) or system |
| On behalf of | Whose authority | For an agent, the human who created the grant or the binding (D-0029) |
| Trigger | Why now | The evt_ or inc_ that woke the agent, when there is one |
| Channel and operation | What was called | api, panel, mcp, cli or system, plus endpoint or tool name |
| Parameters | With what | The request parameters, with secrets redacted |
| Result | What happened | Success, or the error code and forbidden reason |
| Justification | In the actor’s words | Quoro-Justification, or the tool’s justification argument |
The log is a chain, not a table with a timestamp column: each record includes the previous record’s hash, so removing or editing an entry breaks every later link. audit.chain_broken is the event that says a verification found one (D-0031; ../contracts/README.md).
The channel of every action is recorded: api, panel, mcp, cli, system (../contracts/README.md). That is what makes “the agent and the human are logged the same way” a property of the design rather than a promise (D-0002).
MCP
The MCP server is rmcp’s Streamable HTTP transport mounted on the same Axum server at /mcp, plus a stdio mode through the CLI (F-0041). It calls the same service layer as the HTTP API, with the same scopes and the same audit log; there is no second path (D-0031). Authentication is the same bearer access token as the API, and every mutating tool takes a required justification and an optional incident_id, the MCP equivalents of the Quoro-Justification and Quoro-Incident headers (../contracts/README.md).
The tool set is the one fixed in ../contracts/README.md: reads for cluster, nodes, projects, apps, deployments, logs, events, incidents, approvals and audit, plus restart_app, redeploy_app, delete_app, resolve_incident, request_approval and get_approval. Each tool maps one-to-one onto an endpoint and needs the same scope.
The AI-native loop
D-0032 defines a four-step loop — detect, notify, wake, act. Each step has a named mechanism here; the incident sits between detection and notification.
- Detect. Node agents run health checks and emit
eventmessages; the control plane also derives detections from state (node.disconnected,certificate.expiring). - Event. The control plane records the event, which can be one of the types in ../contracts/README.md —
app.downfor a health check past its threshold,vm.crashedfor an unexplained exit,node.disk_low,deployment.failed. - Incident. An event that a webhook binding is interested in opens an incident (
inc_), and the incident scopes the grant the agent will get (D-0030, D-0032). - Wake. The webhook dispatcher delivers a signed event to the configured endpoint (
Quoro-Signature, five-minute freshness window, retries withwebhook_deliveriesrecords). The receiver — an agent runner — starts the agent with Quoro’s MCP endpoint and the incident context. - Act. The agent acts under its incident grant. Anything destructive stops at an approval request and waits for a human (D-0030).
Events are defined in ../contracts/events.schema.json. The loop is the M3 deliverable (D-0033).
Agent runner. The wake step needs a runner that receives the webhook delivery, verifies Quoro-Signature, reads WebhookWake.agent_id and the incident id from the payload, holds that agent’s own agent key (akey_) configured by the operator at the runner (Quoro never sends credentials in a webhook), mints a 5-minute access token via POST /api/v1/auth/agent-token, and acts through Quoro’s MCP endpoint within the incident grant, passing incident_id and justification on every mutating tool. The runner is a reference example in examples/agent-runner/, outside the quoro binary, owned by the mcp workstream and delivered in M3 (D-0049); the runner contract is the one in this section.
13. Realtime transport (D-0057)
D-0057 decides the split, superseding D-0044: SSE for one-way streams and WebSockets for bidirectional sessions:
| Traffic | Transport | Endpoints |
|---|---|---|
| Server-to-client streams: event stream, live app logs, deployment progress | SSE (text/event-stream) | GET /api/v1/events/stream, GET /api/v1/apps/{app_id}/logs/stream |
| Bidirectional interactive sessions: web terminal, VM console (M4, xterm.js) | WebSocket | terminal and console endpoints |
| Node agent session | WebSocket | ../contracts/node-agent-protocol.md, outside D-0057’s scope |
The reasons for SSE on the read paths: it is plain HTTP, so it passes reverse proxies and Cloudflare without upgrade handling; it reconnects automatically; and Last-Event-ID maps directly onto the seq cursor of events and logs, which gives a resume point for free. The reason for WebSockets on the interactive paths is that a terminal needs both directions on one connection.
The claim that Axum supports both natively — an sse module and a ws feature — is a Hypothesis; it was not re-verified in the research stage. The cost of using two mechanisms is one more client-side code path; the benefit is that the common read-only case never needs a WebSocket.
14. High availability: prepared for in the MVP
HA and backups are out of the MVP but prepared for (D-0033). “Prepared for” is a specific claim: each item below is a field, message or path that exists in M0 to M4 and is exercised in M5 or M6, not a design that would have to be retrofitted.
| Item | Prepared in M0-M4 | Delivered in |
|---|---|---|
Change log with term (leader epoch) | M0 schema; constant 1 until elections (D-0013, D-0016) | M6 |
| Lease messages and the self-fencing path in the node-agent protocol | M0 protocol (D-0019) | M5b for expiry; the self-fence path ships from M0 |
Lease modes local and expiring | M0 protocol (D-0047) | M5b |
Lease states held, uncertain, expired, revoked | M0 vocabulary | M5b |
| Fencing tokens | M0 protocol field | M5b |
| Concurrent fences and a mid-fence crash | — | M5b, mandatory before expiring leases ship (D-0075) |
HA policy field on apps (none, cold, warm, hot) | M0 schema; only none implemented (D-0020) | M5a (cold), M5b (warm), M6 (hot) |
| Node resource reports feeding the N+1 indicator | Node agent reports from M2 (D-0021) | M5b |
| Manual promotion | Replica nodes follow the log from M2 (D-0013) | M5b (D-0067) |
| Witness and three votes | — | M5b (D-0017) |
| Edge as third voter | — | M5b (D-0018; a single edge is a traffic single point of failure) |
| Failback sequence | — | M5b (D-0022) |
| Chunk engine | — | M5a cold, M5b warm (D-0023) |
Lease modes
Two modes (D-0047), because D-0019 (self-fencing from M0) and D-0011 (apps keep running when the control plane is gone) would otherwise conflict during M1-M4, when there is only one control plane:
local: issued for HA policynoneorcold, where no other copy can start. It never expires when the arbiter is unreachable; it ends only by an explicitlease_revoke(move, delete, planned switchover) or when an operator declares the node lost, which requires a manual fence acknowledgement before a cold backup is restored elsewhere. That keeps D-0011 and the M2 demo true: stopping the control plane does not stop apps.expiring: issued forwarmandhotfrom M5b. Duration-based, measured on the node’s monotonic clock from when the renewal was sent, minus a safety margin. The state path isheld->uncertain->expired-> self-fence, and a lease is also revoked explicitly.
Fencing tokens are carried on every write to shared storage (D-0047), not only in the lease messages.
The distinction between two lease states is the core safety rule:
uncertainmeans “arbiter unreachable”: the node keeps running what it runs and starts nothing new. It has not lost the lease; it cannot prove it still holds it.expiredmeans “lease lost”: the node self-fences, stopping its stateful VMs (D-0019).
Conflating the two produces one of two failures: fencing on every network blip, or continuing to run a stateful app that another node has already been allowed to start. Treating them as separate states is what lets a partition be survivable on one side and safe on the other.
For an expiring lease the deadline is valid_until = t_send + D - M, where t_send is the node’s monotonic time when it sent the renewal the current grant answers, D is the grant’s duration_ms and M = max(1000 ms, D / 10) (../contracts/node-agent-protocol.md section 8.4).
| From | Event | To | What the node does |
|---|---|---|---|
| (none) | lease_grant | held | Starts the stateful app |
held | renewal failing, arbiter unreachable | uncertain | Keeps running, starts nothing new |
held | lease_revoke | revoked | Stops the stateful app |
held | monotonic clock reaches valid_until | expired | Self-fences: stops the stateful app |
uncertain | renewal succeeds | held | Resumes normal operation |
uncertain | monotonic clock reaches valid_until | expired | Self-fences: stops the stateful app |
expired | new lease_grant | held | Restarts only under the new grant’s token |
Two properties matter in the table. Recovery from uncertain back to held is silent, because nothing was given up; recovery from expired is not, because the app was stopped and the fencing token moved on. And no transition ever goes directly from uncertain to starting something, which is the whole point of the state.
The authority never grants the lease to another holder before D + 2M after its last grant, so the old holder’s fencing completes first (../contracts/node-agent-protocol.md section 8.4).
Fencing tokens increase on every ownership change and stay the same across renewals (D-0073); they travel on every write to shared storage — chunk store, object store, replication stream — and receivers reject stale tokens (D-0019). A token is what makes “the old holder is definitely not writing” enforceable by the storage layer rather than by good behaviour.
Lease arbiter options
| Option | Evidence | Consequence |
|---|---|---|
| Quoro’s own witness quorum | A witness is a free daemon in a different failure domain that breaks ties and stores no data (D-0017) | No external dependency; needs a third machine or a managed plan |
| R2 conditional PUT | Atomic If-Match / If-None-Match PUT with a single winner, but no TTL, one write per second per key, no conditional delete, and a 404 does not prove an in-flight PUT failed (docs/research/r2-conditional-writes.md, F-0037, F-0038) | Single winner is real; TTL must be emulated by timestamps, so fencing tokens are still required |
| Cloudflare Durable Objects | A strongly consistent single-threaded object with alarms for a server-side TTL; fits the free plan at a 5 s renewal per F-0039; acquire can be ambiguous when an object restarts; needs a Worker in the user’s account (docs/research/durable-objects.md) | Closest to a real lease; adds a Worker and a provider account to the critical path |
Both external arbiters still require fencing tokens (D-0019), and conditional-write support must be probed per provider at setup (F-0038). Neither is required for the self-hosted path: the witness is free and first-class (D-0017), and the external arbiters are what a managed offering can add.
Failback
Failback is never automatic and never immediate (D-0022). The six steps of the planned sequence:
- The returning node rejoins fenced, checks its leases and starts nothing stateful.
- It joins as a replica of the current lease holder, which is the source of truth.
- Its divergent writes are kept in a quarantined snapshot and recorded in the audit log, never silently dropped.
- Incremental resync runs from the last common point, and the node becomes the
warmcopy. - By default there is no failback: the roles simply swap.
- If the user prefers the original node, a planned switchover waits for replication lag near zero and a low-traffic window, freezes writes for seconds, does a final sync, moves the lease, shifts traffic at the load balancer and drains connections.
A planned switchover loses no data. Only a surprise failure can lose seconds, and the amount is bounded by the replication interval (D-0020, D-0022). Stateless apps need none of this: the load balancer slow-starts traffic back to the returning node (D-0022).
15. Key flows
Each flow is written as the sequence of calls, records and messages it produces, using the exact names from ../contracts/README.md. “Change-log entry” means one numbered, hash-chained entry appended in the same transaction as the state table update (D-0013, D-0015); “audit record” means one entry in the audit chain, appended in the same transaction as the action (D-0031).
Four rules hold in every flow:
- Authenticate, then authorize, then act. The scope check happens in the service layer, against the grants as they are at that instant (D-0030). No channel is exempt.
- One transaction, one record. The state change, its change-log entry and its audit record commit together, so a crash cannot leave an action without a record or a record without an action (D-0013, D-0031).
- Idempotent by key. Every creating
POSTcarries anIdempotency-Key; retries return the first result instead of creating a second object (../contracts/README.md). - Correlatable end to end. Every request has a
req_id, echoed asQuoro-Request-Id; event idsevt_and incident idsinc_carry the thread from detection to action (D-0029, D-0031).
(a) Deploy (M2)
- The caller sends
POST /api/v1/apps/{app_id}/deploymentswith scopeapp:deploy, anIdempotency-Key, and — if it is an agent —Quoro-Justification. The channel isapi,panelormcpdepending on the caller, and is recorded. - Authorization checks the caller’s identity, the scope against the app’s project through the grant’s selector, and whether a destructive scope needs approval (it does not;
app:deployis not destructive). A refusal is audited with itsforbiddenreason. - Change-log entries: the new
deploymentsrow with statuspending, and the app’s current deployment pointer. Audit record: who, scope, parameters, result. - Event
deployment.started. - The scheduler chooses a node and builds that node’s desired state at rev N+1, which now contains the new instance, its image reference and digest, and its domain.
- The primary sends
desired_statewith rev N+1 on the node’s session. - The node persists rev N+1 before applying it (D-0011), then prepares the image: pull, verify the digest, build the rootfs, cache by digest so a redeploy of the same image is free.
- The node creates and starts the microVM. The deployment status moves
preparing, thenstarting; the VM’s observed status is a separate enum and follows its own transitions. A stateful app starts only while its lease isheld(D-0019); otherwise the deployment stops here. - The health check passes, the node sends
status_reportandeventvm.started. - The primary records
deployment.succeeded, applies the route to the route table, and obtains a certificate through ACME or registers the tunnel route (D-0024). - The previous deployment becomes
supersededand its microVM is removed. For a stateless app the new instance starts before the old one stops; for a stateful app the old one stops first, because there is a single writer (D-0019). - Any failure in steps 7 to 10 ends as
deployment.failedwith the failing phase, which is one ofpull,rootfs,bootorhealth(the deploymentphasefield, not a status), and the node keeps the previous instance running where it still can.
(b) Restart by an agent (the M3 demo)
- A node health check fails past its threshold, and the node sends
eventapp.down. - The primary records the event and opens an incident
inc_, emittingincident.opened(D-0032). - The webhook binding that matches the event delivers a signed event to its endpoint: headers
Quoro-Event-Id,Quoro-Event-Type,Quoro-Delivery-IdandQuoro-Signature; a receiver rejects a signature older than five minutes. - An incident grant is issued and
grant.createdis emitted. Its scopes come from the binding, its selector is the affected app, and its TTL comes from the binding (D-0030). - The agent runner starts the agent with the incident context and Quoro’s MCP endpoint at
/mcp. - The agent exchanges its agent key (
akey_) for a short-lived access token (atk_). The key alone authorizes nothing (D-0030). - The agent reads state and logs through MCP:
get_app,get_app_logs(scopeapp:logs),list_incidentsandget_incident(scopeincident:read). - The agent calls
restart_appwith a justification and the incident id (scopeapp:restart, endpointPOST /api/v1/apps/{app_id}/restart). - The service layer checks the grant: scope, selector, TTL, uses remaining, and
agent_disabled. A refusal would be recorded with its reason (scope_missing,selector_mismatch,grant_expired,grant_revoked,grant_exhausted). - Change-log entry: the app’s
restart_generationis bumped. Audit record: channelmcp, the agent, the human who configured the binding as “on behalf of”, the triggering event, the tool, the parameters, the result and the justification (D-0029, D-0031). - Event
app.restarted. - The primary sends the new
desired_state; the node restarts the microVM and reportsstatus_reportwhen it is running again. - Event
app.upwhen the health check passes again. - The incident is resolved by the agent (
resolve_incident, scopeincident:resolve) or by a human;incident.resolvedis emitted, and the incident grant ends with the incident or when its TTL passes.
(c) Node loss
- The primary notices the session drop or missed heartbeats and emits
node.disconnected. The node’s status becomesdisconnected. - An incident can be opened and an agent woken, exactly as in (b), with scopes limited to the affected node and apps.
- The node itself keeps running and reconciling its last desired state; local leases stay
held, because alocallease does not expire while the arbiter is unreachable (D-0047). It redials with backoff and jitter. - Nothing is started elsewhere automatically before M6 (D-0016, D-0033). The panel shows the app as degraded and the N+1 indicator answers whether its apps would fit on the remaining nodes (D-0021).
- To restore a stateful app elsewhere from a
coldbackup (M5a), an operator must first declare the node lost, as part of thelocallease mode (D-0047), and acknowledge the manual fence. That revokes its leases and increments the fencing token, so a node that comes back cannot keep writing (D-0019). - From M5b, with
expiringleases: the node’s renewals start failing, it goesuncertain(keep running, start nothing new), thenexpired, self-fences its stateful apps, and buffersnode.fencedfor replay on reconnect; if a forced stop cannot confirm a VM stopped, it emitsnode.fence_failedinstead, marks itself failed, and the lease is not granted elsewhere until an operator acknowledges the fence (D-0074). - A
warmcopy may start only after the old lease has expired on the arbiter’s side plus the safety margin (D-0019). That ordering, not the detection time, is what prevents two writers.
(d) Node return
- The node redials and sends
hellocarrying its applied desired-state rev, its change-log position and the leases it believes it holds. - The primary sends
lease_revokefor any lease that is no longer the node’s, before anything else. - The node stops those stateful VMs first, before applying anything else (D-0022).
- Divergent writes, if any, are kept as a quarantined snapshot and recorded in the audit log (D-0022; M5b). Nothing is discarded silently.
- The primary sends the full
desired_state; the node reconciles, and no duplicate app appears — this is the M2 demonstration that reconciliation is idempotent (D-0011). - Change-log follow resumes from the node’s last verified
seq(D-0013). - There is no automatic failback: a planned switchover is a separate, explicit action (D-0022).
16. Stack confirmation (D-0056, superseding D-0028)
D-0056 fixes the baseline stack, keeping the same items D-0028 listed and replacing the graphical console with text-only consoles. This section confirms it item by item, with the refinements the research forced; D-0057 adds the realtime split.
| Item | Baseline stack | Status here | Notes |
|---|---|---|---|
| Axum + Tokio | Control plane and API in Rust on Axum + Tokio | Confirmed | One server hosts the API, the MCP endpoint and the embedded panel |
| sqlx + SQLite | sqlx with SQLite | Confirmed (D-0015) | One writer, no database server; the change log and derived tables are the schema |
| Node agent | Rust static binary | Confirmed | It is part of the single binary per D-0012, so it is a role rather than a separate artifact |
| MCP | rmcp, the official Rust SDK | Confirmed (D-0048) | rmcp 3.8.0, three semver-major releases between March and July 2026 (F-0041): pinned to a minor version and majors are budgeted |
| API contract | OpenAPI via utoipa -> TS client (orval or openapi-ts) | Confirmed (D-0048) | The hand-written docs/contracts/openapi.yaml is frozen; CI diffs utoipa’s output against it; every response needs a non-empty description because of F-0042; orval generates the panel client |
| Panel | React + TanStack Router/Query/Table, shadcn/ui + Tailwind, rust-embed | Confirmed (D-0048) | The panel is built against a client generated from the frozen spec by orval |
| Consoles | xterm.js; no noVNC (D-0056) | Confirmed (D-0056) | There is no VNC (F-0034): Cloud Hypervisor has no display device, so consoles are text-only, xterm.js over serial or virtio-console |
| Metrics | uPlot | Confirmed | Fed by on-demand node telemetry (D-0014) |
| Realtime | SSE for one-way streams, WebSockets for bidirectional sessions (D-0057) | Confirmed (D-0057) | Section 13: SSE for the event stream, live logs and deployment progress; WebSockets for terminal and VM console |
| Builds | Dockerfile only, run by BuildKit inside an ephemeral builder microVM, output to a node-local OCI layout (D-0060) | Confirmed (D-0060) | The OCI layout feeds the normal OCI-to-rootfs path; that BuildKit runs under Cloud Hypervisor is a Hypothesis (section 19); Railpack/Nixpacks are re-verified before M4, and a zero-config builder may be added later as a second Builder implementation |
| Docker client | bollard | Not needed | Confirmed: there is no Docker on nodes (D-0008) |
| Backup | Quoro’s own chunk engine with client-side encryption (D-0069) | Confirmed (D-0069) | Litestream 0.5 has no client-side encryption (F-0040), so SQLite snapshots come from the SQLite backup API or VACUUM INTO and are chunked and encrypted locally; a Litestream backend for a lower RPO may be added later as an option (D-0069) |
Two of the “confirmed” rows carry a caveat about the dependency rather than about the choice. rmcp’s release cadence means the MCP layer must be isolated behind the service-layer boundary so a minor-version upgrade is a contained change (D-0048). utoipa’s empty-description behaviour (F-0042) means the API contract has a lint rule, not just a convention: every response must carry a non-empty description. Every dependency in the list must still be justified individually (D-0056).
Dependency justification
D-0056 requires that every dependency be justified, so each one above carries a reason rather than a precedent:
| Dependency | Why it is there | What would remove it |
|---|---|---|
| Axum + Tokio | The API, MCP and panel all need one async HTTP server; Tokio is its runtime | Nothing realistic in Rust |
| sqlx + SQLite | Compile-time-checked queries against the one database the design has (D-0015) | A different database, which D-0015 rules out for the MVP |
| rmcp | The official MCP SDK (F-0041); writing the protocol by hand would be more surface, not less | A smaller, stable SDK |
| utoipa + orval | Type-safe client generation from the frozen spec; hand-written clients drift | A generator with no empty-description bug (F-0042) |
| React + TanStack + shadcn/ui + Tailwind | The panel; the TanStack pieces are the data and routing layer | Nothing planned |
| rust-embed | One binary, no asset directory to lose (D-0012) | Nothing planned |
| xterm.js | Text consoles over the serial or virtio-console (F-0034) | Nothing planned |
| uPlot | Metrics rendering without a large charting stack | Nothing planned |
| hyper + rustls + an ACME client | The embedded reverse proxy that serves ingress on hosts with a public IP (D-0059) | The M2 spike showing that owning ACME is too costly, in which case Caddy’s admin API becomes the backend |
| BuildKit, in an ephemeral builder microVM | Dockerfile-only builds in M4 (D-0060) | A different builder backend behind the same Builder implementation |
Not on the list: bollard, because there is no Docker on nodes (D-0008); noVNC, because consoles are text-only (D-0056, F-0034); a container runtime, because a container Engine is a later implementation (D-0009).
17. Workspace layout
This is the planned M0 cargo workspace layout. It follows the rule that each workstream owns its paths (D-0036), one crate or package per workstream, so that parallel work rarely touches the same file.
| Path | Owner workstream |
|---|---|
crates/quoro/ | control-plane — the binary: CLI, role wiring, startup |
crates/types/ | control-plane — ids, scopes, event types and shared domain types generated from the contracts |
crates/control-plane/ | control-plane — API, services, auth, grants, audit, store, scheduler, webhooks |
crates/proto/ | node-agent — node-agent protocol messages |
crates/node-agent/ | node-agent — local store, reconciler, session, leases |
crates/engine/ | engine — Engine trait, Cloud Hypervisor implementation, fake engine, networking, OCI-to-rootfs |
crates/init/ | engine — the guest minimal init |
crates/ingress/ | ingress — the Ingress interface and the proxy backend |
crates/providers/ | ingress — DNS, load balancer, object store, Cloudflare |
crates/mcp/ | mcp — rmcp tools over the service layer |
panel/ | panel — React application and its generated client |
installer/, packaging/, .github/workflows/, xtask/ | installer — install, release, CI, and contract validation |
Two notes on the table. docs/contracts/ is not owned by a workstream after M0: it is frozen, and a change there is a standalone pull request that touches only the contract (D-0036). And crates/proto/ is separate from crates/node-agent/ so that the control plane can depend on the message types without depending on the agent implementation, which is what keeps the protocol contract reviewable on its own.
Build and checks
The workspace exists to make the contracts checkable in CI, not only readable:
xtaskvalidates the contracts: the OpenAPI document parses, every response has a description, the event schema validates its examples, the SQL schema runs in a fresh in-memory SQLite database (../contracts/README.md).xtaskalso generates the utoipa document and diffs it against the frozenopenapi.yaml, failing on any semantic difference (D-0048).- The panel builds against a client that orval generates from the frozen spec, so a panel change that needs a new field needs a contract change first (D-0048; D-0036 for the contract-change rule).
- Releases are signed and reproducible, because the installer is
curl | sh(D-0027).
The dependency direction is one-way: crates/quoro depends on every role crate, crates/control-plane and crates/node-agent both depend on crates/types and crates/proto, and crates/engine depends on neither. That is what allows the engine and the control plane to be developed and tested in parallel, which is the reason parallel workstreams can run at the same time at all (D-0036).
Release and verification
D-0027 sets the requirements, and this document restates them so the installer workstream has one list:
- Every release binary is signed.
- The
curl | shinstaller verifies the signature against a published key before it runs anything. - Builds are reproducible, including the panel’s JavaScript bundle embedded with rust-embed.
- Provenance is published.
The scheme is not chosen — for example minisign, or Sigstore plus SLSA provenance — and the installer workstream proposes it before M1, pending maintainer decision.
18. Open questions and recommendations
Every open item this document depends on. None of them is decided here.
| Id | Question | Options | Recommendation | Needed by |
|---|---|---|---|---|
| — | Custody of the cluster keys when a replica is promoted to primary | (a) wrap both keys to each replica’s node key; (b) re-key on promotion with an operator-confirmed re-pin | (a), as recorded with D-0050 | M5b, with manual promotion (D-0067) |
Decided since the first draft, and therefore no longer listed above:
- Ingress proxy: D-0059.
- Git builds: D-0060.
- Image path, rootfs conversion and minimal init: D-0061.
- Disk snapshot primitive: D-0062.
- Node mesh: D-0063.
- Release milestones: D-0064.
- Manual promotion: D-0067.
- SQLite and catalog-template backups: D-0069 (answers Q-0026; a Litestream backend with SSE-C for a lower RPO may be added later as an option).
- Curated catalog: format, owner, location and signature scheme are decided at M4 planning, before the first template ships: D-0070 (answers Q-0060).
- HA policy costs for
noneandcold: D-0068 (answers Q-0025). - Docs-site timing: D-0065 (answers Q-0009).
- Prerequisites for shipping expiring leases in M5b: D-0075.
- Realtime transport: D-0057 (supersedes D-0044).
- Graphical console dropped from the baseline stack: D-0056 (supersedes D-0028).
- Lease modes
localandexpiring: D-0047. - API source of truth, generator and pinning: D-0048.
- Agent runner: D-0049.
- Cluster key algorithms, storage and rotation: D-0050.
- Provider credentials and the
providersresource: D-0051. - Node-local volumes in M5a: D-0052.
- Off-site audit copy in M5a: D-0053.
- Host layout, CLI and setup token: D-0054.
- Lab TLS for the M2 demonstration: D-0055.
Custody of the cluster keys on promotion is the only open row. Q-0024 fixed when promotion ships (M5b, D-0067) but not how a promoted replica obtains the keys; until promotion exists the keys do not leave the primary.
Q-0001 to Q-0006, Q-0009, Q-0024, Q-0025, Q-0026 and Q-0060 are decided (D-0059 to D-0065, D-0067 to D-0070) and are therefore absent from the table.
19. Hypotheses to verify
Every claim marked Hypothesis in this document, and what would settle it.
- Axum supports SSE and WebSockets natively. Used in section 13. Verify by building one
text/event-streamroute and one WebSocket route in the current Axum version and running them in CI; settle before the M3 event stream ships. - An object store can support object lock on every provider we target. Used in section 11 for the off-site audit copy. Verify per provider with a real bucket: create a locked object, try to overwrite and delete it, and record the result.
- The available Rust ACME and TLS crates are mature enough to own the proxy path. Used in section 10 and D-0059. Verify with a spike before M2: obtain and renew one certificate for a real domain through the embedded proxy.
- An encrypting S3 proxy or SSE-C is operationally acceptable as a backup path. Used in section 6, options 1 and 4, and D-0069’s optional Litestream backend. Verify by restoring a primary snapshot from the bucket in a drill, not by reading documentation.
- BuildKit inside a builder microVM works under Cloud Hypervisor. Used in section 16 and D-0060. Verify with an M4 spike that runs one Dockerfile build end to end and produces an OCI layout.
- Railpack/Nixpacks are viable zero-config builders, with Nixpacks in maintenance and Railpack as its successor. Used in D-0060, which re-verifies them before M4. Verify before M4 by building one real repository with each.
- Running Cloud Hypervisor as a separate process keeps the AGPL-3.0 and Apache-2.0/BSD-3-Clause licenses cleanly separated. Used in section 2. This is a legal reading, not a reviewed conclusion. Verify with a license review before the first public release, which is the M3 preview (D-0064): a lawyer confirms that process separation is enough, or the distribution model changes.
- A crash-consistent block copy of a running database is not sufficient, so native replication is needed. Used in section 14 and D-0023’s exception for databases. Verify by restoring a chunk-engine copy of a running database and measuring what it contains.
- A
locallease that never expires does not let two writers exist. Used in section 14 and D-0047. Verify with the M5b partition test: with the control plane unreachable, confirm that no other node can be told to start the stateful app. - The witness can be small enough to run on any cheap machine in a different failure domain. Used in section 3 and D-0017, which puts the witness in a different failure domain from the nodes. Verify in M5b by running it on a third machine and cutting each link in turn.
- Decision to verify, D-0018: a single edge is a traffic single point of failure. With the edge also a voter, losing it removes both the traffic path and one vote. D-0018’s mitigations are Cloudflare as the traffic edge with the small VPS acting only as witness, or two edges with DNS failover. Verify in M5b that the chosen mitigation keeps both traffic and quorum when the edge dies.
- One tunnel per node with an HTTPS monitor on the app hostname gives per-node failover. Used in section 10. Verify in M5a by powering off a node behind its own tunnel and confirming the load balancer removes that endpoint and keeps serving from the other.
- The load-balancer health check interval is short enough for the RTO the
warmpolicy promises. Used in section 14. Verify by measuring detection-to-recovery time in M5b. mkfs.ext4 -dplus a Rust OCI registry client is a workable rootfs pipeline. Used in section 8 and D-0061. Verify in the M0 spike by booting a microVM from a converted image.- virtio-fs is only an optimisation and never a requirement. Used in section 8. Verify by confirming the M2 deploy path boots from a disk image with no virtiofsd present.
Quoro contracts
Status: M0 draft for review.
These files are the interfaces that Quoro’s workstreams build against in parallel. They are written before any code (D-0040) and are the M0 deliverable named in D-0033.
Contracts freeze at the end of M0. After the freeze, a contract change is a standalone pull request that touches only the contract and is reviewed on its own; it is never mixed into feature work (D-0036). If an implementation needs something a contract does not allow, stop and open an issue proposing the change.
Index
| File | What it fixes | Main consumers |
|---|---|---|
| openapi.yaml | HTTP API for the M1-M4 surface (OpenAPI 3.1): auth, nodes, projects, VMs, apps, Git sources and builds, deployments, domains, logs, metrics, terminal and console sessions, agents, grants, approvals, incidents, audit, events, webhooks | control-plane, panel, mcp, installer |
| events.schema.json | JSON Schema (draft 2020-12) of the event envelope and every event type | control-plane, node-agent, panel, mcp, webhook receivers |
| node-agent-protocol.md | Enrollment, the dial-out session, messages, desired-state reconciliation, change-log follow, leases and self-fencing (D-0019) | node-agent, control-plane, engine |
| audit-log-format.md | Audit record format, hash chaining, verification, off-site copy with object lock | control-plane, mcp, panel |
| permission-model.md | Identities, scopes, resource selectors, grants (TTL, max uses), incident grants, JIT approval | control-plane, mcp, panel |
| db-schema.sql | Control-plane SQLite schema: numbered change log and the state tables derived from it (D-0015) | control-plane |
| migrations/ | Numbered schema migrations applied in order on top of the frozen db-schema.sql; each bumps schema_version | control-plane |
| engine-trait.md | The Rust Engine trait, its types and semantics; Cloud Hypervisor is the first implementation (D-0007) | engine, node-agent |
The architecture these contracts implement is described in ../architecture/architecture.md; threats and mitigations are in ../architecture/threat-model.md. Terms follow ../glossary.md.
Validation
Two checks run on every contract change.
scripts/check_contracts.py(Python 3 standard library only; runpython3 scripts/check_contracts.py):events.schema.jsonparses as JSON, its event-type list equals the Event types table, and every example’stypeis in that list and carries the schema’s required top-level keys;db-schema.sqlloads into an in-memory SQLite database, the migrations inmigrations/apply in order on top of it withschema_versionending at one plus their count, and its tables equal the Database tables list; inopenapi.yaml(checked line by line, without a YAML parser) the document declares OpenAPI 3.1, every operation hasx-quoro-scopewith a scope from the table or a special value, every response has adescriptionor a$ref, everyx-quoro-mcp-toolmatches the MCP tools table (endpoint and scope), and every event type is mentioned; every backticked scope in the markdown contracts exists in the Scopes table; the node-agent message names listed below equal the message headings of node-agent-protocol.md section 5, and every JSON example block in that file parses.- Full semantic validation of
openapi.yamlagainst OpenAPI 3.1 and ofevents.schema.jsonagainst JSON Schema 2020-12 needs third-party validators; it is not in the standard-library script and is added to CI with the cargo workspace in M0.
openapi.yaml is hand-written and is the frozen source of truth for the HTTP API (D-0048). The implementation’s utoipa output is generated and diffed against it in CI, and the panel client is generated from it with orval.
Shared vocabulary
Every contract uses exactly the names below. If a contract needs a name that is not here, add it here in the same pull request.
Roles and statuses
- Roles of the single
quorobinary (D-0012):node,witness,edge. They combine (for examplewitness+edge, D-0018). - Control-plane status of a node:
primary(single writer and source of truth) orreplica(holds a copy of the change log, serves reads, forwards writes) (D-0012). Primary and replica are statuses, not roles. - The witness role stores no data (D-0017). In M1-M4 the cluster has exactly one primary and no witness (D-0016).
Identifiers
All ids are strings <prefix>_<ULID> (ULID in Crockford base32, 26 characters), for example app_01JC9Z6Q5W8N3K4T7R2M1XHB0D. Ids are never reused.
| Resource | Prefix | Notes |
|---|---|---|
| cluster | cls_ | One per installation |
| node | nod_ | A machine running the binary |
| node join token | njt_ | One-time enrollment token |
| project | prj_ | Groups apps; also has a unique slug ([a-z0-9-]{1,40}) used in selectors |
| VM | vm_ | One microVM managed by the engine (M1). An app’s instances are VMs whose app_id is set |
| app | app_ | A deployable service; runs as one microVM per instance (D-0008) |
| deployment | dep_ | One rollout of an image to an app |
| build | bld_ | One image build of an app’s Git source, run on the app’s node (ADR 0117, ADR 0118) |
| domain | dom_ | A hostname routed to an app |
| volume | vol_ | Persistent disk of a stateful app (shape prepared, used from M5) |
| user (human) | usr_ | A human identity |
| API token (human) | tok_ | Personal token of a human |
| agent | agt_ | An AI identity: name, model, owner (D-0029) |
| agent key | akey_ | Long-lived agent credential that authorizes nothing by itself |
| access token (agent) | atk_ | Short-lived token minted from an agent key |
| grant | grt_ | Scope + selector + TTL (+ max uses) (D-0030) |
| approval request | apr_ | JIT approval for a destructive action (D-0030) |
| incident | inc_ | Opened by a detection; scopes incident grants (D-0030, D-0032) |
| event | evt_ | One event; also has a numeric seq |
| webhook | whk_ | Subscription that delivers events |
| webhook delivery | whd_ | One delivery attempt record |
| provider | prv_ | Credentials of one external provider (D-0051) |
| lease | lse_ | Lease on an app or on the primary status (D-0019) |
| request | req_ | Correlation id of one API or MCP call |
| interactive session | trm_ | One web terminal or VM console session (ADR 0114); not a panel login session |
Change-log entries, audit records and events also carry a numeric, gap-free seq per log.
Node-agent protocol messages carry msg_ ids. They are message-local (they pair a request with its answer inside one session), are never stored, and are not resource ids.
Keys
The cluster has three keys (D-0050):
- cluster signing key: Ed25519. It signs
desired_stateand lease messages, and nodes pin it at enrollment. Rotation uses the node-agent messagesigning_key_rotate, signed by the old key. - cluster data-encryption key: 256-bit key for XChaCha20-Poly1305. It encrypts every
*_enccolumn; a*_encBLOB stores the key id, then the 24-byte nonce, then the ciphertext with its tag, so that entries encrypted under different keys can coexist and are selected by key id, which is how rotation works. - node-local key: encrypts a node’s local copy of secrets and never leaves the node.
The cluster keys are generated at the primary’s first start and stored as 0600 files under /var/lib/quoro, outside SQLite. They are backed up separately from the database: losing the data-encryption key loses every secret it protects. Custody of the cluster keys on promotion is open (Q-0024): the candidate is to replicate both keys wrapped to each replica’s node key, against re-keying on promotion with an operator-confirmed re-pin.
Resource relationships
- A project contains apps. A VM may belong to an app (
app_idset) or stand alone (M1 lifecycle,app_idnull). Standalone VMs belong to a project too. - An app has deployments; the current deployment’s image runs as one or more VMs (instances) placed on nodes. In M1-M4 an app has one instance.
- An app is
statefulor not. Stateful apps have volumes and may run only on their lease holder (D-0019). - An app has an HA policy:
none,cold,warm,hot(D-0020). Onlynoneis implemented before M5a; the field exists from M0. - A domain belongs to one app and has a TLS mode:
acme(public-IP hosts) ortunnel(Cloudflare Tunnel) (D-0024). - An app either deploys images given by reference or has a Git source (
source): then its images come from builds of that source, and each successful build starts a deployment withtrigger: build(ADR 0117). A build runs on the node that runs the app (ADR 0118). - An interactive session is a web terminal into an app’s VM or a read-only console of a VM (ADR 0114). It lives on the primary while it is open and leaves two audit records, its open and its
session.end(ADR 0115).
Built images
The image a build produces never leaves the node that built it (ADR 0118), so it has no registry reference. Every contract names it with the reference
build.quoro.invalid/<app_id>:<build_id>
and the build’s manifest digest as its digest, for example build.quoro.invalid/app_01JC9Z7B3N5Q7S9Z1W3Y5A7C9E:bld_01JC9ZA2B3C4D5E6F7G8H9J0KM with sha256:.... The host is under the reserved .invalid top-level domain (RFC 6761), so the reference parses as an ordinary OCI reference but can never be pulled: a node prepares it from the build’s OCI layout and never contacts a registry for it. App.image, Deployment.image, vms.image and desired_state spec.vms[].image all carry it verbatim.
Status enums
- Node
status:pending(enrolled, never connected),connected,disconnected,fenced,removed. - VM
desired_status:running,stopped. VMobserved_status:creating,stopped,starting,running,stopping,crashed,deleted,unknown. - App
status:deploying,running,degraded,down,stopped. - Deployment
status:pending,preparing(pulling image, building rootfs),starting,succeeded,failed,superseded. - Build
status:queued(waiting for the node),preparing(builder VM and source checkout),building,exporting(writing the OCI layout),importing(turning the layout into a root disk),succeeded,failed,cancelled(ADR 0117). The last three are terminal. - Domain
cert_status:pending,issued,renewing,failed,not_applicable(tunnel mode). - Grant
status:active,expired,revoked,exhausted. - Approval
status:pending,approved,denied,expired. - Incident
status:open,resolved. - Lease
mode:local(HA policynone/cold: never expires when the arbiter is unreachable; ends bylease_revokeor an operator declaring the node lost) orexpiring(HA policywarm/hot, from M5b:held->uncertain->expired, self-fence atexpired; revoked explicitly) (D-0047). Lease state as seen by a node:held,uncertain,expired,revoked. See node-agent-protocol.md. - Webhook delivery
status:pending,delivered,failed.
Scopes
A scope is <resource>:<verb> (D-0030). The complete list:
| Scope | Allows | Flags |
|---|---|---|
cluster:read | Read cluster info, version, log position | |
node:read | List and read nodes and node metrics | |
node:enroll | Create node join tokens | human-only |
node:remove | Remove a node from the cluster | destructive, human-only |
node:fence_ack | Acknowledge a node’s failed fence after verifying its VMs are stopped, so its leases may be granted again | human-only |
project:read | List and read projects | |
project:write | Create and update projects | |
project:delete | Delete an empty project | destructive |
vm:read | List and read VMs | |
vm:create | Create a VM | |
vm:start | Start a VM | |
vm:stop | Stop a VM | |
vm:delete | Delete a VM and its disks | destructive |
vm:console | Read a VM’s serial console, live and its recent output (ADR 0116) | |
app:read | List and read apps, their Git sources, builds, deployments, domains, env var names and app metrics | |
app:create | Create an app | |
app:update | Change app settings, the Git source, env vars, domains | |
app:restart | Restart an app’s instances | |
app:redeploy | Roll out the current image again | |
app:deploy | Deploy a new image (new deployment); start and cancel builds, whose success deploys | |
app:start | Start a stopped app | |
app:stop | Stop an app | |
app:delete | Delete an app and its data | destructive |
app:logs | Read app logs and build logs, including live streams | |
app:terminal | Open an interactive terminal in an app’s VM (ADR 0113, ADR 0114) | human-only |
agent:read | List and read agents | |
agent:write | Create, update, disable agents; rotate agent keys | human-only |
grant:read | List and read grants | |
grant:write | Create and revoke grants | human-only |
approval:read | List and read approval requests | |
approval:create | File an approval request | |
approval:decide | Approve or deny an approval request | human-only |
incident:read | List and read incidents | |
incident:resolve | Resolve an incident | |
audit:read | Read the audit log and run verification; humans also fetch session transcripts | |
event:read | Read events and the event stream | |
webhook:read | List and read webhooks and deliveries | |
webhook:write | Create, update, delete, test webhooks | human-only |
provider:manage | Create, list, read, delete providers and rotate their secrets; map nodes to tunnel providers | human-only |
user:read | List human users, to resolve user ids to names | human-only |
user:write | Manage human users and their roles | human-only |
- destructive scopes need JIT approval when an agent uses them: the agent files an approval request, a human approves it, and a short-lived extra grant is issued (D-0030).
- human-only scopes can never appear in an agent’s grant, so an agent cannot widen its own power or approve its own requests.
- The wildcard
app:*style is not allowed in grants; scopes are listed explicitly. - Human roles are fixed scope bundles:
owner(every scope),operator(every scope exceptuser:write),viewer(every:readscope plusapp:logs).user:readis a:readscope, so every role holds it.app:terminalandvm:consoleare not:readscopes, so aviewerholds neither (ADR 0114). - Builds and metrics add no scope of their own: a build is authorized as a deployment of its app (
app:deploy), its record as app data (app:read), its log as app output (app:logs); node metrics neednode:readand app metricsapp:read.
Resource selectors
A grant’s selector says which resources its scopes apply to. JSON form:
{ "projects": ["blog"], "apps": ["app_01J..."], "nodes": ["nod_01J..."] }
A resource matches if it matches any listed entry (projects by slug, others by id); an app matches projects through its project. {"all": true} matches everything. An empty selector {} matches nothing. String form for CLI and docs: project=blog, app=app_01J..., node=nod_01J..., all.
Event types
Event type is <subject>.<what> in lowercase with underscores. The complete list:
| Type | Emitted when | D-0032 example |
|---|---|---|
node.enrolled | A node completed enrollment | |
node.connected | A node’s session started | |
node.disconnected | A node’s session ended or missed heartbeats | |
node.fenced | A node self-fenced stateful apps after losing a lease, every bound VM confirmed stopped | |
node.fence_failed | A node could not confirm a fence stopped its stateful VMs; it marked itself failed | |
node.disk_low | A node’s free disk fell below its threshold | disk full |
vm.created | A VM was created | |
vm.started | A VM reached running | |
vm.stopped | A VM stopped on request | |
vm.crashed | A VM exited without being asked to stop | VM down |
vm.deleted | A VM was deleted | |
app.created | An app was created | |
app.updated | An app’s settings changed | |
app.deleted | An app was deleted | |
app.down | An app’s health check failed past its threshold | VM down |
app.up | An app recovered | |
app.restarted | An app’s instances were restarted | |
deployment.started | A deployment began | |
deployment.succeeded | A deployment’s instances are healthy | |
deployment.failed | A deployment failed (image pull, rootfs, boot, health) | |
build.started | A build was created and queued | |
build.succeeded | A build produced its image; the deployment it starts follows | |
build.failed | A build failed (source checkout, a build step, export, import, timeout) | build failed |
build.cancelled | A build was cancelled before it finished | |
certificate.issued | A TLS certificate was obtained or renewed | |
certificate.expiring | A certificate expires within its warning window | certificate expiring |
certificate.failed | Obtaining or renewing a certificate failed | |
grant.created | A grant was issued | |
grant.revoked | A grant was revoked | |
grant.expired | A grant’s TTL passed or its uses ran out | |
approval.requested | An agent filed an approval request | |
approval.approved | A human approved a request | |
approval.denied | A human denied a request | |
approval.expired | A request expired undecided | |
incident.opened | A detection opened an incident | |
incident.resolved | An incident was resolved | |
audit.chain_broken | Audit verification found a broken link |
Errors
HTTP errors use application/problem+json (RFC 9457) with an extra code field. code values: unauthenticated (401), forbidden (403), approval_required (403), not_found (404), conflict (409), validation_failed (422), rate_limited (429), not_primary (503, write reached a replica that cannot forward), node_unreachable (504, on-demand telemetry could not reach the node), internal (500).
For forbidden, the field reason is one of: scope_missing, selector_mismatch, grant_expired, grant_revoked, grant_exhausted, human_only, agent_disabled. For approval_required, the body carries scope and the target so the agent can file an approval request.
Request headers
Authorization: Bearer <token>(human API token or agent access token); the panel uses a session cookie.Idempotency-Keyon everyPOSTthat creates something.Quoro-Justification: the agent’s stated reason for the call, required on every mutating call by an agent, recorded in the audit log (D-0031).Quoro-Setup-Token: the one-time setup token, required byPOST /api/v1/setuponly (D-0054).Quoro-Incident: the incident id the call belongs to, when an agent acts on an incident.Last-Event-IDon SSE streams (GET /api/v1/events/stream,GET /api/v1/apps/{app_id}/logs/stream,GET /api/v1/builds/{build_id}/logs/stream): resume after the givenseq. The metrics streams (GET /api/v1/nodes/{node_id}/metrics/stream,GET /api/v1/apps/{app_id}/metrics/stream) carry no resumable position and ignore it. One-way streams use SSE; bidirectional sessions use WebSockets (D-0057): the web terminal and the VM console (GET /api/v1/interactive-sessions/{session_id}/ws).- Every response carries
Quoro-Request-Id(req_...). - Webhook deliveries carry
Quoro-Event-Id,Quoro-Event-Type,Quoro-Delivery-IdandQuoro-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the webhook secret>. Receivers reject a signature older than 5 minutes.
Operation annotations
Every operation in openapi.yaml carries x-quoro-scope: one scope from the table above, or a list when the request needs several, or one of four special values: public (no authentication, security: []), authenticated (any valid human or agent identity, no scope), human (any authenticated human identity, session or personal API token; an agent caller is refused with 403 forbidden, reason: human_only), node (node credential; only the node-agent session and enrollment). Operations that an MCP tool also exposes carry x-quoro-mcp-tool with the tool name.
MCP tools
M3 exposes these tools (D-0033: read plus restart and redeploy; delete exists so the approval flow can be exercised). Each calls the same service operation as the listed endpoint and needs the same scope (D-0031).
| Tool | Endpoint | Scope |
|---|---|---|
get_cluster | GET /api/v1/cluster | cluster:read |
list_nodes | GET /api/v1/nodes | node:read |
get_node | GET /api/v1/nodes/{node_id} | node:read |
list_projects | GET /api/v1/projects | project:read |
list_apps | GET /api/v1/apps | app:read |
get_app | GET /api/v1/apps/{app_id} | app:read |
list_deployments | GET /api/v1/apps/{app_id}/deployments | app:read |
get_deployment | GET /api/v1/deployments/{deployment_id} | app:read |
get_app_logs | GET /api/v1/apps/{app_id}/logs | app:logs |
list_events | GET /api/v1/events | event:read |
list_incidents | GET /api/v1/incidents | incident:read |
get_incident | GET /api/v1/incidents/{incident_id} | incident:read |
resolve_incident | POST /api/v1/incidents/{incident_id}/resolve | incident:resolve |
restart_app | POST /api/v1/apps/{app_id}/restart | app:restart |
redeploy_app | POST /api/v1/apps/{app_id}/redeploy | app:redeploy |
delete_app | DELETE /api/v1/apps/{app_id} | app:delete (destructive) |
request_approval | POST /api/v1/approvals | approval:create |
get_approval | GET /api/v1/approvals/{approval_id} | approval:read |
list_audit | GET /api/v1/audit | audit:read |
Every mutating tool takes a required justification string and an optional incident_id, the MCP equivalents of the Quoro-Justification and Quoro-Incident headers.
M4 adds no MCP tool. Builds, metrics and console sessions are reachable by agents through the HTTP API with a grant; the web terminal is human-only (ADR 0114); a later tool is a contract change of its own.
Channels
Every action reaches the same service layer through one of these channels, recorded in the audit log as channel: api (HTTP API), panel (the web panel, which uses the API with a session), mcp (MCP server, D-0031), cli (the quoro command), system (Quoro itself: reconciliation, schedulers, incident automation). MCP tools map one-to-one onto service operations and require the same scope as the matching endpoint.
Node-agent message types
Defined in node-agent-protocol.md. Node to control plane: hello, heartbeat, status_report, event, lease_renew, log_ack, stream_data, stream_end, command_result, dns_challenge. Control plane to node: welcome, desired_state, lease_grant, lease_revoke, log_entries, stream_open, stream_close, stream_input, stream_ack, command, signing_key_rotate, dns_challenge_result, error. Either side: ping, pong.
Database tables
Defined in db-schema.sql and changed only by the numbered files in migrations/: meta, change_log, nodes, node_join_tokens, node_credentials, node_tunnels, projects, vms, apps, app_env, deployments, builds, domains, volumes, leases, users, api_tokens, sessions, agents, agent_keys, access_tokens, grants, approvals, incidents, events, webhooks, webhook_deliveries, providers, audit_log, audit_exports, interactive_sessions. Tables marked local in that file are not replicated through the change log.
Open items before the freeze
| Element | Recommended | Open alternative |
|---|---|---|
| Cluster key custody on promotion | Replicate both keys wrapped to each replica’s node key | Re-key on promotion with an operator-confirmed re-pin (open; decided with manual promotion in M5b, D-0067) |
Only key custody on promotion is still open. It does not block the freeze: promotion arrives in M5b and the key files and message shapes it needs already exist. The image pipeline and init are decided (D-0061), and so is the M5a snapshot primitive (D-0062), with an lvm-thin vs btrfs spike before M5b. Every other element formerly listed here is decided (D-0047, D-0048, D-0050, D-0051, D-0052, D-0053, D-0054, D-0056, D-0057, D-0061, D-0062, D-0067, D-0071, D-0072, D-0073, D-0074).
Permission model
Status: M0 draft for review.
This contract fixes how Quoro decides whether a caller may perform an operation. It implements agents as first-class identities (D-0029), grants made of a scope, a resource selector, a TTL and an optional maximum number of uses (D-0030), one authorization path shared by the HTTP API and MCP (D-0031), and the incident-grant and just-in-time approval flows (D-0030, D-0032). The M3 scope it must satisfy is the M3 Definition of Done in ../product/roadmap.md (D-0033).
Contracts freeze at the end of M0; after the freeze a change to this file is a standalone pull request (D-0036). If an implementation needs something this contract does not allow, stop and open an issue proposing the change (D-0036).
Vocabulary, ids, scopes, reasons, roles, channels and MCP tool names are defined once in README.md, where the tool list is the table in section MCP tools, and are used here unchanged. The audit record this contract produces is specified in audit-log-format.md.
1. Purpose and principles
Quoro is operated by humans and by AI agents over the same state (D-0002). This contract exists so that an agent can be given real power over infrastructure without that power being open-ended, unnamed or permanent (D-0030).
The principles, each of which the rest of this file turns into a rule:
- An agent is an identity, not a shared password. It has a name, a model and an owning human, and every action records who acted, on behalf of whom and why (D-0029).
- Authority is a grant, never a property of a key. An agent key authorizes nothing by itself; it can only mint short-lived access tokens, and those tokens carry no scopes. What an agent may do is read from its grants on every call (D-0030).
- Every grant is bounded in time. A TTL is mandatory; there is no grant without an expiry (D-0030).
- Every grant is bounded in space. A grant names the resources its scopes apply to through a selector; an empty selector matches nothing (D-0030).
- Least privilege by construction. Agents hold the scopes an incident or a task needs, nothing more; incident grants are issued with exactly the scopes the webhook declared (D-0030, D-0032).
- Destructive actions are off by default. An agent that needs one asks a human, and the human’s approval produces a separate, single-use grant for that one call (D-0030).
- One gate for every channel. The API, the panel, the CLI, MCP and Quoro’s own automation reach the same service layer and the same authorization check; MCP has no second path (D-0031).
- Refusals are evidence. A refused call is audited with the reason, not dropped (D-0031).
The threat this model addresses is a capable agent acting with more authority than the situation warrants, and nobody being able to reconstruct afterwards what it did and why (D-0029, D-0031). Threats and their mitigations are listed in ../architecture/threat-model.md.
1.1 Channels
Every action arrives through one of the channels in the vocabulary: api, panel, mcp, cli, system (README.md, “Channels”). The channel is recorded in the audit record but does not change authorization: the same identity with the same grant gets the same answer through any of them (D-0031).
A system action is taken by Quoro itself, for example reconciliation, a scheduler, incident automation or the audit exporter. It is not a caller with a credential; it is an internal actor whose actions are audited like any other (D-0029).
2. Identities and credentials
There are four identity types: user, agent, node and system (README.md, “Identifiers”; the audit record uses the same four values in actor_type). They are separate: an agent is not a human session with a different name (D-0029).
| Identity | Credential | Lifetime | What it authorizes | Stored as |
|---|---|---|---|---|
human (usr_) | panel session cookie | 24 h absolute expiry, no sliding renewal | the panel and its API calls, as that human’s role | sessions.token_hash |
human (usr_) | personal API token (tok_) | optional expires_at; valid until expiry or revoked_at | the HTTP API and the CLI, as that human’s role | api_tokens.token_hash |
agent (agt_) | agent key (akey_) | long-lived until revoked_at | nothing else: it may only mint access tokens | agent_keys.key_hash |
agent (agt_) | access token (atk_) | 5 minutes | API and MCP calls, subject to the agent’s grants | access_tokens.token_hash |
node (nod_) | node credential (enrollment key pair, njt_ join token at enrollment) | as long as the node is enrolled and not removed | the node-agent session only (x-quoro-scope: node) | nodes.public_key; node_join_tokens.token_hash |
| system | none | — | internal actions through the service layer | — |
Rules on credentials:
- Secrets are shown once. A node join token, a personal API token, an agent key and a webhook secret are returned by the call that creates them and never again; only a hash (or, for the webhook secret, an encrypted value) is stored (README.md, “Identifiers”; D-0030).
- Personal API tokens (
tok_) can be listed, created and revoked only by a human; an agent’s access token is refused withhuman_only, so an agent can never obtain a credential that escapes its grants (D-0029, D-0030). - Provider credentials are managed only by humans. Providers hold the secrets of external services (DNS, tunnel, load balancer, object storage), so every operation needs
provider:manage, which is human-only: an agent is refused withhuman_only(D-0051). A provider secret is write-only, encrypted with the cluster data-encryption key, never returned by a read and redacted from audit parameters (D-0050, D-0051). - Access tokens are opaque. An access token is 32 random bytes encoded as an
atk_-prefixed string. It is not a JWT and carries no claims: scopes, selectors and expiry of authority live in the grant rows, not in the token (D-0030). - Access tokens expire in 5 minutes and are minted against an agent key on demand. A long-running agent re-mints; nothing about the re-mint widens its authority, because the grants are read per call (D-0030).
- An expired or unknown access token is
unauthenticated(401). The caller re-mints from its agent key; the agent key itself is never sent to an ordinary endpoint. - Disabling an agent invalidates its access tokens immediately.
agents.disabledis read as part of the identity lookup on every request, before any grant is consulted, so the next call afterdisabledis set answersforbiddenwith reasonagent_disabled(D-0029, D-0030). - Revoking an agent key stops new access tokens (
agent_keys.revoked_at); already-minted access tokens die at their 5-minute expiry, and any grant check they reach still applies (D-0030). - The node credential never authorizes an API call. It authenticates the node-agent session and enrollment only; every operation annotated
nodein openapi.yaml is reachable with it and nothing else is (README.md, “Operation annotations”; D-0011). A node therefore cannot read the audit log, change a grant or act as a human. - A human’s session cookie is not a bearer token. It is accepted only by the panel origin and never by the MCP endpoint; an MCP client authenticates with an access token (D-0031).
3. Human roles
A human’s authority is their role. Roles are fixed bundles of scopes, not grant rows: a human holds every scope in their bundle for as long as the role is theirs (README.md, “Scopes”).
| Role | Holds |
|---|---|
owner | every scope |
operator | every scope except user:write |
viewer | every :read scope plus app:logs |
Consequences of the bundles:
- A
viewercan read apps, deployments, nodes, events, incidents, grants, approvals and the audit log, and can stream app logs, but cannot mutate anything (README.md, “Scopes”). - An
operatorcannot create or change human users; that isuser:writeand owner-only (README.md, “Scopes”). - Every role holds
user:read, so anoperatororviewercan list users and turn a user id (owner_user_id,on_behalf_of,decided_by,created_by) into a name.user:readis human-only: agents never list users. app:terminalandvm:consoleare in theownerandoperatorbundles and not inviewer, because neither is a:readscope (ADR 0114). Aviewercan read a build’s log (app:logs) but cannot start a build.- Human roles are not grantable to agents. An agent never receives a “role”; it receives grants (D-0029, D-0030).
roleis a column on the user (users.role), so changing a role changes every subsequent decision for that human with no re-issue step (D-0029).
A human may hold personal API tokens; a token inherits the human’s role at call time, so demoting or disabling the human takes effect on the next call (D-0029).
Own password. Every human may change their own password, whatever their role, with POST /api/v1/auth/password (openapi.yaml, changePassword; ADR 0135). It is not a scope: it needs no user:write, acts only on the caller and proves the current password. It is accepted only with the panel session cookie; a request with an Authorization header is unauthenticated, so a personal API token cannot turn into a new password and an agent never reaches it. A successful change ends the user’s other panel sessions; the calling session stays valid and personal API tokens are not touched (revoke them with DELETE /api/v1/tokens/{token_id}). Interactive sessions opened from an ended panel session close with close_reason credential_expired at their next re-check (section 7.5). Changing another user’s password stays updateUser with user:write.
4. Scopes
A scope is <resource>:<verb> and is the unit of authority in both human roles and agent grants (D-0030). The complete list, with the flags this contract depends on:
| Scope | Allows | Flags |
|---|---|---|
cluster:read | Read cluster info, version, log position | |
node:read | List and read nodes and node metrics | |
node:enroll | Create node join tokens | human-only |
node:remove | Remove a node from the cluster | destructive, human-only |
node:fence_ack | Acknowledge a node’s failed fence after verifying its VMs are stopped, so its leases may be granted again | human-only |
project:read | List and read projects | |
project:write | Create and update projects | |
project:delete | Delete an empty project | destructive |
vm:read | List and read VMs | |
vm:create | Create a VM | |
vm:start | Start a VM | |
vm:stop | Stop a VM | |
vm:delete | Delete a VM and its disks | destructive |
vm:console | Read a VM’s serial console, live and its recent output (ADR 0116) | |
app:read | List and read apps, their Git sources, builds, deployments, domains, env var names and app metrics | |
app:create | Create an app | |
app:update | Change app settings, the Git source, env vars, domains | |
app:restart | Restart an app’s instances | |
app:redeploy | Roll out the current image again | |
app:deploy | Deploy a new image (new deployment); start and cancel builds, whose success deploys | |
app:start | Start a stopped app | |
app:stop | Stop an app | |
app:delete | Delete an app and its data | destructive |
app:logs | Read app logs and build logs, including live streams | |
app:terminal | Open an interactive terminal in an app’s VM (ADR 0113, ADR 0114) | human-only |
agent:read | List and read agents | |
agent:write | Create, update, disable agents; rotate agent keys | human-only |
grant:read | List and read grants | |
grant:write | Create and revoke grants | human-only |
approval:read | List and read approval requests | |
approval:create | File an approval request | |
approval:decide | Approve or deny an approval request | human-only |
incident:read | List and read incidents | |
incident:resolve | Resolve an incident | |
audit:read | Read the audit log and run verification; humans also fetch session transcripts (section 7.6) | |
event:read | Read events and the event stream | |
webhook:read | List and read webhooks and deliveries | |
webhook:write | Create, update, delete, test webhooks | human-only |
provider:manage | Create, list, read, delete providers and rotate their secrets | human-only |
user:read | List human users, to resolve user ids to names | human-only |
user:write | Manage human users and their roles | human-only |
4.1 Destructive scopes
Four scopes are destructive: node:remove, project:delete, vm:delete and app:delete (README.md, “Scopes”). They remove data or capacity and cannot be undone by repeating a call.
This contract fixes their M3 behaviour:
- A standard grant can never contain a destructive scope. Attempting to create one is
validation_failedat the grant endpoint. - An incident grant can never contain a destructive scope.
webhook.wake.grant_scopesis validated when the webhook is saved, so an incident can never hand an agent the power to delete (D-0032). - An agent reaches a destructive scope only through an approval grant, that is a grant with
kindapproval, created by a human’s decision on an approval request (D-0030). Section 9 defines the flow. - A destructive call with no matching approval grant answers
approval_required(403), notforbidden: the agent is not told “never”, it is told “ask first” (README.md, “Errors”; D-0030).
Making destructive scopes grantable in standing grants would be a change to this contract and therefore to an accepted decision (D-0030 says destructive actions are off by default), not an implementation detail (D-0036).
node:remove is destructive and human-only, so no agent can delete a node at all: an approval grant can carry a destructive scope, never a human-only one (README.md, “Scopes”).
4.2 Human-only scopes
Eleven scopes are human-only: node:enroll, node:remove, node:fence_ack, app:terminal, agent:write, grant:write, approval:decide, webhook:write, provider:manage, user:read and user:write (README.md, “Scopes”).
- A grant whose
scopescontains a human-only scope is refused at creation withvalidation_failed, and a hypothetical row that contained one is refused at call time with reasonhuman_only(README.md, “Errors”). - The set is chosen so an agent can never widen its own power (
agent:write,grant:write,webhook:write), approve its own request (approval:decide), take over cluster membership (node:enroll,node:remove), declare a possibly running VM stopped so its lease moves (node:fence_ack, D-0079) or hold external credentials (provider:manage) (D-0030, D-0051).user:readis human-only so an agent never reads the list of human identities and their emails.app:terminalis human-only in M4 because a shell in the app’s VM reads every secret the app holds and acts outside single audited calls (ADR 0114); a later, non-interactive exec scope reachable only through an approval grant is a separate decision. - Because
approval:decideis human-only, “an agent approves its own destructive request” is not a policy that could be misconfigured; it is unrepresentable (D-0030).
4.3 Wildcards
Grants list scopes explicitly. app:*, * and any other wildcard form are rejected with validation_failed (README.md, “Scopes”). The only wildcard in the model is the selector form {"all": true}, which is a resource selector, not a scope (section 5).
5. Resource selectors
A grant’s selector says which resources its scopes apply to. JSON form:
{ "projects": ["blog"], "apps": ["app_01J..."], "nodes": ["nod_01J..."] }
and the string form used by the CLI and by documentation: project=blog, app=app_01J..., node=nod_01J..., all (README.md, “Resource selectors”).
Matching rules:
- A resource matches if it matches any listed entry. Entries are combined with OR, not AND.
- Projects are matched by
slug, never byprj_id; apps and nodes are matched by id (README.md, “Resource selectors”). - An app matches
projectsthrough its ownproject_id’s slug, soproject=blogcovers every app in theblogproject. {"all": true}matches everything and is the only way to say “the whole cluster”.{}(the empty selector) matches nothing. A grant with an empty selector is valid to store and useless to use; it is refused at call time withselector_mismatch.
Matching per target type:
| Target type | Matched by |
|---|---|
| app | apps entry equal to its id, or projects entry equal to the slug of its project, or {"all": true} |
| VM | apps entry equal to the VM’s app_id, or projects entry equal to the slug of the VM’s project, or nodes entry equal to the VM’s node_id, or {"all": true} |
| deployment | the app it belongs to (as above), or {"all": true} |
| build | the app it belongs to (as above), or {"all": true} |
| interactive session | its VM (as above; a terminal session’s VM is an instance of its app), or {"all": true} |
| domain | the app it belongs to (as above), or {"all": true} |
| project | projects entry equal to its slug, or {"all": true} |
| node | nodes entry equal to its id, or {"all": true} |
| incident | its subject (resolved as the subject’s own type above), or {"all": true} |
| approval request | its target (resolved as the target’s own type above), or {"all": true} |
5.1 Cluster-level scopes
cluster:read, event:read and audit:read have no resource of their own:
- They are matched by any non-empty selector. A grant carrying
event:readwith selector{"projects":["blog"]}may read the event stream, but only records whose subject or project belongs toblog(constraint below). - Results are filtered, never refused. A list call under a selector returns the matching items; it does not answer
forbiddenbecause some rows in the table are out of scope. Filtered lists are the normal outcome for agents holding narrow grants (D-0030). - Events are filtered by
subject(and byproject_idwhen set). - Audit records are filtered by
target_type/target_idandproject_id, and byactor_idwhen the selector names apps or nodes the actor belongs to. A record with no target (for example a cluster-level write) is visible only to a selector of{"all": true}. - A grant whose selector is
{}reads nothing under these scopes, consistently with section 5.
The filtering above is a read-path rule; it does not create a second permission check. An implementation that returns everything to a narrowly scoped agent and relies on the panel to hide rows violates this contract (D-0030).
6. Grants
A grant is the only carrier of agent authority (D-0030). Its fields:
| Field | Type | Required | Meaning |
|---|---|---|---|
id | grt_ id | yes | identity of the grant |
agent_id | agt_ id | yes | the agent it empowers |
kind | standard | incident | approval | yes | why the grant exists (section 6.1) |
scopes | array of scope strings | yes | what it allows; never human-only, destructive only for approval |
selector | selector object | yes | where it applies (section 5) |
expires_at | RFC 3339 timestamp | yes | TTL is mandatory; no grant is open-ended |
max_uses | integer or null | no | maximum authorized mutating calls; null means unlimited within the TTL |
uses | integer | yes | how many mutating calls it has authorized |
status | active | expired | revoked | exhausted | yes | lifecycle state (section 6.2) |
incident_id | inc_ id or null | for kind=incident | the incident the grant was issued for |
approval_id | apr_ id or null | for kind=approval | the approval request that produced it |
on_behalf_of | usr_ id | yes | the human whose authority is being lent |
created_by_type | user | system | yes | who issued it |
created_by_id | id | yes | that human or subsystem |
reason | text | yes | why it was issued, in words |
created_at | RFC 3339 timestamp | yes | when it was issued |
revoked_at | RFC 3339 timestamp or null | no | when a human or the system revoked it |
revoked_by | id or null | no | who revoked it |
The field names match the shared entity model and the grants table in db-schema.sql.
6.1 Kinds
| Kind | Issued by | Typical TTL | May carry destructive scopes | Ends when |
|---|---|---|---|---|
standard | a human with grant:write | chosen by that human | no | TTL, max uses, or revocation |
incident | the system, when an incident opens | wake.grant_ttl_s (default 3600 s) | no | TTL, max uses, the incident resolving, or revocation |
approval | the system, on a human’s approval | 10 minutes | yes, exactly one | one use, TTL, or revocation |
standard is the everyday grant: a human lends an agent a few scopes on a few resources for a shift or a task (D-0030). incident and approval are the two automatic kinds, defined in sections 8 and 9 (D-0030, D-0032).
6.2 Lifecycle
status moves in one direction:
active ──TTL passed──────────▶ expired
│
├──── revoked by a human──▶ revoked
│
└──── last use consumed────▶ exhausted
- A grant is created
active(eventgrant.created). - The transition to
expired,revokedorexhaustedemitsgrant.expiredorgrant.revoked;grant.expiredcarriesreasonttlormax_usesin itsdata(README.md, “Event types”). revokedandexpiredare terminal; there is no re-activation of a grant id. A new grant is a new row with a new id (README.md, “Identifiers”: ids are never reused).exhaustedis computed fromuses >= max_uses, so a grant withmax_uses1 that authorized one call isexhaustedeven if its TTL has not passed.
6.3 TTL
expires_at is mandatory on every grant. There is no default of “forever” and no way to omit it: the grant endpoint answers validation_failed without it (D-0030).
- The TTL is absolute from
created_at; it is not refreshed by use. An agent that needs longer asks for a longer grant (D-0030). - Expiry is evaluated against the control plane’s clock at decision time, not against any node’s clock.
- A grant at or past
expires_atisexpiredand authorizes nothing, even for a read (D-0030).
6.4 Maximum uses
max_usescounts authorized mutating calls, that is calls that pass authorization and are executed with resultok.- Reads do not consume uses. An agent can poll
get_appunder a one-use grant as often as it likes (D-0030). - A refused call does not consume a use; a failed call (result
errorafter authorization) does not consume a use either, because the use is incremented with the action in the same transaction (section 7.3) and an action that did not commit did not happen. max_usesof 1 is the shape of every approval grant (section 9).
6.5 Revocation
- A human with
grant:writerevokes any grant; the system revokes incident grants when their incident resolves (section 8). - Revocation is checked on every request, not cached and not attached to the token. The next call after revocation is therefore refused with
grant_revoked, which is how “locks the agent out within seconds” (D-0030) holds: the bound is the next request, not a refresh interval. - Revoking a grant does not revoke the agent key or delete audit records; it removes the authority the grant lent (D-0030).
- Revocation emits
grant.revokedand is itself audited.
6.6 Issuing grants
POST /api/v1/grants requires grant:write and is human-only. The service refuses with validation_failed:
- a scope that does not exist;
- a wildcard scope;
- a human-only scope;
- a destructive scope on a
standardgrant (section 4.1); - a missing
expires_at, anexpires_atin the past, or amax_usesbelow 1; - a selector that is not the documented shape.
The created grant emits grant.created and is audited with the issuing human as actor (created_by_type user) (D-0030, D-0031).
7. Authorization
Authorization is one function called by the service layer. The API, MCP, the panel, the CLI and Quoro’s own automation call it; none of them has a bypass (D-0031).
7.1 Inputs
authorize(
identity, -- resolved from the credential: {type, id, role?, disabled?, model?}
operation, -- endpoint name or MCP tool name, e.g. "restart_app"
required_scopes, -- one scope or a list, from x-quoro-scope
target, -- {type, id} or null for list calls
params, -- the request body/query as the caller sent it
channel, -- api | panel | mcp | cli | system
justification, -- agent's stated reason (required for mutating agent calls)
incident_id, -- optional, from Quoro-Incident
approval_id -- optional, only for destructive calls
) -> Decision { allow, grant?, reason?, approval? }
operation is the endpoint or MCP tool name from openapi.yaml; required_scopes comes from the operation’s x-quoro-scope, so a tool exposed over MCP inherits exactly the scope of its endpoint (README.md, “Operation annotations”; D-0031).
7.2 Decision procedure
1. resolve identity
- unknown/expired credential -> 401 unauthenticated
- node credential on a non-`node` op -> 403 scope_missing
- system identity -> allow (internal actor, audited)
2. if identity.type == "user":
if required_scopes ⊆ role_bundle(identity.role) -> allow
else -> 403 scope_missing
3. if identity.type == "agent":
a. if human_only ∩ required_scopes ≠ ∅ -> 403 human_only
b. if identity.disabled -> 403 agent_disabled
c. candidates = grants where
agent_id == identity.id
AND status == active
AND expires_at > now
AND (max_uses is null OR uses < max_uses)
AND required_scopes ⊆ scopes
d. if candidates is empty :
if destructive ∩ required_scopes ≠ ∅ -> 403 approval_required
else -> 403 scope_missing
e. candidates = candidates where selector matches `target`
(for list calls: filter the result rows by selector, never refuse)
f. if candidates is empty -> 403 selector_mismatch
g. if destructive ∩ required_scopes ≠ ∅ :
keep only grants with kind == "approval"
whose approval_id's target == target
and whose approval_id's params_hash == sha256_jcs(params)
if none -> 403 approval_required
h. grant = candidate with the earliest expires_at
(ties broken by earliest created_at, then by lowest id)
i. mutating call: in one transaction
re-read grant FOR UPDATE; re-check expiry, revocation, uses, status
perform the action
uses += 1
if max_uses is not null and uses == max_uses -> status = exhausted, emit grant.expired(reason=max_uses)
append the audit record with result "ok" and change_seq of the action
commit
read call: no use is consumed, no transaction beyond the read
j. allow, carry grant.id into the audit record
Step (a) before step (c) is what makes human_only the answer even when a malformed grant row exists and even for a disabled agent. Step (b) before the grant query is what makes disabling an agent immediate.
7.3 The transaction
An authorized mutating call writes three things atomically: the state change (through the change log, D-0013), the increment of uses and the audit record (D-0031). Either all three exist or none does. This is why “the action exists but its audit record does not” is not a state the system can reach (see audit-log-format.md).
The transaction re-reads the grant after step (h) so that a revocation or expiry that lands between selection and commit still refuses the call. The bound on “within seconds” (D-0030) is then stricter than seconds: it is the next commit.
7.4 Refusal reasons
Every refusal maps to an HTTP application/problem+json body with code and, for forbidden, a reason:
| Reason | Status | When |
|---|---|---|
human_only | 403 forbidden | the call needs a scope that can never be granted to an agent |
agent_disabled | 403 forbidden | the agent is disabled |
scope_missing | 403 forbidden | no active grant contains the required scope, or the human’s role lacks it |
selector_mismatch | 403 forbidden | a grant has the scope but its selector does not cover the target |
grant_expired | 403 forbidden | the only grants with the scope have passed expires_at |
grant_revoked | 403 forbidden | the only grants with the scope were revoked |
grant_exhausted | 403 forbidden | the only grants with the scope have no uses left |
approval_required | 403 approval_required | a destructive scope is required and no matching approval grant exists |
Order. When several reasons could apply, the answer is the first that applies in this order: human_only, agent_disabled, scope_missing, selector_mismatch, grant_expired, grant_revoked, grant_exhausted. The order is part of the contract because the M3 demo prints the reason (docs/product/roadmap.md, M3 Definition of Done): an agent that reads grant_expired learns something different from an agent that reads selector_mismatch.
Implementation note: the candidate query in step (c) filters on status and expiry for speed, so reasons grant_expired, grant_revoked and grant_exhausted are produced by a second, unfiltered lookup over the agent’s grants when step (d) or (f) would otherwise answer scope_missing or selector_mismatch. The unfiltered lookup is only consulted to choose the reason; it never authorizes.
approval_required body. It carries scope (the single destructive scope) and target (type, id), so the agent can file an approval request without guessing (README.md, “Errors”). It may also carry operation and params_hash as convenience; the approval endpoint recomputes both from the request body it receives.
Every refusal is audited with result refused and result_detail set to the reason (approval_required included). A refusal names no change_seq because nothing changed (D-0031).
Authentication failures are not refusals. An unauthenticated (401) call has no identity to attribute, so it is written to the server log, not the audit chain (D-0031; section 11).
7.5 Interactive sessions
A web terminal or a VM console is opened by one authorized call and then stays open for minutes or hours over a WebSocket (ADR 0057, ADR 0114). The open is authorized like any call: app:terminal for POST /api/v1/apps/{app_id}/terminal-sessions (human-only, so an agent is refused with human_only at step 3a), vm:console for POST /api/v1/vms/{vm_id}/console-sessions (a human’s role, or an agent’s grant whose selector covers the VM). The answer is a single-use ticket valid for 30 seconds; the WebSocket upgrade presents the ticket instead of a credential (openapi.yaml, connectInteractiveSession).
Because one authorization would otherwise cover hours of activity, the primary re-checks an open session and closes it, with the close_reason in brackets, when:
| Condition | Checked | close_reason |
|---|---|---|
| the human is disabled or deleted, or the agent is disabled | on change, and at least every 30 s | access_revoked |
| the human’s role no longer holds the scope | on change, and at least every 30 s | access_revoked |
| the agent’s grant that authorized the open is revoked, expires or is exhausted, and no other active grant covers the scope and the VM | on change, and at least every 30 s | access_revoked |
| the panel session cookie, the personal API token or the agent access token that opened it expires or is revoked | at its expiry | credential_expired |
| no input from the client for 15 minutes (a console has no input: no WebSocket message from the client) | continuously | idle_timeout |
| the session has been open for 8 hours | continuously | max_duration |
An agent access token lives 5 minutes (section 2), so an agent’s console session ends at that token’s expiry unless the agent opens a new one; console sessions are meant for short reads of boot output, not for permanent tails. The re-check consumes no grant use: only the open is a call. Every close, for any reason, writes the session.end audit record (audit-log-format.md, section 3.3).
7.6 Session transcripts
A terminal session’s transcript holds every byte typed and printed, so it holds whatever secret appeared on the screen (ADR 0115). GET /api/v1/interactive-sessions/{session_id}/transcript therefore needs audit:read and a human caller: an agent holding audit:read is refused with human_only, because an agent may not read through a transcript what it may not obtain through a terminal (ADR 0114). Every transcript fetch is audited, also when a human makes it (audit-log-format.md, section 3).
8. Incident grants and the AI-native loop
D-0032 defines the loop: detect, notify, wake, act. This section fixes the permissions half of the wake and act steps.
8.1 The webhook’s wake configuration
A webhook may carry a wake object (shared entity model; events.schema.json):
| Field | Type | Meaning |
|---|---|---|
agent_id | agt_ id | the agent the webhook wakes |
grant_scopes | array of scope strings | the scopes the incident grant will carry; non-destructive only |
grant_ttl_s | integer, default 3600 | lifetime of the incident grant |
grant_max_uses | integer or null | optional cap on how many mutating calls the incident grant allows |
grant_scopesis validated when the webhook is saved: a human-only scope or a destructive scope receivesvalidation_failed. A webhook therefore cannot be used to route around section 4.1 (D-0032).webhook:writeis human-only, so the agent that will be woken cannot configure its own future grant (README.md, “Scopes”).created_byis the human who saved the webhook and becomeson_behalf_ofof every incident grant the webhook causes, which is how “on whose behalf” is answered for a wake nobody was present for (D-0029).wakecan be changed withPATCH /api/v1/webhooks/{webhook_id}but not removed. To stop a webhook from waking an agent, delete the webhook and create it again withoutwake; the new webhook gets a new signing secret.
8.2 When the grant is issued
- A detection (node agent report, health check, certificate state) opens an incident with
subject{type, id}(D-0032). incident.openedis emitted.- For each enabled webhook whose
event_typesmatches and whoseselectorcovers the incident’s subject, the system delivers the event and, if the webhook haswake, issues an incident grant:kindincident;agent_id=wake.agent_id;scopes=wake.grant_scopes, copied verbatim;selector= the incident subject only — an app id, a node id or a project slug, never a broader selector;expires_at= now +wake.grant_ttl_s;max_uses=wake.grant_max_uses;incident_id= the incident;on_behalf_of=webhook.created_by;created_by_typesystem,reasonnaming the incident and the webhook.
grant.createdis emitted and the issuance is audited withactor_typesystem.- The delivery body is the
incident.openedevent withdata.wake={agent_id, grant_id}added: the woken agent and the incident grant just issued (events.schema.json,data_incident_opened). The signature covers it like the rest of the body, and every retry of that delivery carries the samedata.wake. A delivery whose webhook issued no grant (nowake, or the incident was resolved before the delivery) carries nodata.wake. The event log itself never carries it.
8.3 When the grant ends
- Revoked by the system when the incident resolves.
POST /api/v1/incidents/{incident_id}/resolverevokes every incident grant with thatincident_id, emittinggrant.revoked(D-0030, D-0032). - Otherwise it ends by TTL or by
max_uses, like any grant (section 6). - A resolved incident does not delete its grants; they stay visible as
revokedso the audit trail can be read later (D-0031).
8.4 Worked example
The D-0030 example, in this contract’s terms: an app on one node goes down.
- Detection opens
inc_01J...with subject{ "type": "app", "id": "app_01J..." }. - A webhook with
wake={ "agent_id": "agt_01J...", "grant_scopes": ["app:read", "app:logs", "app:restart", "incident:read", "incident:resolve", "approval:create"], "grant_ttl_s": 3600 }fires. - The agent receives
grt_01J...: those six scopes on{"apps":["app_01J..."]}, one hour. - The agent calls
get_app, thenrestart_appwith a justification naming the incident. Both pass; only the restart consumes a use, andmax_usesis null so the hour is the only bound. - The app comes back; the agent calls
resolve_incident(it holdsincident:resolvebecause the webhook declared it; without it, the agent reports and a human resolves). On resolve, the grant becomesrevoked.
A grant for one hour on one app, and nothing else, is the whole point: the agent could not have restarted a different app, read a project it was not woken for, or deleted anything (D-0030).
9. Just-in-time approval for destructive actions
D-0030 requires that destructive actions be off by default and reachable only after a human approves a specific call. The flow, with the exact endpoints and events:
- The agent attempts the destructive call. For example
DELETE /api/v1/apps/{app_id}(scopeapp:delete) or thedelete_appMCP tool (D-0031, README.md “MCP tools”). - The service refuses with
approval_required(403) carryingscopeandtarget. Nothing is deleted, and the refusal is audited withresultrefused,result_detailapproval_required(D-0030). - The agent files a request:
POST /api/v1/approvalswithapproval:create, body{scope, target, operation, params, justification, incident_id?}. The service computesparams_hashas the SHA-256 hex of the RFC 8785 canonical serialization ofparams, storesstatuspendingandexpires_at= now + 1 hour, and emitsapproval.requested(D-0030). - A human decides in the panel with
approval:decide, which is human-only: the panel shows the operation, the target, the parameters and the agent’s justification, and the human approves or denies (D-0030; README.md “Scopes”). - On approve the system, in one transaction:
- sets
statusapproved,decided_by,decided_at, optionaldecision_note; - creates an approval grant:
kindapproval, scopes = exactly the one requested scope, selector = that target only,max_uses1,expires_at= now + 10 minutes,approval_idset,on_behalf_of= the deciding human; - emits
approval.approvedandgrant.created; - audits the decision with
actor_typeuser.
- sets
- The agent repeats the same call with the same params. Authorization matches the approval grant by target and
params_hash; the call executes;usesreaches 1; the grant becomesexhaustedand emitsgrant.expiredwithreasonmax_uses(section 6.2). The audit record carriesapproval_idandgrant_id(D-0030, D-0031).
The same call with different params is a different call: params_hash differs, no approval grant matches, and the answer is approval_required again. This is what stops “approve a harmless restart, then send a delete” (D-0030).
Denial and expiry:
- Denied:
POST /api/v1/approvals/{approval_id}/denywithapproval:decideand a note;statusdenied,approval.denied, no grant. The agent’s retry answersapproval_required(D-0030). - Expired: a pending request past its
expires_at(default 1 hour) becomesexpiredand emitsapproval.expired; no grant is created and a late decision answersconflict(409) (D-0030). - Already decided: a second decision on the same request answers
conflict(409); approval is not idempotent in the “make it work twice” sense, it is idempotent in the “cannot double-issue a grant” sense.
Who may approve:
- Any human holding
approval:decide:ownerandoperator(README.md, “Scopes”). - Never the agent that filed it, never another agent, and never a caller reaching the endpoint through MCP:
approval:decideis human-only, so an MCP client cannot call the decide operation at all (D-0030). - The deciding human is recorded as
decided_byand as the approval grant’son_behalf_of, so the audit record of the destructive call names the agent that did it and the human who allowed it (D-0029).
What approval does not do: it does not widen the agent’s other grants, it does not create a reusable permission, and it does not pre-approve a class of future calls (D-0030).
10. MCP mapping
M3 exposes the tools listed in README.md (D-0033: read plus restart and redeploy; delete exists so the approval flow can be exercised). The tool list is the table in README.md, section MCP tools. Each calls the same service operation as the listed endpoint and requires the same scope (D-0031).
Rules that follow from D-0031 and are not optional:
- Every mutating tool takes a required
justificationstring and an optionalincident_id. They are the MCP equivalents of theQuoro-JustificationandQuoro-Incidentheaders (README.md, “MCP tools”; D-0031). A mutating tool call withoutjustificationisvalidation_failed, not an unaudited success. - MCP clients authenticate with the same bearer access token as the API:
Authorization: Bearer atk_.... There is no MCP-specific identity, and a human session cookie is not accepted by the MCP endpoint (D-0031). approval:decidehas no MCP tool. The absence is deliberate: an agent cannot approve anything through MCP (D-0030).- Tool and endpoint annotations must agree. Each operation in openapi.yaml carries
x-quoro-scopeand, when a tool exposes it,x-quoro-mcp-tool; a tool whose annotation disagrees with the README table fails the contract check. - The MCP server is a thin adapter. It validates the tool arguments, calls the service operation and returns its result; it never queries the database directly and never constructs an audit record (D-0031).
rmcpis pinned to an exact minor version, because the SDK shipped three semver-major releases between March and July 2026 and upgrades are scheduled work rather than a range bump (F-0041;docs/research/rust-api-and-mcp-tooling.md). The transport is Streamable HTTP mounted as a Tower service; no session state is assumed, since recent spec revisions remove protocol sessions (F-0041).- Inbound token validation is Quoro’s own middleware, not something the SDK is documented to provide: server-side OAuth middleware is not documented in rmcp, so the bearer check is implemented on the Axum side (F-0041;
docs/research/rust-api-and-mcp-tooling.md, labelled a Hypothesis there for the absence of documentation).
11. Accountability
Every action, allowed or refused, records the same accountability fields (D-0029, D-0031). The full record is specified in audit-log-format.md; this section fixes what each identity type puts in them.
| Question | Field | Where it comes from |
|---|---|---|
| who acted | actor_type, actor_id | the resolved identity |
| which model | agent_model | agents.model, agents only |
| on behalf of whom | on_behalf_of | the grant’s on_behalf_of, or the deciding human for an approval |
| why | justification | Quoro-Justification or the MCP justification argument, required for mutating agent calls |
| what woke it | trigger_event_id, incident_id | the incident and the event that opened it |
| what it did | operation, scope, target_type, target_id, params | the call |
| what happened | result, result_detail, change_seq | the outcome |
| which authority | grant_id, approval_id | the grant that allowed it, the approval that produced it |
Per identity type:
| Identity | actor_type / actor_id | agent_model | on_behalf_of |
|---|---|---|---|
| human | user / usr_ | omitted | omitted (a human acting for themselves); set to the human who decided, on an approval decision record |
| agent | agent / agt_ | the agent’s model | the matched grant’s on_behalf_of |
| node | node / nod_ | omitted | omitted |
| system | system / the subsystem name | omitted | set when the system acts on an incident grant it issued |
Two consequences worth stating plainly:
- An agent action always names three parties: the agent, its model and the human whose authority it borrowed (D-0029).
- A
systemaction that a webhook caused still names the human who created the webhook, through the incident grant andtrigger_event_id. There is no unattributed automation (D-0029, D-0032).
A human’s read calls are not recorded; an agent’s reads are (D-0031). That asymmetry is deliberate: the log exists to make agent behaviour reconstructable, and the volume of human browsing would bury it (see audit-log-format.md, “What is recorded”).
12. Rate limits
- Rate limits are enforced per identity: a human token and an agent access token each get their own bucket, and sharing an agent key does not pool two agents into one bucket (D-0029).
- Exceeding a limit answers
rate_limited(429) inapplication/problem+json. - The values are implementation defaults, not contract. This contract fixes only that the limit is per identity and that the answer is
rate_limited; an implementation may choose the numbers and change them without a contract change (D-0030). - A rate-limited call is audited as
refusedwithresult_detailrate_limitedwhen the identity is known, so a burst that an agent produced is visible in the log (D-0031). POST /api/v1/auth/passwordis limited per user, counting attempts with a wrong current password, so a stolen session cannot be used to guess the password (ADR 0135).
13. Worked examples
The scenarios cover the M3 Definition of Done in ../product/roadmap.md (D-0033). “Audit result_detail” is the field on the record this call produces.
| # | Identity | Authority | Call | Result | Audit result_detail |
|---|---|---|---|---|---|
| 1 | agent agt_A | incident grant, app:read + app:restart, selector {"apps":["app_1"]} | restart_app on app_1 | ok; app restarts | ok, with change_seq, incident_id, grant_id |
| 2 | agent agt_A | standard grant, app:read only | restart_app on app_1 | 403 forbidden | scope_missing |
| 3 | agent agt_A | standard grant, app:restart, expires_at 10 minutes ago | restart_app on app_1 | 403 forbidden | grant_expired |
| 4 | agent agt_A | standard grant, app:restart, revoked by a human a second ago | restart_app on app_1 | 403 forbidden | grant_revoked |
| 5 | agent agt_A | standard grant, app:restart, selector {"projects":["blog"]} | restart_app on an app in project shop | 403 forbidden | selector_mismatch |
| 6 | agent agt_A | standard grant, app:restart, max_uses 1, one use already spent | restart_app on app_1 | 403 forbidden | grant_exhausted |
| 7 | agent agt_A | any grant (even {"all": true}) | delete_app on app_1 | 403 approval_required, body carries scope and target | approval_required |
| 8 | agent agt_A, after the human approves | approval grant, app:delete, selector {"apps":["app_1"]}, max_uses 1, TTL 10 min | delete_app on app_1 with the approved params | ok; app deleted; grant exhausted | ok, with approval_id and grant_id |
| 9 | agent agt_A | grant minus the destructive scope | delete_app on app_1, unchanged params, expired approval grant | 403 approval_required again | approval_required |
| 10 | agent agt_A | any grant containing a human-only scope (malformed row) | POST /api/v1/grants (grant:write) | 403 forbidden | human_only |
| 11 | agent agt_A | valid grant, but the agent was disabled a moment ago | get_app on app_1 | 403 forbidden | agent_disabled |
| 12 | human usr_H, role viewer | role bundle | restart_app on app_1 | 403 forbidden | scope_missing |
| 13 | human usr_H, role operator | role bundle | delete_app on app_1 | ok; no approval needed for a human | ok |
| 14 | agent agt_A | incident grant, event:read, selector {"projects":["blog"]} | list_events | ok; only blog subjects returned | ok |
| 15 | agent agt_A | standard grant, app:restart, TTL 1 h, max_uses null | get_app on app_1 twenty times, then restart_app | all ok; only the restart consumes a use | ok on each |
| 16 | agent agt_A | standard grant, app:terminal (malformed row), selector {"all": true} | POST /api/v1/apps/{app_id}/terminal-sessions | 403 forbidden | human_only |
| 17 | agent agt_A | standard grant, vm:console, selector {"apps":["app_1"]} | POST /api/v1/vms/{vm_id}/console-sessions on app_1’s VM | 201 with a ticket; the session ends when the access token expires | ok; a later session.end with credential_expired |
| 18 | human usr_H, role viewer | role bundle | POST /api/v1/vms/{vm_id}/console-sessions | 403 forbidden | scope_missing |
Two rows are worth reading together. Row 7 and row 8 are the same call: the second one succeeds only because a human approved exactly that call with exactly those params. Row 9 shows what happens when the approval is not there any more — the agent does not inherit a permanent right to delete from having once been approved (D-0030).
14. What this contract does not decide
- Whether destructive scopes may ever go into standing grants. This contract says no for M3 (D-0030: destructive actions are off by default). Changing it needs a decision, not a patch (D-0036).
- The exact numeric rate limits. Implementation defaults (section 12).
- How long a human session lives beyond the 24 h absolute expiry, and whether refresh tokens are added. The contract fixes that a session expires absolutely; anything more is a later decision (D-0029).
- Grant approval by two humans (four-eyes). Not in D-0030 and not in this contract.
- An external identity provider. Quoro authenticates its own humans with a password checked against an Argon2id hash and with its own tokens; no SSO path exists in M0-M6 (D-0029;
users.password_hashin db-schema.sql). - The off-site copy of the audit log, which the audit contract fixes in shape. It ships in M5a with the S3-compatible path, after an object-lock probe (D-0053; audit-log-format.md).
Audit log format
Status: M0 draft for review.
This contract fixes the shape of the audit log, how records chain, how the chain is verified and how a copy is written off-site. It implements D-0031: the audit log is append-only, hash-chained, optionally copied to S3-compatible storage with object lock, and MCP writes through the same path as the HTTP API. It also implements the “everything must be reconstructable” requirement of D-0029 and rides on the replicated change log of D-0013.
The permission rules that decide whether a call may happen are in permission-model.md. This file assumes that decision has been made and describes what is written once it has.
Contracts freeze at the end of M0; a change to this file after the freeze is a standalone pull request (D-0036).
1. Purpose and principles
An operator of a self-hosted cluster has to be able to answer, months later: which agent restarted which app, on whose authority, because of which event, with what parameters, and did it work (D-0029, D-0031). The audit log is the only place that answer exists. It is not a debugging log and not a metrics stream; it is the accountability record of every action taken against the cluster.
The principles:
- Append-only. Records are inserted, never updated and never deleted (D-0031). There is no update endpoint, no delete endpoint and no retention job in M0-M6 (section 9).
- One record per action, in the action’s transaction. A record is written in the same SQLite transaction as the change it describes, so an action without its record cannot exist (D-0031; D-0013).
- Hash-chained. Each record names the hash of its predecessor, so a modified or reordered record is detectable (D-0031; D-0013).
- Same path for every channel. API, panel, CLI, MCP and Quoro’s own automation write through one service layer and therefore one log (D-0031).
- Copyable out of reach. An optional off-site copy with object lock exists so that the person operating the server cannot silently rewrite the record (D-0031; section 8).
- Records are not events. The audit log and the event stream are different logs with different consumers (section 10).
2. Record format
An audit record has no prefixed id; it is identified by its seq, which is gap-free from 1 within the cluster’s audit log (shared entity model; db-schema.sql, table audit_log).
| Field | Type | Required | Meaning |
|---|---|---|---|
seq | integer | yes | position in the audit log, gap-free from 1 |
time | RFC 3339 UTC string | yes | when the record was appended |
actor_type | user | agent | node | system | yes | which kind of identity acted |
actor_id | string | yes | the acting id (usr_, agt_, nod_) or the system subsystem name |
agent_model | string | no | the agent’s model, on agent records only |
on_behalf_of | usr_ id | no | the human whose authority was lent |
channel | api | panel | mcp | cli | system | yes | how the action arrived |
operation | string | yes | the endpoint (DELETE /api/v1/apps/{app_id}), the MCP tool name (delete_app), or session.end for the close of an interactive session (section 3.3) |
scope | scope string | no | the scope the call was authorized under; absent when the call needed none |
target_type | string | no | app, vm, deployment, build, domain, project, node, incident, approval, grant, webhook, user, cluster |
target_id | string | no | id of that target |
project_id | prj_ id | no | project the target belongs to, when it has one |
params | JSON object | yes | the request parameters, secrets redacted (section 4) |
justification | string | no | the agent’s stated reason; required on mutating agent calls, omitted for humans |
trigger_event_id | evt_ id | no | the event that woke the agent |
incident_id | inc_ id | no | the incident the call belongs to |
grant_id | grt_ id | no | the grant that authorized the call |
approval_id | apr_ id | no | the approval request behind an approval grant |
request_id | req_ id | yes | correlation id of the call, echoed as Quoro-Request-Id |
result | ok | refused | error | yes | outcome of the call |
result_detail | string | no | the refusal reason or the error code |
change_seq | integer | no | the change-log seq this action produced, when it changed state |
prev_hash | 64 lowercase hex chars | yes | hash of record seq - 1; 64 zeros for seq 1 |
hash | 64 lowercase hex chars | yes | hash of this record (section 5) |
Absent optional fields are omitted from the JSON, not set to null. This matters because the hash covers the serialized object: null and “absent” would otherwise be two encodings of the same record with different hashes (section 5).
2.1 Example: the M3 demo restart, by an agent, through MCP
An app on a node went down, an incident opened, the webhook woke an agent, and the agent restarted the app through the restart_app MCP tool (D-0033 M3 demo; D-0032).
{
"seq": 1041,
"time": "2026-11-02T15:42:07Z",
"actor_type": "agent",
"actor_id": "agt_01JC9Z6Q5W8N3K4T7R2M1XHB0D",
"agent_model": "example-model-id",
"on_behalf_of": "usr_01JC9Z6Q5W8N3K4T7R2M1XHB0E",
"channel": "mcp",
"operation": "restart_app",
"scope": "app:restart",
"target_type": "app",
"target_id": "app_01JC9Z6Q5W8N3K4T7R2M1XHB0F",
"project_id": "prj_01JC9Z6Q5W8N3K4T7R2M1XHB0G",
"params": { "app_id": "app_01JC9Z6Q5W8N3K4T7R2M1XHB0F" },
"justification": "app down per incident inc_01JC9Z6Q5W8N3K4T7R2M1XHB0H; restarting its instance",
"trigger_event_id": "evt_01JC9Z6Q5W8N3K4T7R2M1XHB0J",
"incident_id": "inc_01JC9Z6Q5W8N3K4T7R2M1XHB0H",
"grant_id": "grt_01JC9Z6Q5W8N3K4T7R2M1XHB0K",
"request_id": "req_01JC9Z6Q5W8N3K4T7R2M1XHB0M",
"result": "ok",
"change_seq": 4077,
"prev_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"hash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
}
Reading it: the actor is an agent (agt_...), its model is recorded, it acted on behalf of usr_..., the channel was mcp, the operation was the restart_app tool under app:restart, the incident and the triggering event are both named, the grant that allowed it is named, the agent said why, the result was ok, and change-log entry 4077 is the restart itself (D-0029, D-0031).
The two hash values above are illustrative placeholders, not computed hashes; the prev_hash is drawn from a fixed pattern only so the example is readable.
2.2 Example: a refusal
An agent holding a read-only grant attempted delete_app; the call was refused before anything happened (D-0030, D-0031).
{
"seq": 1042,
"time": "2026-11-02T15:43:11Z",
"actor_type": "agent",
"actor_id": "agt_01JC9Z6Q5W8N3K4T7R2M1XHB0D",
"agent_model": "example-model-id",
"on_behalf_of": "usr_01JC9Z6Q5W8N3K4T7R2M1XHB0E",
"channel": "mcp",
"operation": "delete_app",
"scope": "app:delete",
"target_type": "app",
"target_id": "app_01JC9Z6Q5W8N3K4T7R2M1XHB0F",
"project_id": "prj_01JC9Z6Q5W8N3K4T7R2M1XHB0G",
"params": { "app_id": "app_01JC9Z6Q5W8N3K4T7R2M1XHB0F" },
"justification": "asked to remove the app; checking whether that is allowed",
"grant_id": "grt_01JC9Z6Q5W8N3K4T7R2M1XHB0N",
"request_id": "req_01JC9Z6Q5W8N3K4T7R2M1XHB0P",
"result": "refused",
"result_detail": "approval_required",
"prev_hash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
"hash": "f0e1d2c3b4a5968778695a4b3c2d1e0ff0e1d2c3b4a5968778695a4b3c2d1e0f"
}
A refusal has no change_seq, because nothing changed. result_detail carries the reason from the permission contract’s refusal table, so the M3 demo can print “grant expired” or “approval required” straight from the audit record (docs/product/roadmap.md, M3 Definition of Done; D-0030).
2.3 Example: an approval decision by a human
The same agent filed an approval request, and a human approved it in the panel. The approval created a grant, so the record has a change_seq (D-0030, D-0029).
{
"seq": 1043,
"time": "2026-11-02T15:47:52Z",
"actor_type": "user",
"actor_id": "usr_01JC9Z6Q5W8N3K4T7R2M1XHB0E",
"channel": "panel",
"operation": "POST /api/v1/approvals/{approval_id}/approve",
"scope": "approval:decide",
"target_type": "approval",
"target_id": "apr_01JC9Z6Q5W8N3K4T7R2M1XHB0Q",
"project_id": "prj_01JC9Z6Q5W8N3K4T7R2M1XHB0G",
"params": { "approval_id": "apr_01JC9Z6Q5W8N3K4T7R2M1XHB0Q", "decision": "approved", "decision_note": "confirmed with the app owner" },
"approval_id": "apr_01JC9Z6Q5W8N3K4T7R2M1XHB0Q",
"request_id": "req_01JC9Z6Q5W8N3K4T7R2M1XHB0R",
"result": "ok",
"change_seq": 4078,
"prev_hash": "f0e1d2c3b4a5968778695a4b3c2d1e0ff0e1d2c3b4a5968778695a4b3c2d1e0f",
"hash": "6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c"
}
A human record has no agent_model and no on_behalf_of (the human acted for themselves), and no justification (the decision_note is in params). The grant produced by this decision is a separate change-log entry, and the audit record for the destructive call that follows names both approval_id and grant_id (permission-model.md, section 9).
2.4 Reading the log
GET /api/v1/auditrequiresaudit:read, takeslimit(default 50, max 500),cursorandafter_seq, and returns{ "items": [...], "next_cursor": ... }(shared entity model, “Pagination and lists”).- Records are returned in
seqorder, oldest first (D-0031). - An agent’s selector filters the rows it may see; the list is filtered, never refused (permission-model.md, section 5.1; D-0030).
- There is no endpoint that updates or deletes a record.
GET /api/v1/auditandPOST /api/v1/audit/verifyare the only audit operations in openapi.yaml (D-0031).
3. What is recorded
Each call or action in the rows marked “yes” below produces exactly one record (D-0031):
| Event | Recorded | result |
|---|---|---|
| every mutating call by any identity, allowed | yes | ok |
| every mutating call that is refused | yes | refused, result_detail = reason |
| every mutating call that is authorized and then fails | yes | error, result_detail = error code |
every call by an agent, including reads (get_app, list_audit, get_app_logs) | yes | ok / refused / error |
| reads by humans (panel browsing, API reads) | no | — |
| approval requests filed, approved, denied, expired | yes | ok |
| system actions: incident opened and resolved, incident grant issued and revoked, fencing-related lease revocations, node enrollment and removal | yes | ok / error |
| audit verification runs | yes | ok / error |
the open of a terminal or console session (its POST) | yes, for every identity, as a mutating call | ok / refused / error |
| the close of a terminal or console session, for any reason | yes: one session.end record (section 3.3) | ok / error |
| a session transcript fetch, by any identity | yes, also for humans, because a transcript holds secrets (section 3.3) | ok / refused / error |
| bytes, keystrokes and control frames inside a session | no; the transcript holds them on the node, and session.end carries its hash | — |
| authentication failures (no resolvable identity) | no audit record; server log only | — |
Why the split falls where it does:
- Agents are recorded even on reads because an agent’s reading pattern is part of the risk the log exists for: what it looked at before it acted is evidence (D-0029, D-0031). The M3 demo reconstructs a whole incident from these records.
- Human reads are not recorded because the volume would bury the signal; a human’s mutating calls and their approval decisions are recorded (D-0031).
- Authentication failures have no identity, so there is nobody to attribute the record to. They are counted in the server log and in metrics, not in the chain (D-0031).
System records exist so that “the system did it” is not a blank space in the story: an incident grant that appears from nowhere, or a lease revoked because a node lost it, would otherwise be an unexplained state change (D-0029; D-0013). Node enrollment and removal are recorded because they change who is in the cluster (D-0011, D-0031).
3.1 Atomic with the change
An authorized mutating call writes, in one SQLite transaction:
- the change-log entry (the append-only state change, D-0013);
- the audit record, with
change_seqset to that entry’sseq; - any grant bookkeeping, such as
uses += 1(permission-model.md, section 7.3).
If the transaction rolls back, none of the three exists. An audit record with a change_seq therefore always points at a change that happened, and a change-log entry produced by an API or MCP call always has an audit record pointing at it (D-0031).
change_seq is absent for calls that changed nothing: refusals, reads, and errors before any write.
3.2 Replication of audit records
The audit chain is separate from the change-log chain, but audit records travel on the change log: each audit record is also replicated to replica nodes as a change-log entry of entity_type audit (D-0013). A replica node therefore verifies two chains, and the audit chain reaches replicas only through a verified change-log stream (D-0013; node-agent-protocol.md).
The audit_log table itself is the primary’s copy; it is not a local table (see the “local” markings in db-schema.sql).
3.3 Interactive sessions (M4)
A web terminal or VM console session lasts minutes, and one record per call does not describe it (ADR 0115). A session therefore leaves exactly two records, the open and the end, paired by the session id.
The open is the normal record of POST /api/v1/apps/{app_id}/terminal-sessions (scope app:terminal, target_type app) or POST /api/v1/vms/{vm_id}/console-sessions (scope vm:console, target_type vm). Its params are the request body plus the values the primary resolved: session_id (the trm_ id the call minted, so the end record can be paired with it), vm_id, and for a terminal rows, cols, command (the argv that runs, after the default is applied) and user (absent when the app’s own user applies); for a console tail_bytes. The single-use ticket is never in params. A refused or failed open writes its record and no session exists.
The end is a record the primary writes when an opened session closes for any reason, including a ticket that was never used and a primary restart that lost the session (interactive_sessions in db-schema.sql migration 0005 lets it close those at start-up):
| Field | Value |
|---|---|
actor_type, actor_id, agent_model, on_behalf_of, channel | the same as the open’s |
operation | session.end |
scope | the open’s scope (app:terminal or vm:console) |
target_type, target_id, project_id | the open’s |
grant_id | the open’s, when an agent opened it |
request_id | the req_ id of the DELETE /api/v1/interactive-sessions/{session_id} that ended it, or a new req_ id minted by the primary for any other close; never the open’s id, which belongs to the open call |
result | ok, or error when close_reason is error or node_disconnected |
result_detail | absent, or the error code when result is error |
change_seq | absent; a session changes no replicated state |
params | the object below |
params field | Type | Required | Meaning |
|---|---|---|---|
session_id | trm_ id | yes | the session, as in the open’s params |
kind | terminal | console | yes | |
node_id | nod_ id | yes | the node that ran the session and holds its transcript |
vm_id | vm_ id | yes | the VM the session was attached to |
opened_at | RFC 3339 | yes | when the open was recorded |
connected | boolean | yes | whether a WebSocket ever attached; false when the ticket expired unused |
duration_ms | integer | yes | from the open to the close |
bytes_in | integer | yes | bytes the client sent into the session (always 0 for a console) |
bytes_out | integer | yes | bytes the session sent to the client |
exit_code | integer | no | the program’s exit code, terminal sessions whose program exited |
close_reason | string | yes | one of exited, ended_by_client, ended_by_request, ticket_expired, idle_timeout, max_duration, access_revoked, credential_expired, vm_stopped, node_disconnected, transcript_limit, primary_restarted, error (meanings in openapi.yaml, InteractiveSessionCloseReason) |
ended_by | usr_ or agt_ id | no | who called the DELETE, when close_reason is ended_by_request |
transcript_sha256 | 64 lowercase hex chars | no | SHA-256 of the transcript file exactly as the node stored it; terminal sessions that connected |
transcript_bytes | integer | no | size of that file |
Because session.end is chained like every record, a transcript edited after the session ended no longer matches transcript_sha256, and the edit is evident (ADR 0031, ADR 0115).
Transcripts. A terminal session’s transcript is recorded by the node that runs it, never sent to the primary unless a human fetches it, and never part of the audit log, the change log or the off-site copy (ADR 0014, ADR 0115):
- Format: asciicast v2 (newline-delimited JSON): a header line
{"version": 2, "width": <cols>, "height": <rows>, "timestamp": <unix seconds>, "env": {"TERM": ...}}, then one event line per chunk,[<seconds since start>, "i" | "o" | "r", <data>]:iinput,ooutput,rresize as"<cols>x<rows>". Bytes that are not valid UTF-8 are written with U+FFFD replacement in<data>; the hash covers the file as written. - Cap: 16 MiB per session. When the next chunk would pass the cap the node ends the session with
close_reasontranscript_limit, so no part of a session is unrecorded. - Console sessions have no transcript. They are read-only and show the VM’s own serial output, which the node already keeps in its console ring (ADR 0116); their
session.endcarries no transcript fields. - Retention: 30 days on the node, and at most 1 GiB of transcripts per node, the oldest deleted first. Deleting the app or its VM does not delete them; removing the node or purging its data does. A deleted transcript leaves its
session.endrecord, whose hash then has nothing to verify. - Access:
GET /api/v1/interactive-sessions/{session_id}/transcript, which needsaudit:readand a human caller (permission-model.md, section 7.6). The primary relays the file from the node over the node session, on demand; it answersnode_unreachable(504) when the node is not connected andnot_found(404) when the node no longer has the file. Every fetch is recorded, withtarget_typevm, the session’s VM astarget_idandparams{session_id}.
3.4 Password changes (M4)
POST /api/v1/auth/password (ADR 0135, openapi.yaml, changePassword) writes one record per call that reached an identity, with the user as actor and as target (target_type user, target_id the user’s id), no scope and channel panel:
| Outcome | result | result_detail | change_seq |
|---|---|---|---|
| password changed | ok | absent | the change-log seq of the users update |
current_password does not match | refused | wrong_password | absent |
| attempt rate limited | refused | rate_limited | absent |
new_password breaks the password rule or equals the current one | error | validation_failed | absent |
params carries both keys, redacted (section 4): {"current_password": "[redacted]", "new_password": "[redacted]"}. The wrong_password records are the trail of someone guessing a password with a stolen session. A request without a session cookie, or with an Authorization header, has no identity to attribute and is not recorded (section 7).
4. Redaction
Requests can carry secrets. The audit record stores the request parameters, so it must not become a second secret store (D-0031).
The redaction rule:
- The values of the keys
value,values,secret,password,current_password,new_password,tokenandkeyare replaced by the string"[redacted]", at any depth of theparamsobject. - Every environment variable value is replaced by
"[redacted]", whatever the key is called. Environment variable names are kept, because a name is not a secret and the record of which variable changed is the useful part (D-0031; openapi.yaml,app_env). - Everything else is stored as sent.
Build arguments (source.build_args[].value in openapi.yaml) are not secrets, since they end up in the image’s history, but they sit under the key value and are therefore redacted like every other value. Build secrets are passed by name only (the names of app environment variables), so their values never reach a request.
Provider credentials are covered by the secret key: the requests that carry them (POST /api/v1/providers and POST /api/v1/providers/{provider_id}/rotate in openapi.yaml) put the credential under secret, so it is redacted like any other secret (D-0051).
This list is part of the contract: adding a key to it is a contract change, because a verifier or a consumer that expects an unredacted field would otherwise silently see a different record.
{ "name": "DATABASE_URL", "value": "[redacted]" }
Redaction happens before serialization and therefore before hashing: the hash covers the redacted record, which is what is stored and what the chain verifies (section 5). This is deliberate — the record must be verifiable by anyone who can read it, including an auditor holding only the exported copy.
A parameter that is a secret but whose key is not on the list is an implementation bug to fix in the redaction list, not a reason to hash the unredacted value (D-0031).
5. The hash chain
Each record’s hash is:
hash = lowercase_hex( SHA-256( JCS( record_object_without_hash ) ) )
where JCS is the JSON Canonicalization Scheme (RFC 8785) and record_object_without_hash contains every field of the record except hash, with absent optional fields omitted rather than null (shared entity model, “Audit record”; the change log uses the same construction, D-0013).
prev_hashofseq1 is 64 zeros ("0000000000000000000000000000000000000000000000000000000000000000").- For
seq> 1,prev_hashequals thehashofseq - 1. seqis gap-free from 1; there is no hole and no duplicate (D-0031).
Because the serialization is canonical, a record can be re-hashed by any implementation that has the record’s JSON: field order, whitespace and number formatting do not affect the result (RFC 8785, cited by the shared entity model).
5.1 Worked chain (illustrative)
Two records, with hashes shortened for reading. The real values are 64 hex characters.
seq | Key fields | prev_hash | hash |
|---|---|---|---|
| 1 | actor_type system, operation POST /api/v1/incidents/{incident_id}/resolve | 0000…0000 (64 zeros) | H1 |
| 2 | actor_type agent, operation restart_app, grant_id grt_A | H1 | H2 |
To verify record 2:
- Take the JSON object of record 2 without
hash. - Canonicalize it with RFC 8785.
- SHA-256 it; the lowercase hex must equal
hash. - Check that
prev_hashequals record 1’shash. - Check that
seqis 2, one more than record 1’sseq.
Editing any field of record 2 changes H2; editing any field of record 1 changes H1, which breaks record 2’s prev_hash as well. That second effect is the point of the chain: one edited record makes every later record fail.
The H1/H2 values above are placeholders, not computed hashes.
5.2 What the chain is separate from
- The change-log chain (D-0013) chains state changes; the audit chain chains records about actions. They have independent
seqspaces and independent head hashes, and a verifier must not mix them. - The two are joined per record by
change_seq, which points from an audit record to the change it produced, not the other way round (section 3.1).
6. Verification
POST /api/v1/audit/verify requires audit:read and re-walks the chain. The algorithm:
prev = 64 zeros if first_seq == 1, else the hash of record first_seq - 1
expect_seq = first_seq
checked = 0
for record in audit_log ordered by seq, from first_seq to last_seq:
if record.seq != expect_seq -> broken: reason "seq_gap", broken_at_seq = record.seq
h = sha256_hex(jcs(record without hash))
if h != record.hash -> broken: reason "hash_mismatch", broken_at_seq = record.seq
if record.prev_hash != prev -> broken: reason "prev_mismatch", broken_at_seq = record.seq
prev = record.hash
expect_seq += 1
checked += 1
The result object:
{
"ok": true,
"checked": 1043,
"first_seq": 1,
"last_seq": 1043,
"head_hash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
}
and on failure:
{
"ok": false,
"checked": 517,
"first_seq": 1,
"last_seq": 1043,
"head_hash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
"broken_at_seq": 518,
"reason": "hash_mismatch"
}
| Field | Meaning |
|---|---|
ok | true only if every record verified |
checked | records verified before the walk stopped |
first_seq, last_seq | the range walked |
head_hash | hash of last_seq |
broken_at_seq | the first record that failed; absent when ok is true |
reason | hash_mismatch, prev_mismatch or seq_gap; absent when ok is true |
On failure the service emits audit.chain_broken, the only audit-related event type (README.md, “Event types”; section 10). The verification run itself is audited: a record with operation POST /api/v1/audit/verify, result ok or error, and the outcome in result_detail when it failed.
This matches the M3 Definition of Done: after one entry is edited in the local copy, verification reports that exact entry as broken (docs/product/roadmap.md, M3 Definition of Done; D-0013, D-0031).
6.1 Offline verification
quoro audit verify --file <jsonl> verifies an exported file with no server, no database and no network:
- the file is JSON Lines, one record per line, in
seqorder (the same format the off-site copy uses, section 8); - the first record’s
prev_hashis checked against the 64-zero seed unless a startingprev_hashis supplied for a segment that does not begin atseq1; - the same three checks apply: recomputed
hash,prev_hashlink,seqcontinuity; - it exits non-zero and names the failing
seqand reason when the file is broken.
This is the tool an auditor runs against a downloaded segment without trusting the server that produced it (D-0031).
7. Limits, stated honestly
A hash chain is a tamper-evident structure, not a tamper-proof one. It detects:
- a modified record;
- a reordered record;
- an inserted record in the middle;
- a deleted record in the middle (the following record’s
prev_hashno longer matches).
It does not detect:
- Truncation of the tail. Deleting the last N records leaves a chain that verifies perfectly, because nothing after them points back. Only a head hash recorded somewhere else catches this.
- A complete rewrite. Someone with write access to the database and the code can recompute every hash from
seq1 and produce a consistent, valid chain.
The only defence against both is that the head is known outside the server that produced it: the off-site copy (section 8) plus anchoring the head hash somewhere the operator does not control (D-0031). This is precisely why D-0031 asks for a copy out of reach of whoever operates the server, and why the design does not claim that the local chain alone is enough.
Two further honest limits:
- The chain proves internal consistency, not that the recorded parameters and results are true; a lying service writes a consistent lie.
- It proves nothing about calls that never reached the audit gate. Authentication failures are not recorded (section 3), so a burst of failed logins is not visible in the chain.
8. Off-site copy
The off-site copy ships in M5a with the S3-compatible path (D-0053), gated by an object-lock probe per provider. Object-lock support per provider is unverified (Hypothesis) until the research task before M5a and the probe.
8.1 Segments
-
Records are exported as canonical JSON Lines: one audit record per line, serialized with RFC 8785, in
seqorder, redacted as stored (section 4). -
A segment is closed every 1,000 records or 5 minutes, whichever comes first.
-
The object key is:
<prefix>/<cluster_id>/audit/<first_seq 20-digit zero-padded>-<last_seq 20-digit>.jsonl -
Alongside each segment, a small head object is written:
<prefix>/<cluster_id>/audit/head-<last_seq 20-digit>.jsoncontaining
{ "last_seq": ..., "head_hash": "...", "time": "..." }. -
Each object is written once. Segment keys are unique (the seq range is in the key), so an export never overwrites a previous segment.
-
Each object is written with an object-lock retention in compliance mode, so the store itself refuses deletion or overwrite until the retention passes (D-0031).
-
The exporter’s credentials are limited to put: no delete, no overwrite of a locked object. A credential that can delete defeats the purpose of the copy (D-0031).
-
Progress is recorded in
audit_exports(target,last_exported_seq,object_lock,updated_at), so a restarted exporter resumes after the last closed segment (shared entity model; db-schema.sql).
8.2 Provider caveats
- Object lock and conditional writes are not portable across S3-compatible providers (F-0038;
docs/research/r2-conditional-writes.md). The provider adapter probes the capability at setup, and refuses to report the copy as “locked” if the probe fails:audit_exports.object_lockstays false and the panel says so, rather than promising a lock the store does not enforce. - Whether a given provider implements object lock in the form this contract needs is Hypothesis, not covered by
docs/research/; F-0038 covers conditional writes only, and object-lock support per provider must be probed the same way. - R2 limits concurrent writes to the same key to one per second (F-0037). Segment keys are unique and each object is written once, so the limit does not bind this design. The head object has one key per
last_seq, also unique, so it does not bind either. - A failed put is retried; a segment is only recorded as exported in
audit_exportsafter the put is acknowledged. After a crash between put and bookkeeping, the exporter lists the keys afterlast_exported_seqbefore cutting a new segment and adopts an existing segment (and its head object) whose content verifies, instead of writing again: a locked object cannot be overwritten, and a re-cut range would overlap.
8.3 What the copy does and does not solve
It solves tail truncation and full rewrite on the server, because the segments and the head hashes live somewhere the operator of the server cannot rewrite (D-0031). It does not solve a compromised provider account, and it does not make the local log immutable. The honest statement is: the local chain plus an out-of-reach copy make silent rewriting detectable by anyone who compares the two heads (section 7).
9. Retention
- Audit records are never deleted in M0-M6. There is no retention job, no vacuum of
audit_logand no export-then-prune step (D-0031). - A retention policy would be a decision of its own, and this contract does not anticipate its shape. It would have to reconcile “append-only” with deletion and say what happens to the chain when a middle segment is removed (D-0031, D-0036).
- Growth is bounded in practice by the fact that only mutating calls, agent reads and system actions are recorded, not human reads (section 3). That is an expectation, not a limit this contract fixes.
10. Relationship to events
- Audit records are not events. They are not in
events, they are not delivered by webhooks and they do not appear in the event stream. A consumer that wants to react to agent activity uses the audit API withaudit:read(D-0031; events.schema.json). audit.chain_brokenis the only audit event type. It is emitted when verification fails and is the one way the audit log reaches the event stream (README.md, “Event types”).- A mutating call may produce both an audit record and a domain event, for example a restart that produces an
app.restartedevent and an audit record withchange_seq. They are different records for different consumers, and neither replaces the other (D-0031, D-0032).
Engine trait
Status: M0 draft for review. Contracts freeze at the end of M0 (D-0036); after the freeze a change to this file is a standalone contract pull request, never part of feature work.
This file fixes the boundary behind which a virtual machine monitor sits. Cloud Hypervisor with KVM is the first implementation (D-0007), and the boundary exists so that other backends — Firecracker, a container runtime — can be added later without touching the rest of the system (D-0007, D-0009). The MVP requires a host with KVM; a container fallback is a later implementation of the same trait, not a mode of the first one (D-0009).
The node agent is the only caller. Nothing else in Quoro — not the API, the panel, MCP or the CLI — talks to the engine directly; they change desired state, and the node agent turns that into engine calls (node-agent-protocol.md). The engine therefore has no authentication, no tenancy and no policy of its own: it does what the node agent asks on the node it runs on, and the node agent is the component that checks leases, tokens and desired state first.
This file is a contract about behaviour, not about file layout. Names of modules, crates and private helpers may change; the types, method names, semantics and error variants below may not, except by a standalone contract pull request.
1. Shape of the boundary
| Property | Choice | Why |
|---|---|---|
| Language | Rust, async, tokio runtime | The node agent is Rust and async; the engine runs in its process |
| Dispatch | One trait, implemented per backend, selected by an enum at start-up | The trait is not object-safe, so there is no dyn; boxed futures would meet one |
| Ownership | One engine instance per node, shared by the node agent as Arc | Serialization of per-VM operations happens above the engine |
| Process model | One VMM process per VM, outside the agent process | A VMM survives an agent restart, so an upgrade does not restart apps (D-0011) |
| State | The engine owns node-local files; the cluster state is the node agent’s | Desired state is signed and stored by the agent, not by the engine (D-0011) |
| Telemetry | The engine exposes streams and counters; it stores no logs | Logs and metrics stay on the node (D-0014) |
The async methods are declared as fn ... -> impl Future<Output = ...> + Send, so a caller generic over E: Engine can tokio::spawn an engine call (D-0071); backends still implement them with async fn. The trait is not object-safe, which is why the node agent selects a backend through an enum (enum Backend { CloudHypervisor(ChEngine), Fake(FakeEngine) }) rather than Box<dyn Engine>; a need for dyn would be met by boxed futures with otherwise identical signatures.
2. The trait
#![allow(unused)]
fn main() {
// Rust 2024 edition (D-0071).
use std::fmt;
use std::future::Future;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::pin::Pin;
use std::time::{Duration, SystemTime};
use futures_core::Stream;
use tokio::io::{AsyncRead, AsyncWrite};
/// vm_<ULID>, the same identity the control plane uses (README.md).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct VmId(pub String);
/// vol_<ULID>; volumes exist as a shape from M5 (README.md).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct VolumeId(pub String);
/// "sha256:<64 hex>".
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ImageDigest(pub String);
/// A MAC address, six bytes in the order the guest sees them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct MacAddr(pub [u8; 6]);
}
#![allow(unused)]
fn main() {
/// An image as requested, and the digest it resolved to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImageRef {
pub reference: String,
pub digest: ImageDigest,
}
/// An OCI image layout on the node, as a builder wrote it. Added in M4.
/// Section 3.17.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OciLayout {
/// Directory or tarball of the layout (OCI image-layout 1.0).
pub path: PathBuf,
/// Digest of the image manifest the layout's index names.
pub manifest_digest: ImageDigest,
}
/// A rootfs that is ready to boot. Cached by digest.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreparedImage {
pub digest: ImageDigest,
pub rootfs_path: PathBuf,
pub size_bytes: u64,
pub config: ImageConfig,
}
/// What the image says about how to run it, read from its OCI config.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImageConfig {
pub entrypoint: Vec<String>,
pub cmd: Vec<String>,
pub env: Vec<(String, String)>,
pub user: Option<String>,
pub workdir: Option<String>,
pub exposed_ports: Vec<u16>,
}
}
#![allow(unused)]
fn main() {
/// Everything needed to create one microVM.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VmSpec {
pub id: VmId,
pub vcpus: u8,
pub memory_mb: u32,
/// Digest of a PreparedImage; the rootfs is mounted read-only.
pub rootfs: ImageDigest,
/// Size of the writable data disk.
pub disk_gb: u32,
pub volumes: Vec<VolumeAttach>,
pub net: NetAttach,
pub init: InitConfig,
/// Where the app's tagged output goes (section 3.13). From M4 the node
/// agent sets `VirtioConsole` for apps and builder VMs alike.
pub console: ConsoleMode,
/// The serial port (`ttyS0`): kernel and init diagnostics. From M4 the node
/// agent sets `Socket`, and the engine is its only client (section 3.19,
/// ADR 0116). Added in M4.
pub serial: SerialMode,
/// The virtio-vsock device that carries exec sessions (section 3.18,
/// ADR 0113). `None` means no device. Added in M4.
pub vsock: Option<VsockAttach>,
/// Extra writable disks that outlive the VM, identified by serial. Only
/// builder VMs use them (ADR 0118); app VMs carry none. Added in M4.
pub work_disks: Vec<WorkDisk>,
}
impl VmSpec {
/// The canonical form used to decide whether two specs are the same.
///
/// Section 3.6 compares the same `id` by the canonical form of `VmSpec`,
/// so `env` is sorted by `(name, value)` and `volumes` by
/// `(volume_id, mount_path)`. Everything else keeps its order: it is
/// either a scalar or an ordered list where order is meaningful.
#[must_use]
pub fn canonical(&self) -> VmSpec {
let mut canonical = self.clone();
canonical
.init
.env
.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
canonical.volumes.sort_by(|a, b| {
a.volume_id
.0
.cmp(&b.volume_id.0)
.then_with(|| a.mount_path.cmp(&b.mount_path))
});
canonical
}
}
/// A volume attached to a VM at a mount path inside the guest.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VolumeAttach {
pub volume_id: VolumeId,
pub mount_path: String,
}
/// The VM's network attachment on the node bridge.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NetAttach {
pub ip: Ipv4Addr,
pub gateway: Ipv4Addr,
pub mac: MacAddr,
/// Isolation group of the VM. VMs on the same bridge that carry the same
/// group reach each other; VMs in different groups, and a VM with `None`,
/// reach no other VM. The node agent sets it to the VM's project id
/// (`prj_`). Section 3.3.
pub isolation_group: Option<String>,
/// Egress only: the VM reaches the outside through the gateway and nothing
/// on the node, neither another VM nor the host (section 3.3). Builder VMs
/// set it, with `isolation_group: None` (ADR 0118). Added in M4.
pub egress_only: bool,
}
/// The app's command line and identity, as the minimal init must apply it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InitConfig {
pub entrypoint: Vec<String>,
pub cmd: Vec<String>,
pub env: Vec<(String, String)>,
pub user: Option<String>,
pub workdir: Option<String>,
}
/// How (and whether) a console device is attached.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConsoleMode {
/// No console device.
Off,
/// virtio-console to this UNIX socket; carries tagged stdout/stderr lines.
VirtioConsole { socket: PathBuf },
/// Serial console to this UNIX socket; interactive text, one client at a time.
/// Superseded in M4 by `VmSpec::serial` (section 3.14); a spec that sets it
/// together with a `serial` other than `Off` is `Conflict`.
Serial { socket: PathBuf },
}
/// How the serial port (`ttyS0`) is attached. Added in M4. Section 3.19.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SerialMode {
/// No serial device.
Off,
/// Serial output appended to this file (the M1-M3 behaviour).
File { path: PathBuf },
/// Serial port on this UNIX socket; the engine connects to it as the only
/// client from `create` on and keeps a node-local ring of its output.
Socket { path: PathBuf },
}
/// A virtio-vsock device in Cloud Hypervisor's hybrid mode: the host end is a
/// UNIX socket in the VM's runtime directory. Added in M4. Section 3.18.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VsockAttach {
/// Guest context id. Each VM has its own vsock namespace, so every VM uses 3.
pub cid: u32,
pub socket: PathBuf,
}
/// A writable disk that the engine attaches but does not own: `delete` keeps
/// it, and the node agent removes it. Added in M4. Section 3.6.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkDisk {
pub path: PathBuf,
/// Size of the disk when the engine has to create it.
pub size_gb: u32,
/// virtio-blk serial the guest finds it by, for example `quoro-work`.
pub serial: String,
}
}
#![allow(unused)]
fn main() {
/// How a stop is requested.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StopMode {
/// Ask the guest to stop and wait at most this long.
Graceful { timeout: Duration },
/// Stop now, without cooperation from the guest.
Force,
}
/// Mirrors the VM observed_status enum in README.md one to one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VmState {
Creating,
Stopped,
Starting,
Running,
Stopping,
Crashed,
Deleted,
Unknown,
}
/// One VM's observed state.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VmInfo {
pub id: VmId,
pub state: VmState,
/// Present while a VMM process exists for this VM.
pub pid: Option<u32>,
pub started_at: Option<SystemTime>,
pub exit_code: Option<i32>,
}
/// A lifecycle event for any VM on the node. Section 3.12.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EngineEvent {
Started { id: VmId },
Exited { id: VmId, code: i32 },
Crashed { id: VmId, reason: String },
}
/// One tagged line of app output.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutputLine {
pub stream: OutputStream,
pub time: SystemTime,
pub line: String,
}
/// Which of the app's two output streams a line came from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OutputStream {
Stdout,
Stderr,
}
/// Cumulative counters since VM start. Section 3.15.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VmCounters {
pub cpu_time_ms: u64,
pub memory_rss_bytes: u64,
pub net_rx_bytes: u64,
pub net_tx_bytes: u64,
pub block_read_bytes: u64,
pub block_write_bytes: u64,
}
/// What this backend and build support. Section 3.1.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Capabilities {
pub engine: String, // "cloud-hypervisor"
pub version: String, // the pinned release
pub kvm: bool,
pub text_console: bool, // true
pub graphical_console: bool, // false
pub vm_snapshot: bool,
pub volume_snapshot: bool,
/// Whether `exec` works on this backend and build (section 3.18). Added in M4.
pub exec: bool,
}
/// The node network a VM is attached to. Section 3.3.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeNetwork {
pub bridge: String,
pub subnet: String, // CIDR, for example "10.42.1.0/24"
pub gateway: Ipv4Addr,
/// DNS servers handed to the guests created on this network, in order.
/// Taken from the node's configuration; empty means guests get no
/// resolver. Not part of the network's identity. Section 3.3.
pub dns: Vec<Ipv4Addr>,
}
/// A point-in-time volume snapshot descriptor. Section 3.16.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VolumeSnapshot {
pub volume_id: VolumeId,
pub snapshot_id: String,
pub created_at: SystemTime,
pub size_bytes: u64,
/// Node-local location of the snapshot; not part of any API.
pub location: PathBuf,
}
/// Every method is total: it either succeeds or returns one of these.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EngineError {
NotFound,
Conflict(String),
Unsupported(String),
ResourceExhausted(String),
Image(String),
Hypervisor(String),
Io(String),
Timeout,
}
impl fmt::Display for EngineError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// one short lowercase phrase per variant
...
}
}
impl std::error::Error for EngineError {}
}
#![allow(unused)]
fn main() {
/// A stream of every lifecycle event on the node.
pub type EventStream = Pin<Box<dyn Stream<Item = EngineEvent> + Send>>;
/// A stream of one VM's tagged output lines.
pub type LineStream = Pin<Box<dyn Stream<Item = OutputLine> + Send>>;
/// Bidirectional byte stream on a text console (serial or virtio-console).
pub trait ConsoleIo: AsyncRead + AsyncWrite + Send + Unpin {}
/// Any async byte stream is a console stream; tokio's duplex and UNIX socket
/// streams satisfy this without an adapter.
impl<T: AsyncRead + AsyncWrite + Send + Unpin + ?Sized> ConsoleIo for T {}
/// One end of a text console connection.
pub type ConsoleStream = Pin<Box<dyn ConsoleIo>>;
/// Raw bytes in arrival order, for example a VM's serial output. Added in M4.
pub type ByteStream = Pin<Box<dyn Stream<Item = Vec<u8>> + Send>>;
/// A program to run in a running VM, on a PTY. Added in M4. Section 3.18.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExecRequest {
/// The argv; `argv[0]` without `/` is searched in the app's `PATH`.
pub argv: Vec<String>,
/// A user name or uid, optionally `:` and a group name or gid; `None` is the app's own user.
pub user: Option<String>,
pub rows: u16,
pub cols: u16,
}
/// One exec session. Added in M4. Section 3.18.
pub struct ExecSession {
/// The PTY: reads are the program's output, writes are its input. Dropping
/// or shutting it down ends the session (`SIGHUP` to its process group).
pub io: ConsoleStream,
/// Resize requests as `(rows, cols)`.
pub resize: tokio::sync::mpsc::Sender<(u16, u16)>,
/// Resolves once with the program's exit code, `128 + signal` when a
/// signal ended it; an error when the session ended without one.
pub exit: tokio::sync::oneshot::Receiver<i32>,
}
/// The boundary behind which a virtual machine monitor sits.
///
/// The async methods return `impl Future<Output = _> + Send`, so a caller
/// generic over `E: Engine` can spawn an engine call on the tokio runtime
/// (D-0071). A backend still implements them with `async fn`. The trait is not
/// object-safe, which is why the node agent selects a backend through an enum;
/// a `dyn` need would be met by boxed futures with otherwise identical
/// signatures.
pub trait Engine: Send + Sync {
// Host and node preparation
fn capabilities(&self) -> impl Future<Output = Capabilities> + Send;
fn probe_host(&self) -> impl Future<Output = Result<HostProbe, EngineError>> + Send;
fn ensure_network(
&self,
net: &NodeNetwork,
) -> impl Future<Output = Result<(), EngineError>> + Send;
// Images
fn prepare_image(
&self,
image: &ImageRef,
) -> impl Future<Output = Result<PreparedImage, EngineError>> + Send;
fn remove_image(
&self,
digest: &ImageDigest,
) -> impl Future<Output = Result<(), EngineError>> + Send;
/// Added in M4; section 3.17.
fn prepare_layout(
&self,
_layout: &OciLayout,
) -> impl Future<Output = Result<PreparedImage, EngineError>> + Send {
async {
Err(EngineError::Unsupported(
"local OCI layouts are not implemented by this engine".into(),
))
}
}
// Lifecycle
fn create(&self, spec: &VmSpec) -> impl Future<Output = Result<VmInfo, EngineError>> + Send;
fn start(&self, id: &VmId) -> impl Future<Output = Result<VmInfo, EngineError>> + Send;
fn stop(
&self,
id: &VmId,
mode: StopMode,
) -> impl Future<Output = Result<VmInfo, EngineError>> + Send;
fn delete(&self, id: &VmId) -> impl Future<Output = Result<(), EngineError>> + Send;
fn inspect(&self, id: &VmId) -> impl Future<Output = Result<VmInfo, EngineError>> + Send;
fn list(&self) -> impl Future<Output = Result<Vec<VmInfo>, EngineError>> + Send;
// Observation
fn events(&self) -> EventStream;
fn output(&self, id: &VmId) -> Result<LineStream, EngineError>;
fn console(&self, id: &VmId) -> Result<ConsoleStream, EngineError>;
fn counters(&self, id: &VmId) -> impl Future<Output = Result<VmCounters, EngineError>> + Send;
// Interactive access (M4; default implementations return Unsupported)
/// Section 3.18.
fn exec(
&self,
_id: &VmId,
_request: &ExecRequest,
) -> impl Future<Output = Result<ExecSession, EngineError>> + Send {
async { Err(EngineError::Unsupported("exec is not implemented by this engine".into())) }
}
/// Section 3.19.
fn serial_output(
&self,
_id: &VmId,
_tail_bytes: Option<u64>,
) -> Result<ByteStream, EngineError> {
Err(EngineError::Unsupported(
"serial output is not implemented by this engine".into(),
))
}
// Volumes (M5; default implementations return Unsupported)
fn snapshot_volume(
&self,
_id: &VolumeId,
) -> impl Future<Output = Result<VolumeSnapshot, EngineError>> + Send {
async {
Err(EngineError::Unsupported(
"volume snapshots are not implemented by this engine".into(),
))
}
}
fn delete_volume_snapshot(
&self,
_snap: &VolumeSnapshot,
) -> impl Future<Output = Result<(), EngineError>> + Send {
async {
Err(EngineError::Unsupported(
"volume snapshots are not implemented by this engine".into(),
))
}
}
}
/// The result of the host check in section 3.2.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostProbe {
pub kvm_available: bool,
pub kernel_release: String,
/// True when the kernel is recent enough for the backend (6.8 or later for
/// Cloud Hypervisor v54, section 3.2).
pub kernel_supported: bool,
pub cpu_vmx: bool,
pub cpu_svm: bool,
pub nested: bool,
}
}
The derives, MacAddr, the blanket ConsoleIo implementation, Display and Error for EngineError and VmSpec::canonical (section 3.6) are contract items (D-0071), added additively during M0. events, output, console and serial_output are not async because they only build a stream; the first item is produced by the stream itself.
M4 adds, additively: VmSpec::serial, VmSpec::vsock and VmSpec::work_disks with SerialMode, VsockAttach and WorkDisk; NetAttach::egress_only; Capabilities::exec; OciLayout, ByteStream, ExecRequest and ExecSession; and the methods prepare_layout, exec and serial_output, whose default implementations return Unsupported, so a backend that does not offer them still compiles. Code that builds a VmSpec, NetAttach or Capabilities literal sets the new fields to SerialMode::File (the M1-M3 serial log), None, an empty list, false and false, which keeps the M1-M3 behaviour. VmSpec::canonical keeps work_disks in order: their serials are unique, so the order carries no meaning, but the node agent always builds them in the same order.
3. Method semantics
Every method is total in the sense that it either succeeds or returns one EngineError; no method panics on bad input, and no method returns a partially created VM on success. The rules below are this contract’s definitions; each one that follows from an accepted decision, a research finding or an open question cites it inline.
3.1 capabilities
Reports what this backend and build support. It never fails: a missing binary or an unusable host is reported as kvm: false, not as an error, because the node agent must be able to report a node’s real state without a working engine. engine is the backend name, version the pinned build, and graphical_console is false for Cloud Hypervisor: it has no VNC, SPICE, GPU or framebuffer device, and its consoles are text only (F-0034). text_console is true. exec reports whether exec (section 3.18) works: the backend attaches vsock devices and the guest init it ships listens for exec sessions. vm_snapshot reports whether the backend can snapshot VM memory and device state; that capability is not used by Quoro before M5 and never as a volume backup (F-0035).
3.2 probe_host
Checks the machine rather than a VM:
/dev/kvmexists and is writable;- the kernel release is at least 6.8, which Cloud Hypervisor v54 requires (F-0036);
- the CPU exposes
vmxorsvm; - nesting is visible when the machine is itself a guest.
The procedure follows docs/research/vps-nested-virt.md. A successful probe is necessary but not sufficient: /dev/kvm can exist while a guest still fails to start, so the node agent treats the first real boot as the confirmation and reports a failed boot as a deployment failure in the boot phase.
3.3 ensure_network
Brings the node network to the requested NodeNetwork: the bridge exists, carries the gateway address, and forwards app traffic. Quoro owns the networking layer — bridges, TAP devices, addresses, firewall rules — rather than delegating it (D-0007). Precondition: subnet and gateway are consistent. Postcondition: a VM with an address inside subnet can reach the gateway. Idempotent: calling it twice with the same values changes nothing. An existing bridge with a different subnet is Conflict, never a silent reconfiguration.
The identity of a network is bridge, subnet and gateway. dns is not part of it: a call that repeats the identity with a different dns is Ok and changes the servers given to VMs created afterwards; a VM that already exists keeps the servers it was created with until it is recreated. The node agent fills dns from the node’s own configuration, so guest resolvers are a property of the node, not of the control plane.
Isolation groups. Every VM on the bridge is isolated from every other VM on it, except from the VMs that carry the same NetAttach::isolation_group (D-0084). Two VMs with the same group exchange traffic over the bridge in both directions; two VMs with different groups, or a VM whose group is None, exchange nothing — neither frames on the bridge nor packets routed through the gateway. Every VM still reaches the gateway and, through it, the outside, and the host still opens connections to every VM, because the ingress runs on the host. The node agent sets the group to the VM’s project_id, so the VMs of one project reach each other and VMs of different projects do not. The rule is enforced by the engine and holds across VM creates, deletes and an agent restart; the group is part of the VM’s spec, so it is compared by create (section 3.6) like every other field.
Egress only. A VM whose NetAttach::egress_only is true reaches the outside through the gateway and nothing else on the node: no other VM, whatever its group; no address of the host (the node API, the ingress listeners, the node’s own services), except the DNS servers in NodeNetwork::dns on port 53; and no link-local address (169.254.0.0/16). The host may still open connections to it. Builder VMs run untrusted Dockerfiles with outbound network to fetch bases and dependencies, and this is their boundary (ADR 0118). egress_only with an isolation_group other than None is Conflict.
3.4 prepare_image
Pulls the OCI image, verifies that the pulled content matches ImageRef::digest, converts it into a rootfs image, records the extracted ImageConfig, and caches the result by digest.
- Precondition:
referenceanddigestare well formed. - Postcondition:
PreparedImage::rootfs_pathexists and boots; a second call with the same digest returns the cached value without network access. - Idempotent by digest. A digest mismatch between the reference and the pulled content is
Image. - A local OCI layout written by a builder takes
prepare_layoutinstead (section 3.17); both fill the same cache. - The conversion approach is decided (D-0061): Quoro’s own OCI-to-ext4 pipeline, with a Rust registry client and
mkfs.ext4 -d, validated by the M0 spike inspikes/oci-microvm. Three gaps must be closed before the pipeline ships: resolve layer paths inside the rootfs withopenat2RESOLVE_IN_ROOTinstead of refusing absolute-symlink parents, support zstd layers, and deliver the configuration through a config disk or the kernel command line instead of baking it into the rootfs.
3.5 remove_image
Removes a cached prepared rootfs by digest. Deleting a VM must not remove an image another VM uses, so removal is a reference-counted operation or is driven by the node agent after it has computed the desired set. Missing digest is Ok. Idempotent. Rootfs images are per-digest artifacts of the deployment pipeline (D-0008, D-0033), so disposing of them is the engine’s cleanup, not part of any API delete.
3.6 create
Creates the VM and its runtime state without starting it. One microVM per app instance, booted from a rootfs built from the OCI image (D-0008), with the lifecycle split that M1 requires (D-0033). Postcondition on success: a VMM process exists (or the backend’s equivalent), the VM is inspectable, and state is Stopped once creation finishes — Creating is only observable while the call is in flight.
- The same
idwith an equal spec returnsOkand the existingVmInfo. - The same
idwith a different spec isConflict. Equality is compared on the canonical form ofVmSpec,VmSpec::canonical(), which sortsenvby(name, value)andvolumesby(volume_id, mount_path); everything else keeps its order, so field order and the ordering of those two lists must be normalized before comparison. - Resources are reserved at
create, soResourceExhaustedis returned here rather than atstart. - Precondition:
rootfsis the digest of a prepared image,netis inside a network already ensured byensure_network, andvolumesare attached to this node. serial: Socketmakes the engine connect to the serial socket as soon as it exists, before boot, so the ring of section 3.19 holds the whole boot.vsockattaches the device; its socket lives in the VM’s runtime directory, which only root can enter.- Each
WorkDiskis attached as a writable virtio-blk disk with its serial. A missing file is created as an empty ext4 filesystem ofsize_gb; an existing file is attached as it is. Two VMs that name the same work-disk path at once isConflict. The engine never removes a work disk (section 3.9).
3.7 start
Boots a created or stopped VM. Postcondition on success: state is Running and EngineEvent::Started has been or will be emitted. A boot is fast enough that a stopped warm copy is a practical failover target (D-0008, D-0020); the boot-time figure is still a marketing claim, not a measured number (docs/research/cloud-hypervisor.md).
- Starting a running VM returns
Okand does not reboot it. Restarts are expressed as a spec change by the node agent (restart_generationinnode-agent-protocol.md), so the engine never has to guess intent. - Starting an unknown id is
NotFound. Starting a VM whose kernel or rootfs is missing isImageorIo.
3.8 stop
Stops a VM.
Graceful { timeout }asks the guest to exit — the power-button path, which the minimal init turns intoSIGTERMto the app (section 7) — and waits at mosttimeout. On expiry it falls back to forced stop. The call returnsOkonly when the VM is stopped.Forcestops without cooperation from the guest. If the VMM does not confirm the stop (its API does not answer, or the process does not exit within the call), the backend escalates toSIGKILLon the VMM process and reaps it.ForcereturnsOkonly when the VMM process is gone; an error fromForcemeans the VM may still be running, and the node agent must treat it that way (node-agent-protocol.mdsection 8.5, D-0074).- Stopping a stopped VM returns
Ok. Stopping an unknown id isNotFound. - Postcondition:
stateisStopped,exit_codereports the app’s code when the guest reported one, andEngineEvent::Exitedhas been or will be emitted.
3.9 delete
Removes the VM and its own disks. Missing id returns Ok, so a retried delete converges. Volumes are never removed by delete: they belong to the app, not to the instance, and are deleted through the API with app:delete (D-0033, README.md). Postcondition: the VMM process is gone, the root and data disks are removed, and inspect returns NotFound. Work disks are kept too: the node agent owns them and removes them with the app (ADR 0118). The serial ring of section 3.19 is removed with the runtime directory. Precondition: the VM is stopped; a call on a running VM is Conflict.
3.10 inspect
Returns one VM’s VmInfo. Unknown id is NotFound. This is the only method that observes lifecycle state; it never changes it. It is the engine-level source of the observed_status a node reports (D-0032 detect step), and it never reports a state outside the enum in README.md.
3.11 list
Returns every VM the engine knows about on this node, including VMs created by a previous agent process. This is how the node agent rediscovers its world after a restart: VMMs are separate processes and keep running, so list must find them from the runtime directories on disk and the backend’s API, not from in-memory state (D-0011). A VM whose runtime directory exists but whose process is gone is reported as Crashed with the last known exit code, or as Stopped when the exit followed a requested stop. list never starts, restarts or deletes anything.
3.12 events
A stream of EngineEvent for every VM on the node. The node agent maps them to the event types in README.md — a Crashed becomes vm.crashed, a requested stop becomes vm.stopped, a successful boot becomes vm.started (D-0032). The stream exists for the lifetime of the engine instance; a slow consumer must not block the engine, so the implementation buffers and drops with a recorded counter rather than applying backpressure to the VMM.
3.13 output
A stream of OutputLine for one VM: the app’s stdout and stderr, tagged by stream, produced by the minimal init and forwarded over the virtio-console. This feeds local logs, which stay on the node and are queried on demand (D-0014). A second concurrent call for the same VM is Conflict when the backend can serve only one reader; NotFound when the VM has no console configured (ConsoleMode::Off).
3.14 console
A bidirectional byte stream on the text console: a human types into a terminal in the panel, and the bytes reach the guest’s serial console (F-0034; consoles are text-only, xterm.js over serial or virtio-console, D-0056). Used from M4 (D-0033). Text only; there is no graphical console to attach to (F-0034, D-0056). The Cloud Hypervisor console socket accepts one client at a time, so a second concurrent call is Conflict (docs/research/cloud-hypervisor.md).
Superseded in M4. A second connection to a Cloud Hypervisor serial socket silently takes the port away from the current client, and nothing in the guest reads the serial port, so a direct, bidirectional connection per viewer is unsafe and useless (ADR 0116). The M4 console is read-only and comes from serial_output (section 3.19); the web terminal comes from exec (section 3.18). console stays for ConsoleMode::Serial specs and for FakeEngine; on a VM whose serial is Socket it is Conflict. Nothing in the node agent calls it from M4.
3.15 counters
Returns the VM’s counters for metrics (M4, D-0033). Unknown id is NotFound. Values are cumulative since VM start; the node agent computes rates. Counters are best-effort: a backend that cannot report one of the groups returns zeros for it rather than failing the call.
The M4 metrics use two of them (ADR 0014, node-agent-protocol.md sections 5.15 and 5.17): cpu_time_ms and memory_rss_bytes, which the Cloud Hypervisor backend reads from the VMM process on the host (/proc/<pid>/stat and /proc/<pid>/status), not from the backend’s counter document, so they need no guest agent. The node agent samples them every 5 seconds.
3.16 snapshot_volume and delete_volume_snapshot
The point-in-time disk primitive the chunk engine needs (D-0023). Both are Unsupported in the default implementation, and only the M5 code path calls them.
- The primitive for M5a is decided (D-0062): reflink snapshots with a full content-defined chunk scan for cold backups. Before M5b, a spike comparing lvm-thin and btrfs chooses the primitive for warm volumes.
- They must not be implemented with Cloud Hypervisor VM snapshots. A CH snapshot is a paused VM’s
config.json,memory-rangesandstate.json; it contains guest RAM and device state but no disk contents, and CH exposes no dirty bitmap (F-0035). A volume snapshot is about the disk. - If implemented with a filesystem primitive, the call takes no VMM lock: it snapshots the volume of a stopped or running VM as the chosen primitive allows, and the chunk engine handles crash consistency (D-0023).
3.17 prepare_layout
Turns a local OCI image layout, as a builder VM wrote it to its output disk, into a prepared rootfs, the way prepare_image does for a pulled image (ADR 0117, ADR 0118). Added in M4.
- Precondition:
pathis a readable OCI image layout whose index namesmanifest_digest: a directory, or a tarball of one, which may be followed by zero padding up to the end of the file (a raw output disk,node-agent-protocol.mdsection 6.6). - A tarball is read in user space as untrusted input: only regular files named
oci-layout,index.jsonandblobs/sha256/<64 hex>are taken, every other entry (a link, a device, a path with.., a duplicate name) makes the layoutImage, and the bytes read are bounded by the node’s image limit. - The engine verifies every blob against its digest, checks that the manifest’s digest is
manifest_digest, applies the layers with the same pipeline asprepare_image(D-0061), extractsImageConfig, and caches the result bymanifest_digestin the same cache.createthen boots it withrootfsset to that digest, andremove_imageremoves it. - Idempotent by digest: a second call with a digest already cached returns the cached value without reading the layout.
- A missing or malformed layout, a blob that does not match its digest, a media type the pipeline does not support, or a layout larger than the node’s image limit is
Image. Nothing is pulled: a layout that references a blob it does not contain isImage, not a registry fetch. - The engine never deletes the layout; the node agent does, after the call returns.
3.18 exec
Starts a program on a PTY inside a running VM, in the app’s root filesystem, with the app’s environment and working directory, and returns its byte stream (ADR 0113). Added in M4; the web terminal is its only caller.
- The engine connects to the VM’s vsock socket with Cloud Hypervisor’s hybrid handshake (
CONNECT <port>\n, answeredOK <port>\n) on the init’s exec port (section 6), and sends the open request. The guest init runs the program and relays bytes; the engine relays them toExecSession::io. argvis non-empty, holds no NUL, and is at most 64 items of at most 4096 bytes.rowsandcolsare 1 to 1000. OtherwiseConflict.- An unknown id is
NotFound. A VM that is notRunning, has novsock, or whose init does not listen (a VM started before its node got the M4 init) isConflict. A missing program is not an error of the call: the session starts andexitresolves with127. - Many sessions per VM may be open at once. Each is independent: closing one does not touch another, and the app keeps running.
- Back-pressure: when the reader of
iostops reading, the engine stops reading from the vsock connection, which stops the program at its next write. A session never buffers without bound. - The guest is untrusted. The engine bounds every frame it reads from the guest (section 6) and treats output as bytes, never parsing it.
- The vsock socket accepts connections from the host only; the node never creates a
<socket>_<port>listener, so the guest cannot open a connection to the host over vsock.
3.19 serial_output
A read-only stream of a VM’s serial output: first up to tail_bytes of the newest bytes the node kept (all of it when None), then live output until the VM is deleted or the caller drops the stream (ADR 0116). Added in M4.
- With
serial: Socket, the engine is the socket’s only client, fromcreateon. It appends what it reads to a node-local ring in the VM’s runtime directory: two segments of 512 KiB, the older one dropped when the newer one fills, so the ring holds at most 1 MiB and survives VMM restarts, which keeps the previous boot’s output after a crash loop. While the node agent restarts, the VMM keeps up to 1 MiB of output and replays it when the engine reconnects. - Any number of streams may be open on one VM; each one reads the ring and then follows, so viewers never connect to the socket.
- An unknown id is
NotFound. A VM whoseserialis notSocketisConflict. - Input is never written to the serial port: nothing in the guest reads it.
4. Concurrency
- Operations on one
VmIdare serialized by the caller. The node agent holds one lock per VM, so it never issuesstartanddeletefor the same id concurrently. The engine does not promise ordering for concurrent calls on one id and may returnConflict. - The engine must be safe to call concurrently for different VMs, on many VMs at once. This is what lets one node run several apps and replace them independently.
capabilities,probe_host,ensure_network,prepare_image,remove_imageandlistare safe concurrently with any VM operation.ensure_networkandprepare_imageare internally serialized by the implementation where the underlying operation is not reentrant.- No call blocks the runtime for longer than an I/O wait: image preparation and boot are awaited, never run on the reactor thread.
5. Cloud Hypervisor implementation
One cloud-hypervisor process per VM with its own --api-socket, because the VM’s lifetime must not be the agent’s lifetime (D-0011) and because a per-VM socket is the unit the engine can rebuild after a restart (docs/research/cloud-hypervisor.md). The API is HTTP/JSON over a UNIX socket, with reads as GET and mutations as PUT; the engine does not need a Cloud Hypervisor SDK, only an HTTP client over a UnixStream (docs/research/cloud-hypervisor.md).
| Method | What the implementation does | Backend action |
|---|---|---|
capabilities | Reports the pinned release compiled into the node-agent build, plus values observed at start-up | Runs the binary once for its version; no VM call |
probe_host | Reads /dev/kvm, the kernel release and the CPU flags | No backend call |
ensure_network | Creates the bridge, the gateway address and the forwarding rules; each VM gets a TAP on that bridge | No backend call; the TAP is created per VM with the network device |
prepare_image | Pulls, verifies the digest, builds the rootfs, extracts ImageConfig, caches by digest (D-0061) | No backend call |
remove_image | Deletes the cached rootfs directory | No backend call |
create | Creates the runtime directory, starts the VMM with --api-socket <dir>/api.sock, then configures the VM: direct kernel boot of the Quoro-provided guest kernel, rootfs as a virtio-blk disk, the writable disk and any volumes and work disks as further virtio-blk disks, the network device on the node bridge, the console socket, the serial socket and the vsock device; then connects to the serial socket | Process start, then /vm.create |
start | Boots the configured VM | /vm.boot |
stop graceful | Asks the guest to stop and waits for the process to exit | /vm.power-button, then /vm.info until stopped |
stop force | Shuts the VMM down; if the socket does not answer, kills the process and reaps it | /vmm.shutdown, else SIGKILL on the VMM pid |
delete | Deletes the VM, shuts the VMM down, removes the runtime directory and the VM’s own disks, keeps volumes | /vm.delete, then /vmm.shutdown |
inspect | Reads the VM’s configuration and state | /vm.info |
list | Scans the per-VM runtime directories, confirms each VMM is alive and reads its state | /vmm.ping and /vm.info per runtime directory |
events | Watches the VMM process for exit and confirms the resulting state; a requested stop is distinguished from an unexpected exit | Process reaping, /vm.info |
output | Connects to the VM’s virtio-console socket and parses the init’s tagged lines | Reads the --console socket= endpoint |
console | Connects to the VM’s serial socket and relays bytes in both directions, one client at a time | Reads and writes the --serial socket= endpoint (F-0034) |
prepare_layout | Verifies the layout’s blobs, builds the rootfs with the prepare_image pipeline, caches by manifest digest (section 3.17) | No backend call |
exec | Connects to <vm dir>/vsock.sock, sends CONNECT for the exec port, then the open frame; relays frames to the PTY stream (section 3.18) | Hybrid vsock socket; no API call |
serial_output | Reads the node-local ring, then follows the engine’s own serial client (section 3.19) | No backend call; the client reads the --serial socket= endpoint |
counters | Reads the VM’s I/O and device counters and maps them to VmCounters; the exact field names of the backend’s counter document are not fixed by docs/research/cloud-hypervisor.md, so the mapping is Hypothesis | /vm.counters |
snapshot_volume | Default: Unsupported. When implemented: a filesystem-level snapshot of the volume (reflink snapshot for M5a, D-0062) | No /vm.snapshot call (F-0035) |
delete_volume_snapshot | Default: Unsupported. When implemented: removes the filesystem-level snapshot | No backend call |
Details that follow from the research file:
- Direct kernel boot, not firmware:
--kernelwith the Quoro-provided guest kernel and--cmdline. The firmware path adds a bootloader stage and is not needed for a Linux-only guest (docs/research/cloud-hypervisor.md). - One pinned release per node-agent build. Cloud Hypervisor has no versioned v2 API to design against, and neither snapshots nor live migration are portable across versions (
docs/research/cloud-hypervisor.md). The pin is reported incapabilitiesand inengine_versioninhello. - Console is a socket, not a pty.
--serial socket=<path>and--console socket=<path>are the v54 forms; a socket serves one client at a time and accepts a new one after the current one disconnects (F-0034,docs/research/cloud-hypervisor.md). That is whyconsolereportsConflicton a second reader. - M4 devices. Every VM gets
--vsock cid=3,socket=<vm dir>/vsock.sockand--serial socket=<vm dir>/serial.sock, with--console file=orsocket=carrying the app’s tagged output as before. The kernel command line keepsloglevel=4: a boot then writes about 600 bytes to the serial port, which is the guest’s boot output the console shows, and a higher level costs boot time on the emulated UART (ADR 0116). - State files under the per-VM runtime directory — spec, sockets, the VMM pid — so
listcan rediscover a running VM after an agent restart without restarting it (D-0011).
6. Guest contract with the minimal init
The minimal init is part of the engine workstream and is the only process Quoro puts inside the microVM. It is PID 1; the app runs under it (D-0008). It is Quoro’s own small Rust init (D-0061), because the init is on the audit path and a large guest agent would be a black box.
What the init receives. A small machine-readable configuration, read before the app starts: a config disk or the kernel command line. The delivery choice is settled during M1 implementation, not baked into the rootfs (D-0061). The configuration carries:
| Item | Content |
|---|---|
| Entrypoint and arguments | The resolved command line of the app |
| Environment | Name and value pairs, already decrypted by the node agent |
| User | The user the app must run as |
| Working directory | The app’s working directory |
| Network | Address, prefix length, gateway, interface name and the DNS servers of the node network (NodeNetwork::dns) |
| Console | Which console carries log lines |
| Exec port | The vsock port the init listens on for exec sessions (M4) |
What the init must do.
- Mount
/proc,/sysand/dev, and mount the writable disk at the app’s data location. - Configure the network interface with the address, gateway and DNS given in the configuration.
- Start the app as PID 2 under the configured user, in the configured working directory, with the configured environment.
- Forward the app’s stdout and stderr to the virtio-console with a stream tag per line, so the node agent can tell the two apart without parsing app output. This is the source of
OutputLineand therefore of local app logs (D-0014). - Reap orphaned children so no zombie accumulates, and forward signals to the app where they matter.
- On the power-button signal, send
SIGTERMto the app, wait for it to exit or for the timeout to pass, then power the guest off. - Report the app’s exit code so the engine can surface it as
exit_codeand asEngineEvent::Exited. - From M4, mount
cgroup2on/sys/fs/cgroup, which BuildKit needs and app images may expect (ADR 0118). - From M4, listen for exec sessions (below), and unmount or remount read-only every writable disk before powering off, so a work disk is clean for the next VM that attaches it, without journal recovery (ADR 0118).
Configuration version 2 (M4). The init’s configuration grammar refuses unknown keys, so M4 adds one: the header becomes quoro-init-config v2, and one line exec_port=<u32> follows stop_timeout_ms (crates/init/README.md). The engine writes exec_port=1024. The init still accepts a v1 configuration and then starts no exec listener. The engine and the init ship together: the initramfs is rebuilt from the installed init, so a VM gets the new init at its next start, and a VM that kept running across the upgrade answers exec with Conflict until it restarts.
Exec sessions (M4). After the switch into the app’s root and before the app starts, the init listens on vsock port exec_port for the VM’s lifetime; vsock delivers only connections from the host to a guest listener (ADR 0113). One connection is one session. Every frame is [u8 type][u32 length, big-endian][payload], with a payload of at most 64 KiB; a larger or unknown frame ends the session. Host to guest: 1 open (JSON {"argv":[...],"user":null|"...","rows":R,"cols":C}, exactly once, first), 2 input bytes, 3 resize (u16 rows, u16 cols, big-endian), 4 close. Guest to host: 129 output bytes, 130 exit (i32 big-endian, 128 + signal when a signal ended the program), 131 error (UTF-8 message, then the guest closes). Per session the init allocates a PTY and starts the program as a session leader with the PTY as its controlling terminal, the app’s environment with TERM=xterm-256color, the app’s working directory, and the app’s user unless the open names another. Because the workspace forbids unsafe, the init re-executes itself (/proc/self/exe with a helper argument) as a small helper that calls setsid, takes the PTY as its controlling terminal on stdin, stdout and stderr, and replaces itself with the program. A close, or the host dropping the connection, sends SIGHUP to the session’s process group. The exec listener never writes to the app’s stdout or to the log console.
While the app is running, the init must not write to the app’s stdout, so a log line’s stream tag is never ambiguous. On a failed boot — no configuration, a missing entrypoint, an unmountable disk — the init writes a diagnostic to the serial console and powers off with a non-zero code, so the failure surfaces as a deployment.failed event in the boot phase rather than as a silent hang.
7. FakeEngine
Tests must run on machines without KVM, including CI and the laptops of people who only work on the panel. FakeEngine implements the same trait with no hypervisor:
- In memory: VMs are records,
create/start/stop/deletechange their state,listreturns exactly what was created since the instance started. - Deterministic: no timers, no threads, no wall-clock dependence beyond explicit calls. Boots complete when
startreturns. - Injectable: a test can make a VM crash after N seconds, make
startreturnResourceExhausted, makeprepare_imagereturnImage, and make the console echo, so every error path and every event the node agent must map is reachable. - Streams:
events,output,consoleandserial_outputare real streams over in-memory channels, so the node agent’s stream handling is exercised, not stubbed. - M4 methods:
prepare_layoutcaches a fake prepared image by manifest digest;execreturns an in-memory session that echoes input and can be told to exit with a code;serial_outputreplays and follows an injectable byte buffer.Capabilities::execistrue. - Volume methods:
snapshot_volumeanddelete_volume_snapshotsucceed and record a snapshot descriptor, so the M5 chunk-engine code is testable before a real primitive exists.
The node agent runs its protocol tests against FakeEngine: the M0 lease tests — revoking a local lease and expiring a test-only short expiring lease — need no VM at all beyond a fake (node-agent-protocol.md).
8. Not in this trait
- Live migration. Out of scope (D-0034). Cloud Hypervisor supports it over UNIX sockets and TCP with mutual TLS (
docs/research/cloud-hypervisor.md), and Quoro does not expose it. - Graphical consoles. Cloud Hypervisor has no VNC, SPICE, GPU or framebuffer device (F-0034). Consoles are text-only (D-0056): the baseline stack ships xterm.js over the serial or virtio-console and no noVNC, and the trait exposes text consoles only. A graphical console would need an Engine with a display device — a guest-side display server or GPU passthrough — and would be an engine of its own.
- GPU and PCI passthrough. No app contract needs it; adding it would widen the device model and the security surface.
- VM memory snapshots. The backend can do them, but they contain RAM and device state and no disk contents (F-0035), and Quoro’s recovery model is a lease move plus a volume primitive, not a frozen VM.
- Multi-host scheduling, placement, leases and identity. Those live in the node agent and the control plane (D-0019,
node-agent-protocol.md); the engine only knows the VMs of the node it runs on.
9. Milestones
| Milestone | Engine work |
|---|---|
| M0 | The trait, FakeEngine, and the spike that boots a microVM from an OCI image (D-0033) |
| M1 | create, start, stop, delete, list, inspect, prepare_image, ensure_network (D-0033) |
| M2 | output feeds live app logs (D-0014, D-0033); isolation groups on NetAttach and guest DNS on NodeNetwork (D-0084) |
| M4 | counters for metrics (D-0033, ADR 0014); serial_output and VmSpec::serial for the read-only console (ADR 0116); exec and VmSpec::vsock for the web terminal, with init configuration v2 (ADR 0113); prepare_layout, VmSpec::work_disks and NetAttach::egress_only for builds (ADR 0117, ADR 0118). console is superseded |
| M5a/M5b | snapshot_volume and delete_volume_snapshot: reflink snapshots for M5a, the primitive chosen by the lvm-thin vs btrfs spike for M5b (D-0023, D-0062) |
Node-agent protocol
Status: M0 draft for review. Contracts freeze at the end of M0 (D-0036); after the freeze a change to this file is a standalone contract pull request, never part of feature work.
This file fixes the wire protocol between a node agent and the control plane: enrollment, the dial-out session, the message set, desired-state reconciliation, change-log follow, and leases with self-fencing. It is the contract named in README.md for the node-agent, control-plane and engine workstreams.
Names used here — roles, node status, VM observed status, lease modes and node-side lease states, scopes, event types, message types — come from README.md and are used exactly as defined there. Field names come from the shared entity model used by openapi.yaml, db-schema.sql and events.schema.json. Terminology follows ../glossary.md; in particular a replica node is not an app replica, and a lease holder is the only node that may run a stateful app (D-0019).
1. Purpose and principles
1.1 What this contract fixes
- The enrollment flow and the credential a node receives.
- The transport, the envelope and the message set in both directions.
- How desired state is delivered, verified, persisted and applied.
- How a node follows the change log (D-0013) and what it does when its copy diverges.
- The lease lifecycle and the self-fence procedure that D-0019 requires from M0.
- The order of operations when a node returns after a failure (D-0022).
- What the node stores locally, so the data plane can run without the control plane (D-0011).
It does not fix: the HTTP API around it (openapi.yaml), the event payloads (events.schema.json), how the engine drives Cloud Hypervisor (engine-trait.md), or the panel’s realtime transport.
1.2 Principles
| Principle | Consequence in this protocol | Source |
|---|---|---|
| Nodes dial out, never are dialed into | The node opens one outbound WebSocket to the primary; the primary never connects to the node. Works behind NAT and lets the control plane live anywhere. | D-0011 |
| The node is autonomous | The node stores signed desired state locally and reconciles it without the control plane. Losing the session blocks changes, not workloads. | D-0011 |
| Self-fencing exists from M0 | The lease messages, the node-side lease state machine and the self-fence path ship before any HA feature is used, so the protocol is not redesigned later. | D-0019 |
| One writer | Only the primary issues desired state, leases and commands. A replica node forwards to the primary; it never speaks this protocol as an authority. | D-0012 |
| State is replicated, telemetry is not | Logs, console bytes and metrics travel on demand over this session and are not stored by the primary. | D-0014 |
| Every change is numbered | Desired state carries a monotonic rev; the change log carries a gap-free seq with a hash chain. | D-0011, D-0013 |
2. Enrollment
Enrollment is a one-time exchange over the HTTP API. It happens before any session exists and uses the API’s application/problem+json error codes on failure (README.md).
2.1 Join token
A human with the node:enroll scope creates a join token:
POST /api/v1/nodes/join-tokens
The token is a one-time secret with a default lifetime of 1 hour; the API returns the secret once and stores only its hash. node:enroll is human-only, so an agent can never enroll a machine (README.md).
2.2 Join command
The panel and the CLI render a join command (the API returns it as join_command) that carries exactly three values:
| Value | Purpose |
|---|---|
| primary URL | Where to open the session, for example wss://quoro.example.com |
| join token | The one-time secret from POST /api/v1/nodes/join-tokens |
| certificate fingerprint | SHA-256 of the primary’s TLS certificate (DER), so a self-signed certificate is safe to accept |
The fingerprint is a pin, not a trust-on-first-use decision: the node refuses any certificate whose digest differs, and a certificate rotation requires a new join command.
2.3 Enroll request
The node generates an Ed25519 keypair, keeps the private key locally and sends the public key:
POST /api/v1/nodes/enroll
The call is x-quoro-scope: public: the node has no credential yet and the join token is the proof. A token can be consumed once; a used or expired token is 409 or 422 in the API’s error format (openapi.yaml).
| Field | Type | Required | Meaning |
|---|---|---|---|
join_token | string | yes | The join token secret |
name | string | yes | Operator-visible node name, unique in the cluster |
public_key | string | yes | Base64 Ed25519 public key of the node |
roles | array of string | yes | Any of node, witness, edge (README.md) |
capacity | object | yes | cpus, memory_mb, disk_gb |
kvm | object | yes | available (bool), kernel (string), nested (bool, optional) |
The kvm object is filled from a probe at enrollment: /dev/kvm exists and is writable, a vmx/svm flag is present in the CPU flags, and the kernel release is recorded. The probe procedure follows docs/research/vps-nested-virt.md; presence of /dev/kvm alone does not prove that a guest can start, so the first real boot is the confirmation and a failed boot is reported as a deployment failure in the boot phase.
2.4 Enroll response
| Field | Type | Meaning |
|---|---|---|
node_id | string (nod_) | Identity of this node |
cluster_id | string (cls_) | The cluster it joined |
primary_url | string | Base URL the node dials for this and later sessions |
credential | string | Node session credential, returned once |
cluster_public_key | string | Base64 Ed25519 public key that signs desired state |
The credential is 32 random bytes, base64-encoded, and stored root-only on the node. The primary stores only its SHA-256 hash, in the local table node_credentials (db-schema.sql migration 0002), so a database read does not yield a usable credential and the hash never travels in the change log.
Desired state and lease messages are signed with the cluster signing key (Ed25519), whose public key the node pins at enrollment; that key is separate from the cluster data-encryption key that protects data at rest (D-0050). The signing key is Ed25519; data at rest uses a 256-bit cluster data-encryption key with XChaCha20-Poly1305, plus a node-local key that encrypts this node’s copy of desired-state secrets. All keys are files mode 0600 in /var/lib/quoro on the primary, outside the SQLite file, generated at first start and backed up separately from the database. Custody of the keys when a replica is promoted is open (Q-0024).
2.5 After enrollment
The primary records the node with status pending and emits node.enrolled. The node writes the credential, the cluster public key and its node_id to its local store, then opens a session. pending becomes connected when the first session starts; the node never re-enrolls on its own — a lost credential is recovered by removing and re-enrolling the node by a human.
3. Transport
3.1 Connection
GET /api/v1/nodes/session
Authorization: Bearer <node credential>
- WebSocket over TLS, upgraded from the same API listener. The node is the client; the primary is the server.
- The node verifies the TLS certificate against the pinned fingerprint from the join command.
- One session per node. A new session for the same
node_idcloses the older one, so a stale connection can never deliver messages alongside a fresh one. - The operation is annotated
x-quoro-scope: nodeinopenapi.yaml(README.md): a node credential authorizes the session and nothing else.
3.2 Frames
- Text frames only, one JSON message per frame.
- Maximum message size 1 MiB. A larger frame is a protocol violation: the receiver answers
errorwith codebad_messageandfatal: false, and ignores the frame. - Binary frames are not used in v1.
3.3 Envelope
{
"v": 1,
"type": "heartbeat",
"id": "msg_01JC9Z7A4M8Q2V6B3N5T9R1KXW",
"re": "msg_01JC9Z6Q5W8N3K4T7R2M1XHB0D",
"body": { "time": "2026-11-02T10:15:32Z" }
}
| Field | Type | Required | Meaning |
|---|---|---|---|
v | integer | yes | Envelope major version; 1 in v1 |
type | string | yes | One of the message types in README.md |
id | string (msg_) | yes | Identity of this message |
re | string (msg_) | no | id of the message this one answers |
body | object | yes | Message-specific body; may be {} |
msg_ ids are message-local. They exist to pair a request with its answer inside one session, they are never persisted, never appear in the API, the database, the change log, the audit log or an event, and they are not part of the identifier table in README.md. A message id may be reused after a reconnect.
3.4 Versioning, unknown fields and unknown types
- Unknown fields inside a known body are ignored. This allows additive changes without a new version.
- An unknown
typeis answered witherror, codebad_message,fatal: false; the session continues. - A
vwhose major version the receiver does not implement is answered witherror, codeunsupported_version,fatal: true; the session ends. - Minor changes (new optional fields, new message bodies of the same type) do not change
v. A new required field or a changed meaning is a major change and a standalone contract pull request (D-0036).
3.5 Relation to the panel’s realtime transport
This WebSocket is the node protocol and is independent of how the panel receives live data. The panel’s transport is decided (D-0057): SSE for one-way streams and WebSockets for interactive sessions. The node session is a WebSocket regardless, so nothing in this file depends on that choice.
4. Session lifecycle
4.1 Sequence
node agent primary
| |
|-- TLS + GET /api/v1/nodes/session (credential) -->|
|-- hello ---------------------------------------->|
|<---------------------------------------- welcome |
|<----------------------------------- lease_revoke | 0..n stale or superseded leases
|-- command_result ------------------------------>|
|<----------------------------- command fence_ack | 0..1 when an acknowledgement is pending
|-- command_result ------------------------------>|
|<---------------------------------- desired_state |
|-- command_result (stored) --------------------->|
|<----------------------------------- log_entries |
|-- log_ack ------------------------------------->|
|== reconcile: network, images, stop, start, routes ==|
|-- status_report (applied_rev) ------------------>|
|-- heartbeat every 5 s -------------------------->|
|-- status_report on change and every 30 s ------->|
|-- event (buffered while disconnected) ---------->|
|<--------------------------------------- command | on demand
|-- command_result ------------------------------>|
|<------------------------------------- stream_open |
|-- stream_data ... stream_end ------------------>|
|<------------------------------------ lease_grant |
|-- lease_renew every duration/3 (expiring only) ->|
| |
4.2 Rules of the order
hellois the first message the node sends on every session.welcomeis the first message the primary sends.- The primary sends any
lease_revokebefore anything else. The node stops the stateful VMs bound to those leases and answerscommand_resultfor each revoke before it processes the next message. - If an operator acknowledged a failed fence of this node and the node has not yet confirmed it, the primary then sends
commandkindfence_ack(sections 5.17 and 8.5) and waits for itscommand_result. - Only then does the primary send
desired_state. The node verifies, persists, answerscommand_resultwithok, and reconciles. log_entriesfollow the desired state; the node acknowledges withlog_ackas it verifies the chain.- Steady state begins:
heartbeatevery 5 s,status_reporton any observed change and at least every 30 s,eventwhenever something happens, streams on demand.
4.3 Timers
| Timer | Value | Owner | Source |
|---|---|---|---|
| Heartbeat interval | 5 000 ms | node sends, both expect | this file |
| Session timeout | 15 000 ms without any frame | primary | this file |
| Status report interval | 30 000 ms | node | this file |
| Redial backoff | 1 s doubling to 60 s, full jitter | node | this file |
| Event buffer | 10 000 entries | node | D-0011, this file |
| Concurrent streams | 32 per session | primary enforces | this file |
| Stream window | 256 KiB of unacknowledged data bytes per stream | node enforces, primary acknowledges | ADR 0116, section 5.25 |
| Maximum message | 1 MiB | both | this file |
The values above are carried in welcome (heartbeat_interval_ms, session_timeout_ms), so the primary can change them without a protocol change; the node uses the values from the current welcome and falls back to the defaults above when a session has not yet received one.
4.4 Disconnect and redial
The primary marks the node disconnected after 15 s without any frame from it and emits node.disconnected with data.reason either heartbeat_timeout (no frame within the timeout) or session_closed (the socket closed or the server ended the session). A node that reconnects is connected again and the primary emits node.connected.
The node redials with exponential backoff from 1 s doubling to 60 s with full jitter, and resets the backoff after a session that stayed up for at least 60 s. A fatal error stops redialing until an operator acts, except for unsupported_version, where the node keeps dialing (an upgraded primary may accept it) but logs the mismatch locally.
5. Message reference
All bodies are objects. Field names are snake_case. Timestamps are RFC 3339 UTC strings. Ids use the prefixes in README.md.
5.1 hello
Direction: node to control plane. When: first message of every session, before any other message.
| Field | Type | Required | Meaning |
|---|---|---|---|
node_id | string (nod_) | yes | The node’s identity; must match the credential |
agent_version | string | yes | Version of the node agent binary |
engine_version | string | yes | Version of the engine backend, for example the pinned Cloud Hypervisor release (docs/research/cloud-hypervisor.md) |
boot_id | string | yes | Random value generated at agent process start; identifies this run of the agent |
stored_rev | integer | yes | Newest desired-state rev persisted locally |
applied_rev | integer | yes | Newest desired-state rev fully reconciled |
log_seq | integer | yes | Last change-log seq verified locally; 0 if none |
log_hash | string | yes | Hex SHA-256 the node holds at log_seq; 64 zeros when log_seq is 0 |
leases | array of object | yes | Leases known locally, see below |
capacity | object | yes | cpus, memory_mb, disk_gb |
allocated | object | yes | Same shape, currently allocated |
kvm | object | yes | available, kernel, nested (optional) |
dropped_events | integer | yes | Local events dropped from the buffer since the last successful delivery; 0 when none |
Each lease entry:
| Field | Type | Meaning |
|---|---|---|
lease_id | string (lse_) | Lease identity |
resource_type | string | app or primary |
resource_id | string | app_ or nod_ id |
mode | string | local or expiring |
fencing_token | integer | Last token this node saw for the lease |
state | string | held, uncertain, expired or revoked |
leases lets the primary detect a node that believes it holds a lease the primary has already moved, before any desired state is sent. A lease the primary does not know, or one whose fencing_token is lower than the primary’s, is revoked immediately after welcome.
{
"v": 1,
"type": "hello",
"id": "msg_01JC9Z6Q5W8N3K4T7R2M1XHB0D",
"body": {
"node_id": "nod_01JC9Y2M4Q7V8B1N6T3R5K9XZP",
"agent_version": "0.1.0",
"engine_version": "cloud-hypervisor v54.0",
"boot_id": "8f1c2a4e-6b3d-4f5a-9c7e-1a2b3c4d5e6f",
"stored_rev": 412,
"applied_rev": 412,
"log_seq": 902,
"log_hash": "3b1f0c9a5e7d2b8f4a6c1d9e0f3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f90",
"leases": [
{
"lease_id": "lse_01JC9Z8K2M4P6R8T0V2X4Z6B8D",
"resource_type": "app",
"resource_id": "app_01JC9Z7B3N5Q7S9Z1W3Y5A7C9E",
"mode": "local",
"fencing_token": 7,
"state": "held"
}
],
"capacity": { "cpus": 8, "memory_mb": 16384, "disk_gb": 200 },
"allocated": { "cpus": 2, "memory_mb": 2048, "disk_gb": 20 },
"kvm": { "available": true, "kernel": "6.11.2", "nested": true },
"dropped_events": 0
}
}
5.2 heartbeat
Direction: node to control plane.
When: every heartbeat_interval_ms, from the end of the handshake for as long as the session is open.
| Field | Type | Required | Meaning |
|---|---|---|---|
time | string | yes | Node wall-clock time when the heartbeat was sent |
busy | boolean | no | True while a long apply loop is running; purely informational |
{
"v": 1,
"type": "heartbeat",
"id": "msg_01JC9Z9C4P6R8T0V2X4Z6B8D0F",
"body": { "time": "2026-11-02T10:15:32Z", "busy": false }
}
5.3 status_report
Direction: node to control plane. When: after the first reconciliation of a session, on any observed change, and at least every 30 s.
| Field | Type | Required | Meaning |
|---|---|---|---|
applied_rev | integer | yes | Newest desired-state rev fully reconciled |
vms | array of object | yes | One entry per VM the node runs or is changing |
routes | array of object | yes | One entry per hostname the node serves |
capacity | object | yes | cpus, memory_mb, disk_gb |
allocated | object | yes | Same shape, currently allocated |
disk_free_gb | number | yes | Free space on the volume filesystem |
builds | array of object | no | One entry per build in the node’s current spec.builds (section 5.11); absent or empty when there is none (ADR 0117) |
VM entry: vm_id (string, vm_), observed_status (one of creating, stopped, starting, running, stopping, crashed, deleted, unknown), health (passing, failing, unknown), ip (string, optional), restart_count (integer), last_exit_code (integer, optional).
Route entry: hostname (string), cert_status (one of pending, issued, renewing, failed, not_applicable), cert_expires_at (string, optional).
Build entry:
| Field | Type | Required | Meaning |
|---|---|---|---|
build_id | string (bld_) | yes | The build |
status | string | yes | queued, preparing, building, exporting, importing, succeeded, failed or cancelled (README.md) |
commit | string | no | Full commit SHA the ref resolved to, once the checkout finished |
image_digest | string | no | Manifest digest of the built image, on succeeded; the node has already prepared it as a root disk |
failed_phase | string | no | On failed: preparing, building, exporting or importing |
error | string | no | On failed: one-line reason, at most 2 000 characters |
failed_step | object | no | On failed in a build step: vertex (integer), name (string), error (string), dockerfile_line (integer, optional), excerpt (array of at most 20 strings, each at most 500 characters: the step’s last log lines and the Dockerfile excerpt) |
started_at | string | no | When the node left queued |
finished_at | string | no | When the build reached a terminal status |
The primary owns the builds table and the build.* events: it applies each reported status change to the build row and emits the event, so the node emits no build.* event of its own. A status report may skip intermediate statuses; only the terminal one matters.
The primary treats a VM it knows and the node does not report as unknown rather than assuming it is gone; only an explicit deleted or a successful inspect removes it.
{
"v": 1,
"type": "status_report",
"id": "msg_01JC9ZA0D5Q7S9Z1W3Y5A7C9E1",
"body": {
"applied_rev": 412,
"vms": [
{
"vm_id": "vm_01JC9Z7C4P6R8T0V2X4Z6B8D0F",
"observed_status": "running",
"health": "passing",
"ip": "10.42.1.11",
"restart_count": 0
}
],
"routes": [
{
"hostname": "blog.example.com",
"cert_status": "issued",
"cert_expires_at": "2027-01-31T09:00:00Z"
}
],
"capacity": { "cpus": 8, "memory_mb": 16384, "disk_gb": 200 },
"allocated": { "cpus": 2, "memory_mb": 2048, "disk_gb": 20 },
"disk_free_gb": 128.4
}
}
5.4 event
Direction: node to control plane. When: when something the cluster should know about happens on the node — a VM crash, an app going down, a node fencing itself, free disk crossing its threshold.
The body is the event envelope from events.schema.json without seq, which the primary assigns:
| Field | Type | Required | Meaning |
|---|---|---|---|
schema_version | integer | yes | Envelope version; 1 in v1. New optional payload fields do not change it |
id | string (evt_) | yes | Event identity, generated by the node |
type | string | yes | One of the event types in README.md |
time | string | yes | When it happened, node clock |
source | object | yes | { "kind": "node", "node_id": <nod_> }, node_id must match the session |
subject | object | yes | { "type": ..., "id": ... } |
project_id | string (prj_) | no | Project the subject belongs to |
incident_id | string (inc_) | no | Set only when the primary correlates it |
request_id | string (req_) | no | Present when the event answers an API call |
actor | object | no | { "type": "node", "id": <nod_> } for node-originated events |
data | object | yes | Type-specific payload; may be {} |
The primary assigns seq on receipt and deduplicates by id, so a node that resends an event after a reconnect cannot produce two rows. The node buffers events while disconnected (section 6.5).
{
"v": 1,
"type": "event",
"id": "msg_01JC9ZB1E6R8T0V2X4Z6B8D0F2",
"body": {
"schema_version": 1,
"id": "evt_01JC9Z91A2B3C4D5E6F7G8H9J0",
"type": "app.down",
"time": "2026-11-02T10:16:05Z",
"source": { "kind": "node", "node_id": "nod_01JC9Y2M4Q7V8B1N6T3R5K9XZP" },
"subject": { "type": "app", "id": "app_01JC9Z7B3N5Q7S9Z1W3Y5A7C9E" },
"project_id": "prj_01JC9Z5A1B2C3D4E5F6G7H8J9K",
"actor": { "type": "node", "id": "nod_01JC9Y2M4Q7V8B1N6T3R5K9XZP" },
"data": { "failures": 3, "vm_id": "vm_01JC9Z7C4P6R8T0V2X4Z6B8D0F" }
}
}
5.5 lease_renew
Direction: node to control plane.
When: expiring leases only, every duration_ms / 3 while the lease is held or uncertain.
| Field | Type | Required | Meaning |
|---|---|---|---|
lease_id | string (lse_) | yes | Lease being renewed |
fencing_token | integer | yes | Token the node believes it holds |
The primary answers with a lease_grant that carries the same fencing_token when the node still holds the lease — renewals keep the token, they never change it (D-0073) — with re set to this message’s id. A renewal whose token is lower than the primary’s current token, or for a lease that no longer exists, is answered with lease_revoke. The node also sends one lease_renew immediately after a first grant (section 8.4). local leases are never renewed.
{
"v": 1,
"type": "lease_renew",
"id": "msg_01JC9ZC2F7S9Z1W3Y5A7C9E1G3",
"body": { "lease_id": "lse_01JC9Z8K2M4P6R8T0V2X4Z6B8D", "fencing_token": 7 }
}
5.6 log_ack
Direction: node to control plane.
When: after the node has verified and stored a batch of log_entries.
| Field | Type | Required | Meaning |
|---|---|---|---|
seq | integer | yes | Highest contiguous seq verified |
hash | string | yes | The hash of that entry, hex SHA-256 |
The primary keeps sending from seq + 1. An ack that skips a sequence is ignored: the node must verify every entry in order, because the point of the log is that a gap or a broken link is detectable (D-0013).
{
"v": 1,
"type": "log_ack",
"id": "msg_01JC9ZD3G8T0V2X4Z6B8D0F2H4",
"body": {
"seq": 950,
"hash": "9c7e1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f6a7b8c9d0e1"
}
}
5.7 stream_data
Direction: node to control plane.
When: while a stream opened by stream_open is producing data.
| Field | Type | Required | Meaning |
|---|---|---|---|
stream_id | string (msg_) | yes | Stream the data belongs to, chosen by the primary in stream_open |
seq | integer | yes | Per-stream sequence, gap-free from 1 |
lines | array of object | no | Log lines; present for app_logs, deployment_logs and build_logs |
data | string | no | Base64 bytes, at most 64 KiB decoded per message; present for terminal, console and transcript |
metrics | object | no | One sample; present for metrics (section 5.17, metrics_query, for its shape) |
Exactly one of lines, data and metrics is present. A log line is { "time": <RFC 3339>, "stream": "stdout"|"stderr", "line": <string without trailing newline>, "vm_id": <vm_> }; vm_id is optional and names the VM that produced the line, so a stream opened for an app with several VMs stays attributable. Lines come from the engine’s output stream, which the minimal init tags by stream on the guest side (engine-trait.md); the node does not guess. A build log line also carries seq (integer, gap-free from 1 per build, the position used by the API’s cursor and Last-Event-ID) and, when BuildKit names one, vertex (integer, the step number); it never carries vm_id.
Byte streams (data) coalesce output over a few milliseconds, so one echoed keystroke is not one message, and obey the stream window: the node stops reading its source while more than 256 KiB it sent on the stream are unacknowledged (section 5.25). On terminal and console streams the bytes are what the guest wrote, unparsed; the node never interprets them.
{
"v": 1,
"type": "stream_data",
"id": "msg_01JC9ZE4H9Z1W3Y5A7C9E1G3J5",
"body": {
"stream_id": "msg_01JC9ZD9K2M4P6R8T0V2X4Z6B8",
"seq": 17,
"lines": [
{ "time": "2026-11-02T10:16:30Z", "stream": "stdout", "line": "listening on :8080" }
]
}
}
5.8 stream_end
Direction: node to control plane. When: the stream finished, failed, or was closed by either side.
| Field | Type | Required | Meaning |
|---|---|---|---|
stream_id | string (msg_) | yes | Stream that ended |
reason | string | yes | completed, error, cancelled, vm_stopped or limit |
message | string | no | Human-readable detail, for example the engine error |
exit_code | integer | no | terminal only: the program’s exit code, or 128 + signal, when it exited |
transcript_sha256 | string | no | terminal only: lowercase hex SHA-256 of the session transcript as stored on the node |
transcript_bytes | integer | no | terminal only: size of that transcript |
The primary closes the matching API or panel consumer. cancelled is the answer to stream_close; error carries whatever EngineError the node saw. completed ends a terminal whose program exited (with exit_code), a build_logs stream once the build is terminal and every line was sent, and a transcript stream once the file was sent. vm_stopped ends a terminal whose VM stopped and a console whose VM was deleted; a console stream outlives VM restarts, so a viewer sees the next boot. limit ends a terminal whose transcript reached its cap (16 MiB, audit-log-format.md section 3.3). A terminal stream_end always carries the transcript fields when a transcript file exists, whatever the reason, so the primary can write session.end.
{
"v": 1,
"type": "stream_end",
"id": "msg_01JC9ZF5J0V2X4Z6B8D0F2H4K6",
"body": {
"stream_id": "msg_01JC9ZD9K2M4P6R8T0V2X4Z6B8",
"reason": "completed"
}
}
A terminal whose shell exited:
{
"v": 1,
"type": "stream_end",
"id": "msg_01JC9ZF6K1W3Y5A7C9E1G3J5M7",
"body": {
"stream_id": "msg_01JC9ZD0L3N5Q7S9V1X3Z5B7D9",
"reason": "completed",
"exit_code": 0,
"transcript_sha256": "4f2a9c1e7b3d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8",
"transcript_bytes": 18234
}
}
5.9 command_result
Direction: node to control plane.
When: as the answer to desired_state, lease_revoke, command, signing_key_rotate, and a stream_open that could not start.
| Field | Type | Required | Meaning |
|---|---|---|---|
status | string | yes | ok or error |
detail | string | no | Short human-readable detail |
error | object | no | { "code": ..., "message": ... } using the error codes in section 5.18 |
lines | array of object | no | Log lines, oldest first, in the line shape of section 5.7; present only in the answer to command kind logs_query or build_logs_query |
metrics | object | no | Present only in the answer to command kind metrics_query (section 5.17) |
re identifies the message being answered. For desired_state the answer means stored: the signature was verified and the snapshot was persisted atomically. It does not mean applied — that is applied_rev in status_report. For lease_revoke the answer means the stateful VMs bound to the lease are stopped and recorded. A lease_revoke whose self-fence could not confirm that every bound VM stopped is answered with status: error and error.code fence_failed (sections 5.18 and 8.5); detail names the VMs that may still run.
{
"v": 1,
"type": "command_result",
"id": "msg_01JC9ZG6K1W3Y5A7C9E1G3J5M7",
"re": "msg_01JC9ZF9W2M4P6R8T0V2X4Z6B8",
"body": { "status": "ok", "detail": "stored rev 412" }
}
A lease_revoke whose fence failed:
{
"v": 1,
"type": "command_result",
"id": "msg_01JC9ZG7M2X4Z6B8D0F2H4K6N8",
"re": "msg_01JC9ZF0X3N5Q7S9V1W3Y5A7C9",
"body": {
"status": "error",
"detail": "vm_01JC9Z7C4P6R8T0V2X4Z6B8D0F neither stopped nor gone after the forced stop",
"error": {
"code": "fence_failed",
"message": "self-fence of lse_01JC9Z8K2M4P6R8T0V2X4Z6B8D could not confirm every bound VM stopped"
}
}
}
5.10 welcome
Direction: control plane to node.
When: immediately after a valid hello, as the first message from the primary.
| Field | Type | Required | Meaning |
|---|---|---|---|
cluster_id | string (cls_) | yes | Cluster the session belongs to |
term | integer | yes | Cluster term; 1 until elections exist |
server_time | string | yes | Primary’s wall-clock time |
heartbeat_interval_ms | integer | yes | 5 000 in v1 |
session_timeout_ms | integer | yes | 15 000 in v1 |
primary_node_id | string (nod_) | yes | The node holding the primary status |
{
"v": 1,
"type": "welcome",
"id": "msg_01JC9ZH7W2X4Z6B8D0F2H4K6N8",
"re": "msg_01JC9Z6Q5W8N3K4T7R2M1XHB0D",
"body": {
"cluster_id": "cls_01JC9Z0000000000000000000Q",
"term": 1,
"server_time": "2026-11-02T10:15:30Z",
"heartbeat_interval_ms": 5000,
"session_timeout_ms": 15000,
"primary_node_id": "nod_01JC9Z0000000000000000000Q"
}
}
5.11 desired_state
Direction: control plane to node.
When: once per session after welcome and any lease_revoke, and again whenever the desired state for this node changes.
| Field | Type | Required | Meaning |
|---|---|---|---|
rev | integer | yes | Monotonic revision of the node’s desired state |
issued_at | string | yes | When the primary produced it |
term | integer | yes | Cluster term at issue time |
spec | object | yes | vms, routes, network, ingress when the node runs a tunnel, and builds when the node has builds to run (ADR 0117) |
signature | string | yes | Base64 Ed25519 signature by the cluster signing key |
Signature. The signature covers the RFC 8785 (JSON Canonicalization Scheme) serialization of the whole body object with the signature member removed. The node verifies it against cluster_public_key before it parses anything else, persists the verified snapshot atomically, answers command_result with ok, and only then reconciles. An invalid signature is answered with error code bad_signature and the snapshot is discarded; the node keeps running on its previously stored snapshot.
Full snapshot only. v1 has no deltas. Every desired_state is the complete set of VMs and routes for this node, so a lost message is harmless and a node that missed several revisions converges on the next snapshot.
spec.vms[]:
| Field | Type | Required | Meaning |
|---|---|---|---|
vm_id | string (vm_) | yes | VM identity |
project_id | string (prj_) | yes | Owning project |
app_id | string (app_) | no | Set when this VM is an instance of an app; absent for a standalone VM |
deployment_id | string (dep_) | no | The deployment that produced this VM, when it is an app instance; selects the VM for stream_open kind deployment_logs |
name | string | yes | Operator-visible name |
image | string | yes | OCI reference as requested; a built image’s reference is build.quoro.invalid/<app_id>:<build_id> (README.md, “Built images”) |
image_digest | string | yes | Resolved digest |
build_id | string (bld_) | no | Set when the image is a build’s output on this node; the node prepares it from that build and never pulls it (section 6.6) |
vcpus | integer | yes | vCPUs |
memory_mb | integer | yes | Memory in MiB |
disk_gb | integer | yes | Root disk size |
port | integer | no | Container port the app listens on |
env | object | yes | Environment values, decrypted for this node; {} when none |
health_check | object | no | path, interval_s, timeout_s, failures |
desired_status | string | yes | running or stopped |
restart_generation | integer | yes | Incremented by the primary on an explicit restart |
stateful | boolean | yes | Whether the VM may run only while its lease is held |
lease_id | string (lse_) | no | Required when stateful is true |
fencing_token | integer | no | Required when stateful is true |
volumes | array of object | yes | empty until local volumes ship in M5a (D-0052). Each { "volume_id": <vol_>, "mount_path": <string> } |
spec.routes[]:
| Field | Type | Required | Meaning |
|---|---|---|---|
hostname | string | yes | Host to serve |
app_id | string (app_) | yes | App behind the route |
vm_ids | array of string | yes | Candidate backends, one per app replica |
port | integer | yes | Backend port |
tls_mode | string | yes | acme or tunnel |
domain_id | string (dom_) | no | The domain this route serves (domains.id); filled by the primary for every route it builds from a domain |
Domain ids and certificate events. The node names the domain of a route in every certificate.* event it emits (certificate.issued, certificate.expiring, certificate.failed; section 5.4): subject is { "type": "domain", "id": <domain_id> } and data.domain_id carries the same id, as events.schema.json requires. domain_id is an additive optional field: a node that does not know it ignores it, and a node that receives a route without it (from an older primary) reports the certificate state in status_report.routes only and emits no certificate.* event for that hostname, because it never invents an id.
spec.builds[] (optional; ADR 0117, ADR 0118):
| Field | Type | Required | Meaning |
|---|---|---|---|
build_id | string (bld_) | yes | Build identity |
app_id | string (app_) | yes | App the build belongs to; also the build cache key, so caches never mix between apps |
project_id | string (prj_) | yes | Owning project |
repo_url | string | yes | https:// URL of a public Git repository |
ref | string | yes | Branch, tag or full commit SHA to build; the node resolves it to a commit |
dockerfile_path | string | yes | Path of the Dockerfile, relative to context_dir |
context_dir | string | yes | Build context, relative to the repository root; . is the root |
target | string | no | Stage of a multi-stage Dockerfile to build |
build_args | object | yes | --build-arg name to value; {} when none |
secrets | object | yes | BuildKit secret id to plaintext value, taken from the app’s environment variables named in the source; {} when none |
limits | object | yes | vcpus, memory_mb, timeout_s, cache_gb, output_gb |
cancelled | boolean | yes | True once a cancel was requested; the node stops the build and reports cancelled |
A build stays in spec.builds until the primary has recorded the terminal status the node reported. A succeeded build leaves spec.builds in the same revision that references its image in spec.vms (the deployment it started), so the apply loop never sees the built root disk unreferenced and removes it (section 6.2). secrets values are handled like env: plaintext only inside the signed snapshot, encrypted with the node-local key at rest, never logged and never written to the build’s log.
spec.network: subnet (CIDR for the node bridge) and gateway (address on that bridge). The addresses in status_report must fall inside the subnet.
spec.ingress (optional; absent when the node runs no tunnel):
| Field | Type | Required | Meaning |
|---|---|---|---|
tunnel | object | no | The node’s Cloudflare Tunnel (D-0024, D-0094); present when the node is mapped to a tunnel provider (node_tunnels, migration 0003) and the tunnel exists |
spec.ingress.tunnel:
| Field | Type | Required | Meaning |
|---|---|---|---|
tunnel_id | string | yes | The provider’s id of this node’s tunnel |
token | string | yes | The tunnel’s run token, plaintext; it lets the holder run this one tunnel and nothing else |
origin_port | integer | yes | Loopback port where the node’s proxy accepts the tunnel’s traffic |
Tunnels (D-0094). One tunnel per node. The primary creates or looks up the node’s tunnel with the account credential of the tunnel provider, writes the tunnel’s ingress rules (each tunnel route’s hostname to http://127.0.0.1:<origin_port>) and the proxied DNS records itself, and sends the node only tunnel_id, token and origin_port. The node runs the tunnel connector with the token and serves the tunnel routes of spec.routes from its proxy on 127.0.0.1:<origin_port>. The account credential never leaves the primary. A changed token (rotation) replaces the running connector; an absent tunnel stops it.
Environment values. env (and spec.builds[].secrets) carries plaintext values because the node must be able to start the process. It is sent only over the authenticated, pinned session, never over a webhook or an API read, and the node stores it encrypted with the node-local key. The primary stores the same values encrypted with the cluster data-encryption key (AEAD), and the API returns only names (README.md, db-schema.sql). spec.ingress.tunnel.token is handled the same way: plaintext only inside the signed snapshot on the session, encrypted with the node-local key at rest on the node, encrypted with the cluster data-encryption key on the primary (node_tunnels.token_enc), never returned by the API.
{
"v": 1,
"type": "desired_state",
"id": "msg_01JC9ZJ8M3Y5A7C9E1G3J5M7P9",
"body": {
"rev": 413,
"issued_at": "2026-11-02T10:15:31Z",
"term": 1,
"spec": {
"vms": [
{
"vm_id": "vm_01JC9Z7C4P6R8T0V2X4Z6B8D0F",
"project_id": "prj_01JC9Z5A1B2C3D4E5F6G7H8J9K",
"app_id": "app_01JC9Z7B3N5Q7S9Z1W3Y5A7C9E",
"name": "blog-web",
"image": "ghcr.io/example/blog:1.4.2",
"image_digest": "sha256:5c1f0d2a9b8e7f6a5d4c3b2a1908f7e6d5c4b3a29180f7e6d5c4b3a291807f6e",
"vcpus": 2,
"memory_mb": 512,
"disk_gb": 4,
"port": 8080,
"env": { "DATABASE_URL": "postgres://blog@10.42.1.5/blog" },
"health_check": { "path": "/", "interval_s": 10, "timeout_s": 2, "failures": 3 },
"desired_status": "running",
"restart_generation": 1,
"stateful": false,
"volumes": []
}
],
"routes": [
{
"hostname": "blog.example.com",
"app_id": "app_01JC9Z7B3N5Q7S9Z1W3Y5A7C9E",
"vm_ids": ["vm_01JC9Z7C4P6R8T0V2X4Z6B8D0F"],
"port": 8080,
"tls_mode": "acme",
"domain_id": "dom_01JC9Z8D5Q7S9V1X3Z5B7D9F1H"
}
],
"network": { "subnet": "10.42.1.0/24", "gateway": "10.42.1.1" }
},
"signature": "MEUCIQDf3k...c2lnbmF0dXJl"
}
}
A replica node that holds a copy of the change log but is not the primary never sends desired_state; it can only be the primary’s job, because the primary is the single writer (D-0012).
5.12 lease_grant
Direction: control plane to node.
When: when the primary (or, from M5b, the authority behind it) grants a lease, and as the answer to every successful lease_renew.
| Field | Type | Required | Meaning |
|---|---|---|---|
lease_id | string (lse_) | yes | Lease identity |
resource_type | string | yes | app or primary |
resource_id | string | yes | app_ or nod_ id |
mode | string | yes | local or expiring |
fencing_token | integer | yes | Changes only when ownership changes: the same value as the node’s current token when the grant answers a renewal by the current holder; a strictly higher value than any earlier token for the resource when the lease goes to a new holder or is granted again after expiry or revoke. Never reused (D-0073). |
duration_ms | integer | no | Present for expiring, absent for local |
granted_at | string | yes | Authority’s wall-clock time of the grant |
A grant whose token is lower than the highest token the node has seen for that resource, or equal to it while the node does not hold that lease (state held or uncertain) under that token, is a bug in the authority and is refused with error code bad_message, non-fatal: the node keeps its current state and starts or stops nothing.
{
"v": 1,
"type": "lease_grant",
"id": "msg_01JC9ZK9N4Z6B8D0F2H4K6N8Q0",
"re": "msg_01JC9ZC2F7S9Z1W3Y5A7C9E1G3",
"body": {
"lease_id": "lse_01JC9Z8K2M4P6R8T0V2X4Z6B8D",
"resource_type": "app",
"resource_id": "app_01JC9Z7B3N5Q7S9Z1W3Y5A7C9E",
"mode": "expiring",
"fencing_token": 7,
"duration_ms": 15000,
"granted_at": "2026-11-02T10:15:33Z"
}
}
5.13 lease_revoke
Direction: control plane to node. When: the lease moved to another node, the resource was deleted, an operator declared this node lost, or a higher token superseded it.
| Field | Type | Required | Meaning |
|---|---|---|---|
lease_id | string (lse_) | yes | Lease to revoke |
fencing_token | integer | yes | Token of the revoke; at least the node’s current token for the lease |
reason | string | yes | moved, deleted, declared_lost or superseded |
A revoke whose token is lower than the node’s current token predates the node’s latest grant; it is refused with error code bad_message, non-fatal, and the node keeps the lease and its VMs as they are (D-0073).
On receipt the node stops the stateful VMs bound to the lease, records the stop locally, moves the lease to revoked, and answers command_result before it processes any later message of the session. If the self-fence cannot confirm that every bound VM stopped, the answer is command_result with status error and the node follows section 8.5’s failed-fence path.
{
"v": 1,
"type": "lease_revoke",
"id": "msg_01JC9ZM0P5B8D0F2H4K6N8Q0S2",
"body": {
"lease_id": "lse_01JC9Z8K2M4P6R8T0V2X4Z6B8D",
"fencing_token": 8,
"reason": "moved"
}
}
5.14 log_entries
Direction: control plane to node. When: after the desired state of a session, then continuously as the change log grows.
| Field | Type | Required | Meaning |
|---|---|---|---|
from_seq | integer | yes | seq of the first entry in entries |
entries | array of object | yes | Change-log entries, at most 500 |
Each entry has exactly the change-log fields from the shared entity model: seq (gap-free from 1), term, time, actor_type, actor_id, entity_type, entity_id, op (create, update, delete), payload (full new row for create and update, {} for delete), prev_hash (64 hex, 64 zeros for seq 1) and hash (hex SHA-256 over the RFC 8785 serialization of the entry without hash).
{
"v": 1,
"type": "log_entries",
"id": "msg_01JC9ZN1Q6C8D0F2H4K6N8Q0S2",
"body": {
"from_seq": 903,
"entries": [
{
"seq": 903,
"term": 1,
"time": "2026-11-02T10:15:31Z",
"actor_type": "agent",
"actor_id": "agt_01JC9Z7D5Q7S9Z1W3Y5A7C9E1G",
"entity_type": "app",
"entity_id": "app_01JC9Z7B3N5Q7S9Z1W3Y5A7C9E",
"op": "update",
"payload": { "id": "app_01JC9Z7B3N5Q7S9Z1W3Y5A7C9E", "name": "blog" },
"prev_hash": "3b1f0c9a5e7d2b8f4a6c1d9e0f3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f90",
"hash": "9c7e1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f6a7b8c9d0e1"
}
]
}
}
5.15 stream_open
Direction: control plane to node. When: a human or an agent reads live logs, or the panel opens a stream.
| Field | Type | Required | Meaning |
|---|---|---|---|
stream_id | string (msg_) | yes | Stream identity chosen by the primary |
kind | string | yes | app_logs, deployment_logs, build_logs, metrics, terminal, console or transcript |
params | object | yes | Kind-specific selector |
| Kind | params | Data | Ends |
|---|---|---|---|
app_logs | app_id or vm_id (exactly one), optional tail (integer, default 0) | lines | on stream_close |
deployment_logs | deployment_id, optional phase | lines | on stream_close |
build_logs | build_id; optional tail (integer: start with the last tail lines; default 0) or after_seq (integer: start after that line), not both | lines with seq | completed once the build is terminal and every line was sent (ADR 0119) |
metrics | node (boolean) and vm_ids (array of vm_ ids), at least one of them set | metrics, one sample per sampling interval | on stream_close |
terminal | session_id (trm_), vm_id, rows and cols (integers, 1 to 1 000), argv (array of strings, at least one), optional user | data; input through stream_input | completed with exit_code, vm_stopped, limit, or cancelled on stream_close (ADR 0113) |
console | vm_id, optional tail_bytes (integer: replay at most that many bytes of the VM’s console ring first; default the whole ring) | data; stream_input is refused | vm_stopped when the VM is deleted, or cancelled (ADR 0116) |
transcript | session_id (trm_) | data: the transcript file, from its first byte | completed |
A kind this node cannot serve right now is answered with command_result status error: code not_found when the build, VM or transcript is not on this node, not_available when the VM exists but cannot serve the kind (a terminal on a VM started before the M4 guest init, or on a stopped VM). A terminal stream is one PTY session of its own: several terminal streams on one VM are several independent sessions. Any number of console streams may follow one VM; the node fans its one console reader out to all of them and none of them ever connects to the VM’s serial socket (ADR 0116).
The primary enforces 32 concurrent streams per session. A 33rd open is answered with command_result status error, code bad_message, detail stream_limit, without touching the node’s streams.
{
"v": 1,
"type": "stream_open",
"id": "msg_01JC9ZP2R7D0F2H4K6N8Q0S2T4",
"body": {
"stream_id": "msg_01JC9ZD9K2M4P6R8T0V2X4Z6B8",
"kind": "app_logs",
"params": { "app_id": "app_01JC9Z7B3N5Q7S9Z1W3Y5A7C9E", "tail": 100 }
}
}
A web terminal:
{
"v": 1,
"type": "stream_open",
"id": "msg_01JC9ZP3S8E1G3J5M7P9R1T3W5",
"body": {
"stream_id": "msg_01JC9ZD0L3N5Q7S9V1X3Z5B7D9",
"kind": "terminal",
"params": {
"session_id": "trm_01JC9ZA3C4D5E6F7G8H9J0K1M2",
"vm_id": "vm_01JC9Z7C4P6R8T0V2X4Z6B8D0F",
"rows": 24,
"cols": 80,
"argv": ["/bin/sh"]
}
}
}
5.16 stream_close
Direction: control plane to node. When: the consumer went away — the HTTP request ended, the panel tab closed, the agent’s token expired.
| Field | Type | Required | Meaning |
|---|---|---|---|
stream_id | string (msg_) | yes | Stream to stop |
The node stops producing, releases the stream slot, and answers stream_end with reason cancelled. Closing an unknown stream id is ignored.
{
"v": 1,
"type": "stream_close",
"id": "msg_01JC9ZQ3S8F2H4K6N8Q0S2T4Z6",
"body": { "stream_id": "msg_01JC9ZD9K2M4P6R8T0V2X4Z6B8" }
}
5.17 command
Direction: control plane to node. When: the primary needs a one-off action that is not a change of desired state.
| Field | Type | Required | Meaning |
|---|---|---|---|
kind | string | yes | logs_query, probe, fence_ack, build_logs_query or metrics_query |
params | object | yes | Kind-specific parameters |
logs_query params: app_id or vm_id (exactly one), since (RFC 3339, optional), until (RFC 3339, optional), tail (integer, optional). The answer is one command_result with status: ok and the matching lines in lines (section 5.9), each carrying its vm_id. The answer must fit the 1 MiB message limit: the node keeps the newest lines that fit and says in detail that older ones were left out.
probe params: {}. The node re-runs the KVM probe (/dev/kvm, CPU flags, kernel release) and answers with a command_result whose detail summarizes it; the primary updates the node’s kvm record and engine_version.
fence_ack params: lease_ids (array of lse_ ids, at least one). It carries an operator’s acknowledgement of failed fences, recorded through POST /api/v1/nodes/{node_id}/fence-ack (section 8.5). The node clears the failed fence it recorded for each listed lease, and clears its failed mark once no failed fence remains. It answers command_result with status: ok; when failed fences of other leases remain, the node stays failed and detail names them. A listed lease with no recorded failed fence is not an error, so a redelivered fence_ack is harmless. The listed leases stay revoked or expired on the node: it starts nothing stateful for them until a lease_grant arrives with a higher fencing_token (section 8.5 step 4).
build_logs_query params: build_id, after_seq (integer, optional, default 0), limit (integer, optional, default 500, at most 1 000). The answer is one command_result with status: ok and the build’s log lines after after_seq in lines, in the build-log line shape of section 5.7, oldest first, at most limit and within the 1 MiB message limit (the node returns fewer lines rather than a larger message). A build the node does not have is answered with status: error, code not_found.
metrics_query params: node (boolean) and vm_ids (array of vm_ ids), at least one of them set. The answer is one command_result with status: ok and metrics:
| Field | Type | Meaning |
|---|---|---|
interval_s | integer | The node’s sampling interval, 5 s |
node | array of object | Present when node was asked: the node’s samples, oldest first |
vms | array of object | One { "vm_id", "samples" } per asked VM the node runs; a VM it does not run is left out |
The node samples every interval_s into an in-memory ring of 10 minutes per node and per running VM (ADR 0014): the ring is never written to disk, never sent unless asked and lost on an agent restart. A node sample is { "time", "cpu_usage", "cpu_count", "memory_total_bytes", "memory_used_bytes" }: cpu_usage is the busy fraction of all host CPUs over the interval (0 to 1, from /proc/stat), memory_used_bytes is MemTotal - MemAvailable (from /proc/meminfo). A VM sample is { "time", "cpu_usage", "vcpus", "memory_rss_bytes", "memory_bytes" }: cpu_usage is the CPU time of the VM’s VMM process over the interval divided by the interval and by vcpus (0 to 1; it can exceed 1 briefly because the VMM’s own threads count), memory_rss_bytes is the VMM process’s resident memory on the host, which counts the guest pages the guest has touched and does not shrink when the guest frees memory, and memory_bytes is the VM’s configured memory. Both come from the engine’s counters and the host’s process accounting (engine-trait.md section 3.15); no guest agent is involved. The metrics stream sends one object per interval in the same shape, with one sample per array.
{
"v": 1,
"type": "command_result",
"id": "msg_01JC9ZG8N3Y5A7C9E1G3J5M7P9",
"re": "msg_01JC9ZR6W1K6N8Q0S2T4V6X8Z0",
"body": {
"status": "ok",
"metrics": {
"interval_s": 5,
"node": [
{ "time": "2026-11-04T10:15:30Z", "cpu_usage": 0.12, "cpu_count": 8, "memory_total_bytes": 17179869184, "memory_used_bytes": 6442450944 }
],
"vms": [
{
"vm_id": "vm_01JC9Z7C4P6R8T0V2X4Z6B8D0F",
"samples": [
{ "time": "2026-11-04T10:15:30Z", "cpu_usage": 0.04, "vcpus": 2, "memory_rss_bytes": 201326592, "memory_bytes": 536870912 }
]
}
]
}
}
}
Restarts and stops are never commands. They are changes of desired state — restart_generation for a restart, desired_status for a stop — so they are idempotent, survive a reconnect, and cannot be lost with a dropped message (D-0011).
{
"v": 1,
"type": "command",
"id": "msg_01JC9ZR4T9H4K6N8Q0S2T4Z6V8",
"body": {
"kind": "logs_query",
"params": {
"vm_id": "vm_01JC9Z7C4P6R8T0V2X4Z6B8D0F",
"since": "2026-11-02T10:00:00Z",
"tail": 200
}
}
}
{
"v": 1,
"type": "command",
"id": "msg_01JC9ZR5V0J5M7P9R1T3W5Y7A9",
"body": {
"kind": "fence_ack",
"params": {
"lease_ids": ["lse_01JC9Z8K2M4P6R8T0V2X4Z6B8D"]
}
}
}
5.18 error
Direction: either side. When: a message could not be accepted, or the session must end.
| Field | Type | Required | Meaning |
|---|---|---|---|
code | string | yes | One of the codes below |
message | string | yes | Human-readable detail |
fatal | boolean | yes | Whether the session ends |
re | string (msg_) | no | The message that caused it, in the envelope |
| Code | Fatal | Meaning |
|---|---|---|
unsupported_version | yes | Envelope major version not implemented |
unauthenticated | yes | Credential rejected; the node stops dialing |
node_removed | yes | The node was removed from the cluster (section 10) |
bad_signature | no | A desired_state or signing_key_rotate signature did not verify; the message was discarded |
bad_message | no | Malformed frame, unknown type, unknown command kind, oversized frame |
log_diverged | no | The node’s change-log copy does not match the primary’s (section 7) |
not_found | no | A stream_open or command named a build, VM or transcript this node does not have. Carried in the error of a command_result |
not_available | no | The VM exists but cannot serve the requested stream now: a terminal on a stopped VM or on a VM started before the M4 guest init. Carried in the error of a command_result |
fence_failed | no | A self-fence could not confirm that every stateful VM bound to the lease stopped, so a VM may still run (section 8.5). Carried in the error of the command_result that answers a lease_revoke; the node has marked itself failed |
A fatal error ends the session immediately; the node applies its redial rule (section 4.4). A non-fatal error leaves the session usable.
{
"v": 1,
"type": "error",
"id": "msg_01JC9ZS5Z0J6N8Q0S2T4Z6V8W0",
"re": "msg_01JC9ZJ8M3Y5A7C9E1G3J5M7P9",
"body": {
"code": "bad_signature",
"message": "desired_state signature does not verify against the cluster signing key",
"fatal": false
}
}
5.19 ping
Direction: either side. When: to test liveness outside the heartbeat cadence, or to keep a connection warm through an intermediary that closes idle sockets.
| Field | Type | Required | Meaning |
|---|---|---|---|
| (none) | — | — | Body is {} |
The receiver answers pong with re set to the ping id. A ping is not a heartbeat and does not reset the primary’s 15 s timer by itself; any frame does.
{
"v": 1,
"type": "ping",
"id": "msg_01JC9ZT6V1K8Q0S2T4Z6V8W0X2",
"body": {}
}
5.20 pong
Direction: either side.
When: as the answer to a ping.
| Field | Type | Required | Meaning |
|---|---|---|---|
| (none) | — | — | Body is {} |
{
"v": 1,
"type": "pong",
"id": "msg_01JC9ZZ7W2W0S2T4Z6V8W0X2Y4",
"re": "msg_01JC9ZT6V1K8Q0S2T4Z6V8W0X2",
"body": {}
}
5.21 signing_key_rotate
Direction: control plane to node. When: when the cluster signing key is rotated (D-0050).
| Field | Type | Required | Meaning |
|---|---|---|---|
key_id | string | yes | Id of the new cluster signing key: the lowercase hex SHA-256 of its raw 32-byte public key |
public_key | string | yes | Base64 Ed25519 public key of the new signing key |
signature | string | yes | Base64 Ed25519 signature by the current pinned signing key |
Signature. As for desired_state (section 5.11), the signature covers the RFC 8785 serialization of the whole body object with the signature member removed. The node verifies it against the cluster public key it currently has pinned.
Node behaviour. On a valid signature the node persists the new public key and the key_id in its local store (node_meta, section 12), and from then on accepts only signatures by the new key. It answers with a command_result (re set to this message’s id) meaning persisted, the same acknowledgement desired_state and lease_revoke use. A signature that does not verify is answered with error code bad_signature, and the node keeps the old pin and the old key.
{
"v": 1,
"type": "signing_key_rotate",
"id": "msg_01JC9ZV9X3Y0S2T4Z6V8W0X2Y4A6",
"body": {
"key_id": "3f9a1c0e7b52d4a86e1f09c3b7d25e4a90c18f6b2d73e5a41c0b98f7e26d1a35",
"public_key": "9pQ2m0Yb1s7Tq3Vx6Kd8Lw5Rc4Nh2Jf0Gz1Xe7Ua3Ik=",
"signature": "MEUCIQDf3k2m9Qx1s7Tq3Vx6Kd8Lw5Rc4Nh2Jf0Gz1Xe7Ua3I..."
}
}
5.22 dns_challenge
Direction: node to control plane.
When: while the node obtains or renews an ACME certificate for an acme route (D-0094). The node holds no DNS credential: the primary writes the DNS-01 TXT record on its behalf with the domain’s dns provider (domains.provider_id).
| Field | Type | Required | Meaning |
|---|---|---|---|
action | string | yes | set to publish the TXT record, clear to remove it |
hostname | string | yes | The route’s hostname the certificate is for |
value | string | set only | The DNS-01 key authorization digest (base64url, RFC 8555 section 8.4) |
record_id | string | clear only | The id returned by the dns_challenge_result that answered the set |
Authorization. The primary accepts a set only for a hostname that is an acme route in the node’s current desired state and whose domain has a dns provider. The record name is always _acme-challenge.<hostname>; the node cannot choose another name. A clear removes only a record the primary created for this node. Anything else is answered with dns_challenge_result ok: false.
{
"v": 1,
"type": "dns_challenge",
"id": "msg_01JC9ZW2A4C6E8G0J2M4P6R8T0",
"body": {
"action": "set",
"hostname": "blog.example.com",
"value": "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0"
}
}
5.23 dns_challenge_result
Direction: control plane to node.
When: answers each dns_challenge, with re set to its id.
| Field | Type | Required | Meaning |
|---|---|---|---|
ok | boolean | yes | Whether the record was written (set) or removed (clear) |
record_id | string | no | Present on a successful set: the id to pass in the later clear |
message | string | no | Present when ok is false: human-readable reason, never a credential |
A successful set means the provider accepted the record, not that resolvers already see it: the node checks propagation itself before it tells the ACME server the challenge is ready. A failed set fails that issuance attempt, which the node retries with backoff and reports as certificate.failed.
{
"v": 1,
"type": "dns_challenge_result",
"id": "msg_01JC9ZW3B5D7F9H1K3N5Q7S9V1",
"re": "msg_01JC9ZW2A4C6E8G0J2M4P6R8T0",
"body": { "ok": true, "record_id": "372e67954025e0ba6aaa6d586b9e0b59" }
}
5.24 stream_input
Direction: control plane to node.
When: the client of a terminal stream typed, pasted, resized its window or closed its input (ADR 0116).
| Field | Type | Required | Meaning |
|---|---|---|---|
stream_id | string (msg_) | yes | The terminal stream |
seq | integer | yes | Per-stream input sequence, gap-free from 1 |
data | string | no | Base64 bytes for the program’s input, at most 64 KiB decoded |
resize | object | no | rows and cols (integers, 1 to 1 000): the new window size |
eof | boolean | no | True to close the program’s input |
At least one of data, resize and eof is present; the node applies them in that order. The node writes input in seq order and records it in the transcript before it reaches the guest (i and r events, audit-log-format.md section 3.3). A stream_input for a console, build_logs, metrics, transcript or log stream is answered with error code bad_message, non-fatal, and dropped: a console is read-only (ADR 0116). Input for an unknown or ended stream is ignored. Input has no window of its own; the primary forwards at most one client WebSocket message per stream_input, and the WebSocket’s own back-pressure bounds it.
{
"v": 1,
"type": "stream_input",
"id": "msg_01JC9ZX4C6E8G0J2M4P6R8T0V2",
"body": {
"stream_id": "msg_01JC9ZD0L3N5Q7S9V1X3Z5B7D9",
"seq": 3,
"data": "dW5hbWUgLWEK"
}
}
{
"v": 1,
"type": "stream_input",
"id": "msg_01JC9ZX5D7F9H1K3N5Q7S9V1X3",
"body": {
"stream_id": "msg_01JC9ZD0L3N5Q7S9V1X3Z5B7D9",
"seq": 4,
"resize": { "rows": 50, "cols": 132 }
}
}
5.25 stream_ack
Direction: control plane to node.
When: after the primary has handed bytes of a data stream (terminal, console, transcript) to their consumer, at the latest every 64 KiB.
| Field | Type | Required | Meaning |
|---|---|---|---|
stream_id | string (msg_) | yes | The stream |
acked_bytes | integer | yes | Cumulative decoded bytes of the stream’s data that the consumer has taken, from the stream’s start |
Credit flow control (ADR 0116): the node keeps at most 256 KiB of decoded data sent on a stream and not yet covered by acked_bytes. At the window it stops reading the stream’s source: a terminal’s vsock connection, whose own credit flow control then stops the guest program, or a console’s ring, which keeps filling, so a slow console viewer loses the oldest bytes it has not seen and never slows the guest. Without the window, one busy terminal could fill the session’s send queue and delay heartbeats and lease messages on the same WebSocket. An acked_bytes lower than an earlier one is ignored; one beyond the bytes sent is a primary bug, answered with error code bad_message, non-fatal.
{
"v": 1,
"type": "stream_ack",
"id": "msg_01JC9ZY6E8G0J2M4P6R8T0V2X4",
"body": { "stream_id": "msg_01JC9ZD0L3N5Q7S9V1X3Z5B7D9", "acked_bytes": 196608 }
}
6. Reconciliation
6.1 Source of truth on the node
The node always acts on the newest verified snapshot in its local store. It never acts on a message that is still in memory: desired_state is persisted before command_result is sent, so a crash between receipt and apply cannot lose the revision.
revonly increases. Adesired_statewith arevlower than or equal to the stored one is acknowledged withcommand_resultstatusokand ignored.- An older revision arriving after a newer one is a primary bug; the node keeps the newer snapshot and reports it.
6.2 Apply loop order
An apply run always follows this order, whatever changed:
- Network. Ensure the bridge, subnet and gateway from
spec.network, and the per-VM TAP devices. Each VM is attached with itsproject_idas its isolation group, so the VMs of one project reach each other and VMs of different projects do not (engine-trait.mdsection 3.3, D-0084). The DNS servers handed to guests come from the node’s own configuration, not fromspec.network. - Images. Prepare every image referenced by a VM whose rootfs is missing or has a different digest, and remove rootfs images no longer referenced. An image whose VM carries
build_idis never pulled: it was prepared by the build’simportingstep (section 6.6); if it is gone, the deployment fails in phasepullwith the error that the built image is no longer on this node. A root disk is referenced while a VM inspec.vmsor a build inspec.buildsnames it. - Stop. Stop VMs that are no longer desired, or whose spec requires a recreate (image digest, disk size, network identity).
- Start. Start desired VMs that are not running.
- Builds. Start the next
queuedbuild ofspec.buildswhen the node has no build running (one at a time per node by default, ADR 0118), stop a build markedcancelled, and report every change instatus_report.builds(section 6.6). Builds run alongside the other steps, never block them, and are not restarted by a later apply run. - Routes. Update the reverse proxy for the hostnames in
spec.routes, and start, replace or stop the tunnel connector to matchspec.ingress.tunnel. Certificates foracmeroutes are obtained in the background throughdns_challenge(section 5.22); a route keeps serving its previous certificate until a new one is issued.
The order exists so that a VM never starts on a network that is not ready, and no route points at a VM that is being replaced. It is the reconciliation described in D-0011 and the deployments in D-0033.
6.3 Restart, crash and health
- A VM that exits without being asked to stop is
crashed; the node restarts it with backoff from 1 s doubling to 5 min. The backoff resets after the VM has been healthy for 10 minutes.restart_generationin the spec is what a human-initiated restart changes; the crash path does not increment it. - Health checks use the app’s
health_check(pathdefault/,interval_sdefault 10,timeout_sdefault 2,failuresdefault 3). Afterfailuresconsecutive failures the node reports the app asfailingand emitsapp.down; on recovery it reportspassingand emitsapp.up. - Image preparation is cached by digest. Two VMs with the same digest share one prepared rootfs; the digest, not the tag, is the key.
- An agent restart does not restart VMs. VMMs are separate processes that outlive the agent, and the agent rediscovers them on start (
engine-trait.md, D-0011). This is what makes an agent upgrade invisible to running apps.
6.4 Stateful starts
A stateful VM starts only while the node holds that app’s lease in state held and the lease’s fencing_token equals the fencing_token in the VM spec. If either condition fails the VM stays stopped, the fact is recorded locally, and the node reports the VM as stopped with health: unknown; it does not report an error to the primary, because the primary is the one that moved the lease (D-0019). Because renewals keep the token (D-0073), the equality holds for the whole time one holder owns the lease; it changes only when ownership changes, and then the primary issues a new desired_state carrying the new token. A node that marked itself failed (section 8.5) starts no stateful VM at all.
6.5 While disconnected
Everything continues: running VMs keep running, restarts still happen, health checks still run, and the node keeps reconciling against its stored snapshot. Events are buffered in the local store, bounded at 10 000 entries. On overflow the oldest entries are dropped, a counter is incremented, and the next hello reports it as dropped_events so that a primary never assumes silence means “nothing happened”.
6.6 Builds
A build runs on the node in an ephemeral builder microVM, one per build, created through the engine like any VM (ADR 0117, ADR 0118):
-
Builder VM. Root disk: the BuildKit image pinned by digest in the node agent’s build (
pins.txt, like Cloud Hypervisor and the kernel) and prepared withprepare_image; it runs the rootful, daemonlessbuildkitdpattern with--oci-worker-net=host. Size:limits.vcpusandlimits.memory_mb. Network: on the node bridge with no isolation group andegress_only(engine-trait.mdsection 3.3), so it reaches the outside through the gateway and no VM and no service on the host. The builder VM counts against the node’s capacity like any VM; a build whose limits do not fit waits inqueued. -
Disks. The builder VM gets three work disks (
engine-trait.mdsection 3.6), found in the guest by serial:quoro-work: one persistent ext4 cache disk per app on this node, at mostlimits.cache_gbgibibytes, created by the engine on the app’s first build. It holds the BuildKit state and the extracted build context. Only the builder VM ever mounts it; the node never mounts, reads or writes inside it. After each build the builder runsbuildctl prune --keep-storagedown to below the cap. The node deletes the file once the app appears in neitherspec.vmsnorspec.builds.quoro-src: an ephemeral raw disk with no filesystem, written by the node before boot: one tar stream holding the source tree at the resolved commit and the build’s secret files. The guest extracts it and never writes to it.quoro-out: an ephemeral raw disk with no filesystem oflimits.output_gbgibibytes, sparse and zeroed by the node. The builder writes the OCI layout to it as one tar stream; the node reads it back as a tarball in user space.
The node deletes both ephemeral disks when the build ends. A disk the builder VM wrote is never mounted on the host: a guest-written filesystem would be parsed by the host kernel, and the guest is untrusted.
-
Phases.
preparing: the node resolvesrefto a commit with the host’s Git client, fetching only that commit (shallow, public HTTPS only in M4, no submodules) into a bare repository, so no working tree of the repository ever exists on the host; writes the tree of that commit and the secret files toquoro-src; and boots the builder VM.building:buildctl buildwith the Dockerfile frontend,--progress=rawjson,build_argsas--build-argandsecretsas--secret(extracted into guest memory, never ontoquoro-work).exporting:--output type=ociontoquoro-out, at mostlimits.output_gb.importing: after the builder VM powered off, the node finds the manifest digest in the layout’sindex.json, verifies the layout and prepares it as a root disk withprepare_layout(engine-trait.mdsection 3.17), cached by manifest digest, then reportssucceededwithimage_digest. -
Failure. A step that fails ends the build
failedin phasebuilding, withfailed_stepfrom BuildKit’s rawjson output (the failing vertex, its name and error, the Dockerfile line) and the excerpt from the plain log. The wall-clock limitlimits.timeout_sstops the builder VM and fails the build in the phase it was in, with the errortimeout. A failed checkout failspreparing. A node agent restart while a build runs fails the build in its current phase with the errornode agent restarted; builds are not resumed. -
Cancel.
cancelled: truestops the builder VM with a forced stop and reportscancelled. -
Log. The builder VM’s tagged output lines (the same path as app logs,
engine-trait.mdsection 6) are parsed: rawjson objects become build events and plain lines are appended to the build’s log next to its work directory, numbered from 1 (ADR 0119). The log is served bybuild_logsstreams andbuild_logs_query, capped at 8 MiB per build (later lines are counted and dropped with one marker line), and kept for the last 20 builds of each app on this node and until the app’s work disk is deleted. It is never sent unasked and never replicated; only the status andfailed_stepreach the primary (ADR 0014).
Defaults. Until a contract change makes them configurable, the primary sends limits { "vcpus": 2, "memory_mb": 2048, "timeout_s": 1800, "cache_gb": 10, "output_gb": 10 }.
{
"build_id": "bld_01JC9ZA2B3C4D5E6F7G8H9J0KM",
"app_id": "app_01JC9Z7B3N5Q7S9Z1W3Y5A7C9E",
"project_id": "prj_01JC9Z5A1B2C3D4E5F6G7H8J9K",
"repo_url": "https://github.com/example/blog.git",
"ref": "main",
"dockerfile_path": "Dockerfile",
"context_dir": ".",
"build_args": { "NODE_ENV": "production" },
"secrets": {},
"limits": { "vcpus": 2, "memory_mb": 2048, "timeout_s": 1800, "cache_gb": 10, "output_gb": 10 },
"cancelled": false
}
7. Change-log follow
Every change to cluster state is a numbered entry in an append-only, hash-chained log (D-0013, D-0015). Replica nodes follow it so that a manual promotion (D-0016) needs no protocol change: a node that already holds a current copy can serve reads and forward writes.
- Which nodes follow: every node with the role
node, from M2 on. Nothing in this protocol distinguishes a future primary from any other follower, which is what keeps promotion cheap. When promotion happens (manual promotion in M5b, D-0067) the promoted node stops being a follower and starts issuingdesired_statefor its own nodes. - Delivery:
log_entrieswithfrom_seqand up to 500 entries. The node verifies each entry’shashover the RFC 8785 serialization of the entry withouthash, and thatprev_hashequals the previous entry’shash, then stores it and sendslog_ack. - On
hello: the primary compares the node’slog_seqandlog_hashwith its own hash at that sequence. If they differ, the primary sendserrorwith codelog_diverged, non-fatal. The node moves its local copy aside as a quarantined copy — never deleting it, because divergent local writes must never be silently dropped (D-0022) — and refollows fromseq1. - Secrets in the log:
app_envvalues, webhook secrets and provider secrets are stored encrypted with the cluster data-encryption key (AEAD,db-schema.sql), so a follower holds ciphertext it cannot read. How a promoted replica obtains the cluster data-encryption key is open and is decided together with manual promotion in M5b (D-0067); until then the key does not leave the primary.
8. Leases and self-fencing
This section is the core of the contract. It exists because starting a stateful app on node B while it is still running on node A produces two diverging copies of the same data (D-0019).
The lease modes are decided (D-0047): local for HA policy none and cold, expiring (M5b and later) for warm and hot. The node-side states held, uncertain, expired and revoked are fixed.
8.1 Who arbitrates
| Period | Lease authority | Consequence |
|---|---|---|
| M0 to M5a | The primary, in process | One control plane; a local lease survives its absence (D-0011) |
| M5b and later | The primary backed by a quorum with the witness, or an external arbiter | Same messages; the authority behind them is stronger (D-0017) |
The external arbiter options are an S3/R2 conditional write or a Cloudflare Durable Object. Both are researched and both require the fencing token to stay (F-0037, F-0038, docs/research/r2-conditional-writes.md, docs/research/durable-objects.md): object storage has no server-side expiry, and a Durable Object restart can kill an in-flight write, so “the lease expired” is always inferred and always enforced by the node and by the token.
The same mechanism also serves primary election (D-0019). Leases with resource_type: primary appear when automatic failover exists (M6); in M0-M5 the only leases are resource_type: app.
8.2 Messages
| Message | Direction | Purpose |
|---|---|---|
lease_grant | control plane to node | Grant a lease, with mode, token and duration; also the answer to lease_renew |
lease_renew | node to control plane | Ask to extend an expiring lease every duration_ms / 3 |
lease_revoke | control plane to node | End a lease: moved, deleted, declared_lost, superseded |
8.3 local mode
Issued for the HA policies where no other copy of the app can start (none, cold, D-0047), so nobody is waiting to take the lease.
- It never expires because the session is gone or the control plane is unreachable. This is what keeps D-0011 true: an app keeps running when the panel is down.
- It ends only by:
lease_revokewith reasonmoved,deleted,superseded; or- an operator declaring the node lost (part of the
locallease mode, D-0047), which is a manual action from M5a and needs an endpoint that a later contract pull request adds. Declaring a node lost increments the lease’sfencing_token(D-0019), so the old holder’s token is stale even though the old holder never received a message.
- A
locallease on a node whose session is gone is a normal state, not an incident. The primary reports the nodedisconnected, notfenced.
8.4 expiring mode timing
Used for warm and hot, where a second copy is ready and will start as soon as the lease moves (D-0047).
Definitions, all on the node’s monotonic clock:
| Symbol | Value |
|---|---|
D | duration_ms from the grant |
M | margin = max(1000 ms, D / 10) |
t_send | Node monotonic time at which it sent the lease_renew that the current grant answers. A grant that answers no outstanding renewal has no t_send and never sets or extends valid_until. |
| Renewal interval | D / 3; a renewal left unanswered for one interval is late |
valid_until | t_send + D - M |
| Fence deadline | valid_until + M = t_send + D |
T_grace | Graceful-stop timeout used when fencing |
T_force | Upper bound for the forced stop that follows |
valid_until = t_send + D - M
M = max(1000, D / 10)
The authority never grants a duration_ms below 3 000 ms: with the floor margin of 1 000 ms that keeps valid_until at least 2 000 ms after the renewal, so the node has a real window to renew before it must fence. A grant below that floor is refused with error code bad_message, non-fatal.
First grant. An expiring grant that does not answer an outstanding renewal of the node — the first grant of a lease to this node, a grant after expired or revoked, or a higher-token grant while the node already holds the lease — records the token, leaves the lease (or puts it) in uncertain, and makes the node send a lease_renew at once. The lease becomes held only when the authority answers that renewal with the same token; valid_until is then computed from that renewal’s send time. Until then the node starts nothing stateful for it, and an existing valid_until (a higher-token grant while held) is kept, never extended. This removes any assumption about how long the grant took to arrive (D-0072). A local grant needs no renewal and is held on receipt.
Node state machine.
| From | Trigger | To | Action |
|---|---|---|---|
— , held, expired, revoked | lease_grant, local mode, token higher than any seen | held | Record the token |
— , expired, revoked | lease_grant, expiring mode, token higher than any seen | uncertain | Record the token, send lease_renew at once |
held, uncertain | expiring lease_grant with a higher token that answers no renewal (ownership changed) | uncertain | Record the token, keep valid_until, send lease_renew at once |
uncertain | lease_grant with the same token answering an outstanding renewal | held | Compute valid_until from that renewal’s t_send |
held | a renewal is answered with the same token | held | Recompute valid_until from the new t_send |
held | a renewal is late, or the session is lost | uncertain | Keep running; start nothing new stateful |
held, uncertain | monotonic clock reaches valid_until | expired | Self-fence (section 8.5) |
| any | lease_revoke with a token at least the current one | revoked | Self-fence, answer command_result |
A local grant repeated with the same token while held changes nothing.
uncertain is deliberate. It is “the arbiter is unreachable”, not “the lease is lost”: stopping every stateful app during a control-plane or arbiter outage would break D-0011, while starting a new stateful VM in that window could produce the double-run D-0019 forbids. The distinction between the two situations is exactly what the lease research asks for (docs/research/r2-conditional-writes.md, docs/research/durable-objects.md).
Why the node finishes fencing before another holder may start. Let T_grant be the authority’s send time of the grant the node is currently holding. The authority grants the same lease to another holder no earlier than:
T_gate = T_grant + D + 2M
The node starts fencing at valid_until = t_send + D - M and must be done by:
F = valid_until + M = t_send + D
T_grace + T_force <= M
T_grant >= t_send (the grant is sent after the renewal it answers arrived; a held lease always has a t_send, because only an answered renewal makes a lease held), so on a shared time base:
F - T_gate <= (t_send + D) - (t_send + D + 2M) = -2M
The fence therefore completes at least 2M before the gate opens. The two clocks are not shared, so let δ be the bound on their drift and offset over the lease lifetime: the fence completion, expressed on the authority’s clock, is at most t_send + D + δ, and the gate is at least t_send + D + 2M - δ. The remaining gap is 2M - 2δ, which is strictly positive whenever δ < M. The margin M must therefore be larger than the bounded drift and the messaging delay between the machines; that the drift is bounded by such a value is a Hypothesis to be measured on the lab, and a node whose clock steps backwards or forwards by more than M must be treated as a failed node.
Worked example with D = 15000:
| Quantity | Value |
|---|---|
M | 1 500 ms |
valid_until | t_send + 13 500 ms |
Fence deadline F | t_send + 15 000 ms |
Gate T_gate | not before t_send + 18 000 ms |
Chosen T_grace / T_force | 1 000 ms / 300 ms, sum 1 300 ms ≤ M |
| Actual fence completion, worst case | t_send + 14 800 ms |
| Slack before the gate | at least 3 200 ms, minus 2δ |
What the authority must enforce. The gate is the authority’s obligation, not the node’s: the primary (or the arbiter) records the send time of the last grant to a holder and refuses to grant the same lease to anyone else before D + 2M have passed on its own clock. An authority that grants earlier can hand the same app to two nodes. That obligation is why an arbiter with a real single-writer critical section (a Durable Object, or a conditional write on strongly consistent storage) matters at M5b (docs/research/durable-objects.md, docs/research/r2-conditional-writes.md).
Agent restart. expiring leases that were held or uncertain survive an agent restart within the same host boot. The node persists, for each such lease, the remaining validity measured on CLOCK_BOOTTIME together with the host’s kernel boot id (/proc/sys/kernel/random/boot_id, stored as host_boot_id in node_meta, section 12; not the per-process boot_id of hello). On restart, if the kernel boot id is unchanged and the remaining validity has not run out on CLOCK_BOOTTIME, the lease comes back uncertain with that remaining valid_until and renews at once; otherwise it comes back expired and is fenced. CLOCK_BOOTTIME keeps counting while the machine is suspended, so a suspend can only shorten the remaining validity. The VMMs outlive the agent (section 6.3), so an agent upgrade does not stop warm apps. Required before expiring leases ship in M5b (D-0072); M0 restores such leases as expired and fences them, which is the conservative behaviour.
8.5 Self-fence procedure
When a lease reaches expired or revoked, the node:
- Stops every stateful VM bound to that lease with
StopMode::Graceful { timeout: T_grace }, then withStopMode::Forceif the timeout passes, so the whole stop finishes withinT_grace + T_force <= M(engine-trait.md). The forced stop escalates toSIGKILLon the VMM process inside the engine (engine-trait.mdsection 3.8). - Records the stop locally: lease id, token, VM ids, time and the reason (
expiredorrevoked), so the fact survives an agent restart. - Emits
node.fencedwith the lease, the token and the VM ids — only when every bound VM is confirmed stopped or gone. If the session is down the event is buffered (section 6.5) and delivered later. - Starts nothing stateful for that resource until a new
lease_grantarrives with a higherfencing_token.
The node also emits node.fenced when an operator declaration invalidated its token without a message reaching it — it learns that when the primary refuses its next lease_renew or when a hello is answered with a lease_revoke.
Failed fence. If a forced stop fails or times out and the node cannot confirm the VM stopped (the engine reports it neither stopped nor gone), the VM may still be running. The node then never emits node.fenced for that fence; it records the failed fence locally, emits node.fence_failed with the lease, the apps and the VM ids that may still run, marks itself failed in its local store (node_meta, section 12) so the mark survives an agent restart, starts no stateful VM for any lease while failed, and answers a lease_revoke with command_result status error and error code fence_failed (section 5.18). The lease stays unstartable on this node. The primary treats node.fence_failed like the loss of the node: it does not grant the lease to anyone else until an operator acknowledges the fence, as when declaring a node lost (section 8.3) (D-0074).
Acknowledging a failed fence (D-0079). Only a human clears a failed fence, after checking out of band that the VMs it names are stopped (for example by power-cycling the host):
- When the primary receives
node.fence_failed, or alease_revokeanswered withfence_failed, it records the failure on the node: the lease, app and VM ids are added to the node’sfence_failure(db-schema.sqlmigration 0001;Node.fence_failureinopenapi.yaml). While a lease is listed there, the primary grants it to no node. - An operator calls
POST /api/v1/nodes/{node_id}/fence-ackwith the lease ids they verified. The scopenode:fence_ackis human-only, so no agent can clear a fence, and the call is audited (D-0031). The list must equal the node’s outstandingfence_failurelease ids, so an acknowledgement cannot cover a failure the operator has not seen. - In the same transaction the primary increments the
fencing_tokenof each listed lease, exactly as when declaring a node lost (section 8.3), clears the node’sfence_failure, and records the acknowledgement as pending delivery (fence_ack_pending). From then on the leases may be granted again, to this node or another, with the new token. - The primary sends
commandkindfence_ackwith those lease ids (section 5.17): at once when the node is connected, otherwise at its next session after the stale-lease revokes and beforedesired_state(section 4.2). Acommand_resultwithstatus: okclearsfence_ack_pending; until then the primary sends it again at each session start. - The node clears the listed failed fences and, when none remains, its failed mark. A node that never receives the command stays failed, which is the safe side: it starts nothing stateful.
Concurrency. Fences of different leases must run concurrently, and an agent crash in the middle of a fence must leave the lease fenced on restart; both are covered by mandatory tests before expiring leases ship (D-0075). M0 fences one lease at a time, which is acceptable only because no expiring lease exists outside tests.
8.6 Fencing tokens
- A token is an integer per leased resource that increases strictly on every ownership change — a grant to a new holder, or a grant after expiry or revoke — and stays the same across renewals by the same holder (D-0073). It is never reused. The current token lives on the lease row (
fencing_tokenin the shared entity model) and is echoed in everylease_grant,lease_revoke,lease_renew,helloand stateful VM spec. - Every write to shared storage carries the token: the chunk store and object store from M5, and the replication stream (D-0023). A receiver rejects any write whose token is lower than the highest it has seen for that resource, and records the rejection.
- Tokens are what makes the lease safe against a paused node: a node that was frozen past its expiry can still act on stale intent, and only the token at the storage layer stops it (
docs/research/r2-conditional-writes.md). - Arbiters do not remove the need for tokens. A Durable Object can bump a token inside its critical section, and R2 has no server-side expiry at all, but in both cases the node and the storage must still enforce the token (
docs/research/durable-objects.md).
8.7 What exists in M0, and the milestone table
M0 ships the protocol, not the HA feature: the messages, the four node-side states, the self-fence code path, and tests — revoking a local lease, expiring a test-only short expiring lease, and a failed forced stop that ends in node.fence_failed instead of node.fenced, among others. They run against the in-process primary and the FakeEngine (engine-trait.md), so the path is exercised on machines without KVM. This is the “self-fencing from M0” requirement of D-0019.
| Milestone | Authority | Modes in use | What the node does |
|---|---|---|---|
| M0 | In-process primary | local (message-complete), expiring only in tests | Runs the state machine and the self-fence path against the fake engine |
| M1 | Primary | local | Single node; the engine lifecycle runs, no stateful app exists yet |
| M2 | Primary | local | Enrolls, follows the change log, reconciles desired state, emits events |
| M3 | Primary | local | Same; the agent-driven calls arrive as desired-state changes from the API and MCP |
| M4 | Primary | local | Adds builds in desired state, build-log, metrics, terminal, console and transcript streams, stream_input and stream_ack; leases unchanged |
| M5a | Primary | local | Cold backups; operator declaration of a lost node adds a lease revoke with a new token |
| M5b | Primary plus witness quorum or external arbiter | local, expiring (warm) | Warm: renewal loop, uncertain handling, replication writes carrying tokens, restart persistence of held leases (section 8.4), concurrent fences (section 8.5) |
| M6 | Quorum | local, expiring (warm, hot) | Automatic failover and hot; the same messages also carry resource_type: primary leases |
9. Node return
A node that comes back after a failure may hold writes nobody else has and may be missing everything that happened while it was gone, which is why failback is never automatic (D-0022).
Order on reconnect:
hello, carryingstored_rev,applied_rev,log_seq,log_hashand every lease the node believes it holds.- The primary revokes every stale lease: held by a node the primary no longer considers the holder, or carrying a lower token than the primary’s. The node fences those first, before anything else (section 4.2).
- Only after fencing, the primary sends a full
desired_state. A node that returned fenced starts nothing stateful, exactly as D-0022 step 1 requires. - The node reconciles. Because the snapshot is complete and
revis monotonic, reconciliation cannot start a duplicate app: a stateful VM appears in the snapshot only if the node holds the matching lease token, which it does not until the primary grants it again. - Log follow resumes. If the hash chain no longer matches, the divergent copy is quarantined and the node refollows from
seq1 (section 7).
Divergent local writes are kept as a quarantined snapshot and recorded in the audit log from M5b (D-0022 step 3, D-0031). There is no automatic failback: the default is that roles simply swap, and a return to the previous node is a planned switchover with a write freeze, a final sync and a lease move (D-0022).
10. Node removal
DELETE /api/v1/nodes/{node_id}
- The scope is
node:remove, destructive and human-only (README.md), so an agent cannot remove a node. - If the node is connected, the primary first sends
lease_revokefor every lease the node holds and waits for eachcommand_result, so the node fences its stateful VMs; then it ends the session with a fatalerrorcodenode_removedand marks the noderemoved. If the node is not connected, removal increments the fencing token of each of its leases, exactly like declaring the node lost (section 8.3), so the old copy can no longer write to shared storage. Only then may the app start elsewhere; the node itself is not asked to destroy anything. - The node stops dialing permanently. It leaves its stateless VMs running and its fenced stateful VMs stopped, because deleting them is a data decision, not a protocol step.
- Removing a node never destroys data remotely. Purging local state — disks, rootfs images, the node’s store — is a local CLI command run on that machine by whoever has root there. A node that is gone for good therefore keeps its data until someone with access deletes it, which is the safe default.
11. Security notes
Threats and their mitigations are in ../architecture/threat-model.md; this section states only what the protocol does.
- Credential storage. The node credential and the node’s private key are stored root-only on the node. The primary stores only a hash of the credential.
- Desired-state signature. A node accepts a snapshot only if the Ed25519 signature verifies against the cluster public key it received at enrollment. A compromised intermediate or a different cluster cannot make a node start an arbitrary microVM. The signature does not protect against the primary itself: whatever the primary signs, the node runs. The signing key rotates with a
signing_key_rotatesigned by the old key (D-0050), so a new key is accepted only from the holder of the old one. - Certificate pinning. The node refuses a TLS certificate whose SHA-256 differs from the fingerprint in the join command, so a self-signed primary is as safe as a CA-issued one, and a stolen DNS name does not redirect a node.
- Provider credentials stay on the primary (D-0094). A node never receives a provider’s account credential. For a tunnel it receives only that tunnel’s run token, which cannot create tunnels, write DNS records or reach another node’s tunnel. For ACME it asks the primary to write
_acme-challenge.<hostname>TXT records, and only for theacmeroutes of its own desired state (section 5.22), so a compromised node cannot write arbitrary records in the zone or obtain certificates for hostnames it does not serve. - A compromised primary can read everything the cluster stores — state, ciphertext it can decrypt with the cluster data-encryption key, and the telemetry that nodes stream on demand — and can issue desired state, lease grants and commands to every node. Until M5b it is also the lease authority, so it can grant the same lease to two nodes and run a stateful app twice; only a storage receiver that tracks the highest fencing token on its own side limits the damage. From M5b a quorum or external arbiter takes that power away from a single primary (D-0017). It cannot read a node’s local store or its private key.
- A compromised node can lie about observed state, emit false events and, within its lease, run whatever its stored desired state allows. It cannot forge desired state for other nodes, cannot mint credentials, and cannot write to shared storage with a stale token. Its blast radius is its own workloads, which is why stateful data stays protected by the lease and the token rather than by the honesty of the node.
- Terminal and console relay (M4). The primary never touches a VM socket: it authorizes, audits and relays bytes between a client WebSocket and this session (ADR 0113, ADR 0114). The node agent, already root and already the only reader of VM sockets, is the only process that connects to a VM’s vsock socket and its serial socket; both live in the VM’s runtime directory, which only root can enter. The node never creates a
<socket>_<port>listener, so a guest cannot open a vsock connection to the host. The guest is untrusted: the node bounds every frame it reads from it and treats its output as bytes, never as protocol. Aterminalstream exists only because the primary opened it; a node never starts an exec session on its own. - Builds (M4). A build runs untrusted code: the Dockerfile and everything it fetches. It runs only inside a builder microVM, with
egress_onlynetworking and no isolation group (section 6.6), never on the host. Build secrets reach the node only inside the signed desired state, reach the builder VM only on its ephemeral source disk, which the node deletes when the build ends, and live in the guest only in memory as BuildKit secret files; the node never writes them to a build log, astream_dataor an event. The node never mounts a disk the builder VM wrote: it reads the output layout as a tar stream in user space and never touches the cache disk’s contents. The built image never leaves the node: it is imported locally and never pushed.
12. Local node store
The node keeps a SQLite file, owned by root, that holds everything it needs to run without the control plane (D-0011, D-0015 for the choice of SQLite elsewhere). The tables below are described, not specified: they are local to the node, are not part of db-schema.sql, are never replicated, and their exact columns may change without a contract change.
| Table | Holds | Why |
|---|---|---|
node_meta | node_id, cluster_id, boot_id, host_boot_id (the kernel boot id), the failed mark and the failed fences it covers (cleared by command kind fence_ack, section 5.17), agent and engine versions, the cluster public key and its key_id, a reference to the credential | Identity across restarts |
desired_state | One row per accepted rev: the full signed body, its hash, issued_at, term | The snapshot to reconcile from, and the signature to re-verify |
applied_state | Per VM the last applied spec digest, observed status, restart count, last exit code | Detects drift without the primary |
leases | Lease id, resource, mode, token, state, valid_until, duration, remaining validity on CLOCK_BOOTTIME for held and uncertain expiring leases | Survives an agent restart: a fenced lease stays fenced, a held one keeps its remaining validity within the same host boot |
event_buffer | Buffered events with their evt_ id and time, plus the dropped counter | Bounded at 10 000; delivered after reconnect |
change_log | Followed entries in seq order with prev_hash and hash, and the last acked seq | Verifies the chain and resumes follow |
quarantine | Metadata for each quarantined divergent copy: path, time, reason | Nothing divergent is deleted (D-0022) |
cmd_results | msg_ id and outcome of the last answered intrusive messages | Makes a redelivered lease_revoke idempotent |
builds (M4) | Per build: bld_ id, app, status, phase, resolved commit, image digest, failed step, the path of its log file, start and end times | Reports build progress in status_report across an agent restart; keeps the logs of the last 20 builds of each app (section 6.6) |
transcripts (M4) | Per terminal session: trm_ id, VM, file path, size, SHA-256, start and end times | Serves stream_open kind transcript and enforces retention: 30 days and 1 GiB per node, oldest removed first (audit-log-format.md section 3.3) |
Outside the SQLite file, in the VM’s runtime directory, each VM also has its serial ring: two segments of 512 KiB that keep the newest serial output across VMM restarts (engine-trait.md section 3.19). The ring is removed with the VM. Metric samples are never stored: they live in memory only (section 5.17).
The store is the reason a node can be restarted, upgraded or disconnected without stopping apps: it holds the signed intent, the applied reality, and the fencing state, and nothing in it depends on the control plane being reachable.
Quoro threat model
Status: M0 draft for review.
This document is the threat model of the self-hosted Quoro product. It describes what is worth
protecting, who can attack it, where the trust boundaries are, and which threats the design already
answers. It is written against the contracts in ../contracts/ and the architecture in
architecture.md; where the three disagree, an accepted decision wins over this
document, and this document wins over nothing.
1. Scope
In scope: the self-hosted product from M0 to M6 as defined in ../product/roadmap.md. That is one
installation of the quoro binary in the roles node, witness and edge (D-0012), the HTTP API
and panel, the MCP server, the node-agent protocol, the engine on Cloud Hypervisor and KVM
(D-0007, D-0008), ingress and TLS (D-0024), the grant and approval model (D-0030), the audit log
(D-0031), webhooks and the AI-native loop (D-0032), the lease and fencing mechanism (D-0019), and
the later replication, witness and backup paths (D-0017, D-0023).
Out of scope:
- An optional managed offering may come later (D-0003); this document does not cover it.
- Live migration, distributed storage, SDN, multi-tenancy with billing and writing our own VMM (D-0034). None of them exists in M0-M6.
- Licensing and contributor agreements (D-0004). AGPL and the CLA are legal questions, not security controls, and are not discussed here.
- The security of third-party software as a product. Where this document makes a claim about
external software that is not established in
docs/research/, the claim is labelled Hypothesis.
2. Method
The model is built in five passes:
- Assets. What has value to the operator or to an attacker, and where each asset lives.
- Actors. Who can act on the system, and how far each actor is trusted.
- Trust boundaries. Where data crosses from a less trusted to a more trusted region.
- STRIDE per component. For each component, threats are classed as Spoofing, Tampering, Repudiation, Information disclosure, Denial of service or Elevation of privilege, and each row names the mitigation and what remains.
- Mitigations and residuals. A map from decisions to the threats they answer, then the risks that stay open, then the functional checks that would show the mitigations work on the lab.
Every row in the STRIDE tables carries a reference: an accepted decision (D-xxxx), a research
finding (F-xxxx) or a research file, a recommendation marked pending maintainer decision, an open
question (Q-xxxx), a contract file under ../contracts/, or the label Hypothesis / “design choice
of this document” when the statement rests on nothing else. A row without a reference would be an
assertion, and assertions are not accepted here.
Recommendations are pending maintainer decision. Where this document relies on one it says so, for example “recommended, pending maintainer decision”. Open questions stay open: this document lists the options and never presents one as decided.
2.1 Assumptions
- The host kernel and KVM are trusted. Quoro runs on a host whose kernel the operator controls (D-0009). A kernel compromise is outside this model: the engine cannot defend against the layer it runs on.
- Cloud Hypervisor is the VM isolation boundary (D-0007, D-0008). One microVM per app, no Docker daemon on nodes (D-0008). Everything inside a guest is untrusted; the boundary is the hypervisor and the kernel beneath it.
- An operator with root on the primary can do anything locally. They can read the database, rewrite it, stop the process and edit the binary. This is not a flaw to be fixed; it is the reason the audit log has an off-site copy out of reach of whoever operates the server (D-0031) and why the chain only claims tamper evidence, not tamper prevention.
- The network between nodes is hostile. Nodes talk across the public internet, often from behind NAT (D-0010, D-0011). Every node connection is treated as if it were intercepted.
- Agents are untrusted by default. An agent may be prompt-injected by anything it reads: app logs, a webhook payload, an issue title. It acts only inside a grant (D-0029, D-0030).
- The operator’s browser is trusted until it is not. A session cookie stolen from a compromised workstation is treated as the human, because the server has nothing else to go on.
3. Assets
| Asset | Where it lives | Why it matters |
|---|---|---|
| Control-plane state and the change log | SQLite on the primary, replicated to replica nodes (D-0012, D-0013, ../contracts/db-schema.sql) | It is the description of everything that runs. Changing it changes the infrastructure. |
| Audit log | audit_log table on the primary, chained (D-0031, ../contracts/audit-log-format.md) | It is the only record of who did what. Destroying it destroys accountability. |
| Cluster signing key (Ed25519) | Primary, used to sign desired state | Whoever holds it can make any node run any workload. It is the root of node trust. |
| Node credentials | Hashed on the primary, root-only file on each node | A credential authorizes a node-agent session and nothing else, but that session carries desired state and app secrets for that node. |
| Human passwords, sessions and API tokens | Argon2id hashes and token hashes on the primary; cookie in the browser (D-0030, ../contracts/permission-model.md) | A human identity carries a role bundle, which is most of the cluster. |
| Agent keys and access tokens | Agent keys hashed on the primary; access tokens short-lived (D-0029, D-0030) | A stolen access token impersonates the agent for up to five minutes, within whatever grants are active at each call; revoking the grant stops it on the next call (D-0030). |
| Grants and approvals | grants, approvals tables | They are the boundary between “an agent can ask” and “an agent can act”. |
| App secrets (environment values) | app_env, encrypted at rest with the cluster key; decrypted per node for placed apps | They are database passwords and API keys of the operator’s customers. |
| TLS private keys and the ACME account key | Node holding the edge or node role, root-only | A stolen key allows impersonation of the operator’s domains. |
| Cloudflare and other provider API tokens | Provider configuration on the primary, encrypted at rest | They control DNS, load balancing and tunnels for every domain in the installation (D-0025). |
| Webhook secrets | webhooks, encrypted, write-only (D-0032) | They sign deliveries and prove that a webhook came from Quoro. |
| App data on volumes and in backups | Node disks, chunk store, S3-compatible bucket (D-0023) | It is the operator’s actual data, and its loss is not recoverable from the control plane. |
| App logs and metrics | Node-local, queried on demand (D-0014) | They describe app behaviour and often contain secrets in URLs, headers or stack traces. |
| Release signing key | Release pipeline (D-0027) | A stolen key signs a malicious binary that the curl | sh installer will run as root. |
| Availability of running apps | Nodes (D-0011) | The product promise is that apps keep serving when the panel does not. |
| The witness and the lease arbiter | Witness daemon or external arbiter (D-0017) | Whoever controls the arbiter decides who is primary. |
4. Actors
| Actor | Trust level | Notes |
|---|---|---|
| Owner | Fully trusted human | Every scope, including user:write (../contracts/README.md). Can still be phished. |
| Operator | Trusted human | Every scope except user:write. Can enroll nodes (node:enroll) and decide approvals (approval:decide). |
| Viewer | Trusted human, read-mostly | Every :read scope plus app:logs. Cannot change anything, but can read app logs and audit records. |
| Agent | Untrusted by default | May be prompt-injected; acts only within a grant (D-0029, D-0030). Records who, on behalf of whom and why. |
| External agent runner woken by a webhook | Untrusted | Receives event payloads and incident context; its only authority is the incident grant (D-0032). |
| Node | Trusted for its own workloads only | May lie about itself, may be compromised. Cannot write control-plane state or read other nodes’ secrets. |
| Primary | Trusted while it is the primary | Full control of state. A compromised primary is game over for confidentiality and integrity (see T-NODE-14). |
| Replica node | Trusted for its own workloads; holds a copy of the log | Sees control-plane state, including encrypted secrets. Cannot write (D-0012). |
| Witness | Trusted to vote, stores no data | Its only power is breaking ties (D-0017). A lying witness can stall an election but cannot read data. |
| Edge | Trusted, sees all ingress traffic | Terminates TLS, routes by hostname, can read plaintext HTTP (D-0018, D-0024). |
| Providers (Cloudflare DNS/LB/Tunnel, S3-compatible storage) | Trusted for availability, not for confidentiality of plaintext | They see traffic metadata, tunnel plaintext at the edge, and stored ciphertext (D-0025). |
| App workloads inside microVMs | Untrusted code | Arbitrary code supplied by the operator or by an image publisher. Must not reach the host or other apps. |
| Internet clients | Untrusted | Send arbitrary requests to app domains and to the API. |
| Upstream image publishers and the catalog | Trusted only through signatures and digests | The catalog pins images with verifiable signatures (D-0026). |
| Contributors and the release pipeline | Trusted through review and signing | A merged change ships to every installation (D-0004, D-0027). |
| A network attacker on the WAN between nodes | Hostile | Can drop, delay, replay and modify traffic (D-0011). |
5. Trust boundaries
internet clients
|
v
+------------------------+ boundary 1: unauthenticated traffic
| edge / ingress | terminates TLS, routes by hostname
| (proxy or Tunnel) |
+-----------+------------+
| boundary 2: app traffic into a guest
v
+------------------------+
| microVM (guest) | <-- untrusted code, one per app
+-----------+------------+
| boundary 3: virtio devices, console socket, TAP
v
+------------------------+ boundary 4: host kernel / VMM
| node agent + engine | root on the node
+-----------+------------+
| boundary 5: dial-out WebSocket over TLS, certificate pinned
v
+------------------------+
| primary (API, panel, |
| MCP, audit, state) |
+------------------------+
browser / panel --boundary 6--> API (session cookie)
agent / MCP client --boundary 6--> API / MCP (bearer access token)
primary --boundary 7--> webhook receiver (HMAC-signed HTTP POST)
primary --boundary 8--> providers (Cloudflare, S3-compatible storage)
node <--boundary 9--> node (mesh, from M5a)
release pipeline --boundary 10--> operator host (curl | sh)
| # | Boundary | What crosses it | Control |
|---|---|---|---|
| 1 | internet -> edge | Arbitrary HTTP, TLS handshakes | TLS termination, hostname routing, per-app isolation (D-0024) |
| 2 | edge -> guest | HTTP requests for one app | Routing table derived from desired state, one microVM per app (D-0008) |
| 3 | guest <-> host | virtio-blk, virtio-net, virtio-console, serial console, TAP | Cloud Hypervisor device model, seccomp, one process per VM (docs/research/cloud-hypervisor.md) |
| 4 | node agent <-> engine | Engine trait calls, Cloud Hypervisor API socket | Local UNIX socket, node root only, ../contracts/engine-trait.md |
| 5 | node -> primary | Node-agent session, desired state, events, leases | WebSocket over TLS with pinned certificate, Ed25519-signed desired state, ../contracts/node-agent-protocol.md |
| 6 | human/agent -> API | Bearer token or session cookie, Quoro-Justification | Scope check, grant check, audit write in the same transaction (../contracts/permission-model.md) |
| 7 | primary -> webhook receiver | Event payload and Quoro-Signature | HMAC-SHA256 over <t>.<raw body>, 5 minute window (D-0032, ../contracts/README.md) |
| 8 | Quoro -> providers | DNS records, LB pools, tunnel config, objects in a bucket | Provider API tokens scoped per provider; separate credentials per purpose (D-0025) |
| 9 | node <-> node | Replication traffic, chunk shipping (from M5a) | Mesh: kernel WireGuard over netlink, per-node keys and peers in desired state (D-0063), fencing tokens on every write (D-0019) |
| 10 | release pipeline -> host | A binary executed as root by the installer | Signature check plus reproducible build (D-0027) |
Boundary 5 is the most important one in the product: it is the only path by which instructions reach a machine that runs workloads, and it crosses the public internet (D-0011). Boundaries 3 and 4 are where untrusted code meets the host. Boundary 6 is where an untrusted agent meets authority.
6. STRIDE analysis
The tables below use threat ids T-<AREA>-<nn>. The STRIDE column holds one letter: S spoofing,
T tampering, R repudiation, I information disclosure, D denial of service, E elevation of privilege.
(a) HTTP API and panel
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-API-01 | S | A stolen bearer token is replayed by an attacker | Tokens are stored only as hashes, carry an expiry, and a human token can be revoked; agent access tokens live 5 minutes | D-0030, ../contracts/permission-model.md | Theft from a compromised workstation is indistinguishable from the human until revocation |
| T-API-02 | S | A stolen panel session cookie is used from another machine | Session cookie is an absolute 24 h session, stored hashed, bound to the user | ../contracts/permission-model.md | No device binding; a copied cookie works until it expires |
| T-API-03 | T | A write sent to a replica node silently forks state | Only the primary writes; a write that reaches a replica that cannot forward answers not_primary (503) and never applies locally | D-0012, ../contracts/openapi.yaml | A replica that wrongly believes it is primary would fork state; prevented by the promotion rules in (m) |
| T-API-04 | T | A POST is retried by a client or a proxy and creates a duplicate resource | Idempotency-Key is required on every creating POST | ../contracts/README.md | Keys are scoped per identity and expire; a retry after expiry duplicates |
| T-API-05 | T | Cross-site request forgery against the panel’s cookie-authenticated endpoints | Design choice of this document: state-changing panel requests require a non-simple header and the cookie is SameSite=Lax, so a cross-origin form post cannot carry them | design choice of this document | A same-site script injection still succeeds; see T-API-09 |
| T-API-06 | R | A human denies having made a change | Every mutating call by any identity is audited with actor, channel panel or api, params and result | D-0031, ../contracts/audit-log-format.md | Human reads are deliberately not recorded, so “who looked at this” is unanswerable |
| T-API-07 | I | App environment values leak through a read endpoint | app_env.value is write-only: the API returns names only; app:read never returns a value | ../contracts/README.md scope app:read, ../contracts/openapi.yaml | A value still leaks through app logs if the app prints it (T-LOG-01) |
| T-API-08 | D | An unauthenticated client floods the API or an event stream | Authentication before work, per-identity rate limit answering rate_limited | ../contracts/permission-model.md (rate limits are implementation defaults, not contract) | No global admission control in M0; a single identity can still consume the process |
| T-API-09 | E | A viewer escalates to owner through the panel | Human roles are fixed scope bundles; user:write is owner-only; no endpoint grants a role to oneself | ../contracts/README.md roles, D-0030 | A viewer who obtains an owner’s token is an owner (T-API-01) |
| T-API-10 | I | A forbidden response leaks which resources exist | Refusals use a fixed reason set and do not echo resource contents | ../contracts/README.md forbidden reasons | not_found versus forbidden still reveals existence of ids the caller guessed |
| T-API-11 | S | Unclaimed first-run setup: between install and the first POST /api/v1/setup, anyone who reaches the listener can claim owner | The installer prints a one-time setup token, required in the Quoro-Setup-Token header; it is single use and expires after one hour, the primary stores only its hash, and quoro setup-token refuses once an owner exists (D-0054) | D-0054, architecture section 2, host layout | The token is printed on the host console and in the install log, so whoever reads that output within the hour can claim owner |
(b) MCP server and agents
The MCP server is not a separate security path. Each tool maps one-to-one onto a service operation
and needs the same scope as the matching endpoint (D-0031, ../contracts/README.md).
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-MCP-01 | E | Prompt injection in an app log, a webhook payload or an issue title makes the agent call a destructive tool | Destructive scopes are refused with approval_required unless a matching approval grant exists; the grant is bound to the target and params | D-0030, ../contracts/permission-model.md | Injection can still spend the agent’s non-destructive scopes, for example restart loops |
| T-MCP-02 | E | An agent is created or edited with an over-broad grant | grant:write and agent:write are human-only, so an agent can never widen itself; the wildcard app:* is not allowed in grants | ../contracts/README.md scopes and flags, D-0030 | A careless human can create a broad grant; the panel must show the selector and TTL plainly |
| T-MCP-03 | S | An agent key is stolen from the runner’s environment | The agent key authorizes nothing by itself; it only mints access tokens; disabling the agent invalidates tokens immediately; secrets are shown once and stored hashed | D-0029, ../contracts/permission-model.md | Once minted, an access token is usable by whoever holds it for its lifetime |
| T-MCP-04 | S | An access token is replayed within its lifetime | 5 minute lifetime, opaque 32 random bytes, stored as SHA-256, minted per call sequence | ../contracts/permission-model.md | No proof-of-possession; a token captured in transit or in a log is usable for up to 5 minutes |
| T-MCP-05 | E | An agent approves its own approval request | approval:decide is human-only, so the attempt is refused with human_only and audited | D-0030, ../contracts/README.md | An agent can still social-engineer the human approver (T-GRT-06) |
| T-MCP-06 | E | A webhook-woken runner is used as a confused deputy to act outside the incident | The incident grant carries only the webhook’s wake.grant_scopes, is limited to the incident subject, and is revoked when the incident resolves | D-0032, ../contracts/permission-model.md | The subject selector is coarse: an incident on one app allows all listed scopes on that app |
| T-MCP-07 | R | An agent’s stated justification is a plausible lie | The justification is recorded, not trusted: it is evidence for a human reader, and the decision to act was the grant’s, not the text’s | D-0031, header Quoro-Justification | Nothing verifies the text; a model can write a convincing wrong reason |
| T-MCP-08 | I | The MCP tools expose more data than the equivalent endpoint | Each tool calls the same service operation and needs the same scope; there is no MCP-only query | D-0031, ../contracts/README.md MCP tools table | get_app_logs inherits the log risk (T-LOG-01) |
| T-MCP-09 | S | The MCP transport accepts a caller Quoro did not authenticate | Quoro validates the bearer access token itself before dispatching a tool; rmcp documents no inbound authorization middleware, so no such middleware is relied on (Hypothesis) | F-0041, docs/research/rust-api-and-mcp-tooling.md | If rmcp later adds server-side auth, two layers could disagree; the pinned version (F-0041) limits the surprise |
| T-MCP-10 | I | An agent reads app logs and extracts secrets to another system | app:logs is a scope and can be withheld; every agent call, including reads, is audited | D-0031, ../contracts/audit-log-format.md | An agent with app:logs and network egress can exfiltrate what it reads; that is the grant owner’s choice |
| T-MCP-11 | D | An agent in a restart loop harms availability within its scopes | Grants can carry max_uses; reads do not consume uses, mutating calls do | ../contracts/permission-model.md, D-0030 | A grant with no max_uses and a long TTL permits unbounded restarts |
(c) Grants and approvals
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-GRT-01 | E | Privilege escalation by creating a grant for oneself | grant:write is human-only; an agent holding it is refused with human_only; incident and approval grants are created by system, not by the agent | D-0030, ../contracts/README.md scopes, ../contracts/permission-model.md | Any human with grant:write can issue anything to any agent |
| T-GRT-02 | E | An approval grant is reused or widened to another target | Approval grants carry one scope, selector = that target only, max_uses 1 and a 10 minute TTL; the call must match params_hash | D-0030, ../contracts/permission-model.md | A second identical destructive call needs a second approval; that is intentional friction |
| T-GRT-03 | E | A grant lives forever because its TTL was omitted | expires_at is mandatory on every grant; there is no non-expiring grant | D-0030, ../contracts/permission-model.md | Expiry depends on the primary’s clock; a clock jump shortens or lengthens a TTL (T-GRT-07) |
| T-GRT-04 | E | A revoked grant keeps working | Revocation is checked on every request in the authorization path, so the next call after revocation is refused with grant_revoked | D-0030, ../contracts/permission-model.md | A call already executing when revocation lands completes; the audit record shows both |
| T-GRT-05 | E | An incident grant is widened beyond the incident | Incident grants may carry non-destructive scopes only, validated when the webhook is saved, and the selector is the incident subject only | D-0032, ../contracts/permission-model.md | A webhook created with a broad grant_scopes list gives every matching incident that list |
| T-GRT-06 | S | Approval fatigue: a human approves a harmful request after many plausible ones | The panel must show scope, target, params_hash and justification together; the decision is audited with the deciding user | D-0030, ../contracts/audit-log-format.md | Social engineering of the approver is not technically preventable |
| T-GRT-07 | T | Clock skew makes an expired grant usable | Expiry is evaluated against the primary’s clock only; nodes do not evaluate grants | D-0030, ../contracts/permission-model.md | A misconfigured or manipulated primary clock extends every TTL; the audit timestamp is affected too |
| T-GRT-08 | E | Time-of-check to time-of-use between approval and execution | The approval is bound to target and params_hash; a call with different parameters does not match the grant and is refused | D-0030, ../contracts/permission-model.md | A matching call can still be harmful if the human misread the parameters |
| T-GRT-09 | E | An agent collects several narrow grants and combines them into a broad capability | Every request needs one matching grant for its scope and selector; uses are counted per grant; the audit names the grant used | ../contracts/permission-model.md, D-0031 | Nothing prevents a human from issuing several grants to the same agent; combination is a human decision |
| T-GRT-10 | R | A refusal happens but is not recorded, so the attempt is invisible | Every refusal is audited with result refused and result_detail holding the forbidden reason | ../contracts/audit-log-format.md, D-0031 | A refusal caused by a crash before the audit write is an error at best, or missing |
(d) Audit log
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-AUD-01 | T | An operator edits one audit record to hide an action | Each record carries hash over its canonical JSON and prev_hash of its predecessor; verification reports hash_mismatch at that seq and emits audit.chain_broken | D-0031, ../contracts/audit-log-format.md | The edit is evident, not prevented; if nobody verifies, nobody notices |
| T-AUD-02 | T | An operator truncates the tail of the log | Segments and head objects are copied off-site, so a truncated local tail disagrees with the last exported last_seq and head_hash | D-0031, ../contracts/audit-log-format.md | Until an off-site copy exists there is none; the earliest is M5a (section 8.1), so truncation is undetectable before that |
| T-AUD-03 | T | An operator with root rewrites the whole log and its hashes | The off-site copy is written once with an object-lock retention in compliance mode; credentials are limited to put and cannot delete or overwrite a locked object | D-0031, ../contracts/audit-log-format.md | Object-lock support is not portable across S3-compatible providers; the adapter probes it and must report “not locked” when the probe fails (F-0038) |
| T-AUD-04 | R | An agent claims a different actor made the change | Every record carries actor_type, actor_id, agent_model, on_behalf_of, channel, request_id and grant_id | D-0029, D-0031, ../contracts/audit-log-format.md | The fields are filled by Quoro, not by the caller; a compromised primary can write anything (T-NODE-14) |
| T-AUD-05 | I | Secrets appear in the recorded params | A fixed redaction list (value, values, secret, password, token, key and every env value) replaces them with [redacted] | ../contracts/audit-log-format.md | A secret in a key not on the list, for example a URL with a query token, is recorded verbatim |
| T-AUD-06 | T | An action exists without its audit record, or the reverse | The record is appended in the same SQLite transaction as the change it describes, and change_seq links it to the change-log entry | D-0031, D-0013, ../contracts/audit-log-format.md | A crash between the transaction commit and the response is still one consistent state |
| T-AUD-07 | D | Audit write failure blocks all work, or work proceeds unaudited | The transaction couples them, so a failed audit write fails the action | ../contracts/audit-log-format.md | A full disk on the primary stops writes; that is availability, not integrity (T-AVL-07) |
| T-AUD-08 | T | An attacker floods the log to bury an entry or to exhaust storage | Rate limits per identity; agents are rate limited like humans | ../contracts/permission-model.md | No archival policy in M0-M6; growth is unbounded by design (retention would need its own decision) |
| T-AUD-09 | T | Deleting the off-site bucket credential’s target objects | The audit-copy credential is put-only, separate from the backup credential, and locked objects cannot be overwritten | ../contracts/audit-log-format.md, D-0025 | Bucket policy is provider configuration; a misconfigured bucket may accept deletes from another credential |
| T-AUD-10 | R | Verification itself is claimed but never run | POST /api/v1/audit/verify returns {ok, checked, first_seq, last_seq, head_hash, broken_at_seq?, reason?} and the verification run is audited | ../contracts/audit-log-format.md | Nothing forces a schedule; an unverified chain is only as good as the next run |
(e) Node-agent protocol
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-NODE-01 | S | A rogue machine enrolls as a node | Enrollment needs a one-time join token with a 1 h default expiry, created under node:enroll, which is human-only; the token is stored as a hash and marked used | D-0010, ../contracts/README.md scope flags, ../contracts/node-agent-protocol.md | A leaked unused token can be redeemed once within the hour |
| T-NODE-02 | S | A man-in-the-middle intercepts the node session | The join command carries the SHA-256 fingerprint of the primary’s TLS certificate and the node pins it, so a self-signed certificate works and a substituted one does not | ../contracts/node-agent-protocol.md, boundary 5 | Pinning survives only if the fingerprint was transmitted out of band, which is the operator’s job |
| T-NODE-03 | S | An attacker impersonates an existing node | Each session needs the node credential, 32 random bytes, stored hashed on the primary and root-only on the node | ../contracts/node-agent-protocol.md | A stolen node credential plus the pinned host allows impersonation; only one session per node exists at a time |
| T-NODE-04 | T | A forged desired_state makes a node run attacker workloads | Every desired_state is signed with the cluster Ed25519 key over the canonical JSON of its body; the node verifies before persisting | ../contracts/node-agent-protocol.md, boundary 5 | The node trusts the cluster public key it received at enrollment; a wrong key at enrollment poisons everything after it |
| T-NODE-05 | T | Replay of an old, validly signed desired state | rev increases monotonically; the node rejects an older rev and acts on the newest verified stored state | ../contracts/node-agent-protocol.md, D-0011 | Two states with the same rev cannot exist unless the primary is compromised |
| T-NODE-06 | T | Replay of an old log segment to roll state back | Each entry carries prev_hash/hash; the node verifies on receipt and on hello, and a divergence quarantines its copy instead of applying it | D-0013, D-0022, ../contracts/node-agent-protocol.md | A node that never reconnects keeps a stale copy; that is expected, not a compromise |
| T-NODE-07 | I | A compromised node reads secrets of apps that are not placed on it | env values are decrypted per node and sent only for apps in that node’s desired_state | ../contracts/node-agent-protocol.md | A node can read everything for apps that are placed on it; that is unavoidable |
| T-NODE-08 | I | App secrets persist in plaintext on the node | The node stores env encrypted with a node-local key; the session carries them only over the pinned TLS connection | ../contracts/node-agent-protocol.md | Root on the node can read the node-local key; see assumption 2.1 |
| T-NODE-09 | T | A compromised node lies about its status, health or events | Nothing can stop a node lying about itself | ../contracts/node-agent-protocol.md | A lying node can trigger false app.down, false incidents and false agent wakes (T-WHK-05); control-plane state is not writable by a node |
| T-NODE-10 | T | A compromised node injects events to open incidents and wake agents | The primary assigns seq and dedups by id, records source, and the incident grant is limited to the subject with non-destructive scopes | D-0032, ../contracts/node-agent-protocol.md, ../contracts/permission-model.md | Noise and cost from false wakes; an incident on a node cannot grant destructive power |
| T-NODE-11 | D | A node is flooded with frames or oversized messages | One JSON message per text frame, 1 MiB maximum, heartbeat every 5 s and disconnect after 15 s without a frame | ../contracts/node-agent-protocol.md | A slow-loris style session can hold the single session slot until it times out |
| T-NODE-12 | E | A node sends a message type or version the primary does not expect | Unknown fields are ignored, an unknown type is answered with a non-fatal error, and a major version mismatch is a fatal unsupported_version | ../contracts/node-agent-protocol.md | A downgrade attack has nothing to gain: the protocol has one major version |
| T-NODE-13 | T | A removed node keeps receiving desired state | DELETE /api/v1/nodes/{node_id} ends the session with a fatal node_removed and the node stops dialing | ../contracts/node-agent-protocol.md | Its VMs are left as they are; removal never destroys data remotely, by design |
| T-NODE-14 | E | A compromised primary takes over the cluster | Nothing in the protocol stops it: it holds the cluster signing key, the state and the audit log | D-0012, D-0031 | Full control. Signed desired state does not help, because the primary is the signer. This is why the audit copy must be out of reach of whoever operates the server (D-0031) and why a promoted replica must obtain the key through the custody path decided with manual promotion in M5b (D-0067) |
(f) Leases and fencing
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-LEASE-01 | T | Split brain: two nodes run the same stateful app and diverge | A stateful VM starts only while its node holds the app’s lease in state held with the same fencing_token as the VM spec; every write to shared storage carries the token and receivers reject a lower one | D-0019, ../contracts/node-agent-protocol.md, docs/research/r2-conditional-writes.md | Fencing depends on every receiver checking the token; a new shared resource added later must implement it |
| T-LEASE-02 | T | An old lease holder keeps writing after losing the lease | The lease is expiring mode with a monotonic duration, and the node self-fences at valid_until; a failed forced stop never reports fenced (D-0074) | D-0019, ../contracts/node-agent-protocol.md | A paused process can act between valid_until and the actual stop; the fencing token is what protects the data, not the timer |
| T-LEASE-03 | D | The arbiter is unreachable and nodes treat it as lease loss, stopping apps needlessly | “Arbiter unreachable” maps to uncertain: keep running, start nothing new stateful, and fence only at valid_until; local-mode leases never expire because the control plane is gone | D-0047, D-0011, docs/research/r2-conditional-writes.md | A long arbiter outage (a 59 minute R2 outage is recorded) still fences every expiring lease eventually |
| T-LEASE-04 | T | Clock drift between nodes makes a lease look valid after it expired | Durations are computed on the node’s monotonic clock, with margin = max(1000 ms, duration_ms / 10); the authority only regrants after duration_ms + 2 * margin | ../contracts/node-agent-protocol.md; the drift bound itself is Hypothesis | The margin is a guess until measured on the lab; a large enough drift or a suspended VM breaks the arithmetic |
| T-LEASE-05 | T | R2 answers 404 for a read while a conditional write is in flight, so a node believes it holds a lease it lost | Ambiguous outcomes are resolved with If-None-Match: * retries, and the fencing token, not the read result, decides what a shared resource accepts | docs/research/r2-conditional-writes.md, D-0019 | The negative-read window is a documented trap; the retry rule must be implemented, not assumed |
| T-LEASE-06 | T | A Durable Object restarts mid-write and comes back with pre-write state | An in-flight storage-touching request is terminated with an error, so “no definitive answer” means “lease not held”; the token still protects the data | docs/research/durable-objects.md, D-0017, Q-0012 (open) | Alarms are at-least-once, so expiry transitions must be idempotent; that is code-level care, not a protocol guarantee |
| T-LEASE-07 | D | Denial of service on the arbiter stops stateful apps | Accepted: an arbiter outage fences expiring leases, which is availability loss by design rather than corruption | D-0019, docs/research/r2-conditional-writes.md | A provider-wide outage becomes an app outage; the local lease mode limits the blast radius to warm/hot apps |
| T-LEASE-08 | T | A node with a valid credential claims a lease it never had | Lease authority is the primary in M0-M5a and the primary backed by a quorum from M5b; a node cannot grant itself a lease | D-0017, D-0019, ../contracts/node-agent-protocol.md | A compromised primary can grant a lease to any node (T-NODE-14) |
| T-LEASE-09 | T | A local lease never expires, so a lost node comes back and starts its stateful app beside the new holder | A local lease ends only by lease_revoke or when an operator declares the node lost, which increments the fencing token so the old holder’s token is stale; a cold restore elsewhere needs the manual fence acknowledgement first (D-0047) | D-0047, ../contracts/node-agent-protocol.md | The manual “declared lost” step is the weak link: forget it and two copies can start |
| T-LEASE-10 | D | Fencing takes longer than the margin and the node is still stopping when another starts | The node must finish fencing before valid_until + margin, and the authority waits duration_ms + 2 * margin before regranting | ../contracts/node-agent-protocol.md | A guest that ignores SIGTERM delays a force stop; the formula assumes the force stop works |
(g) Engine and guest isolation
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-VM-01 | E | A guest escapes to the host through a virtio device or the VMM | One Cloud Hypervisor process per VM with a small device model (virtio-blk, virtio-net, virtio-console), a memory-safe VMM language, seccomp filters enabled by default and an optional Landlock sandbox | D-0007, D-0008, docs/research/cloud-hypervisor.md | A hypervisor or kernel bug is a host compromise; the device model is minimal but not empty. Sandboxing beyond seccomp/Landlock is Hypothesis |
| T-VM-02 | E | A guest attacks the host kernel through KVM | The kernel is trusted (assumption 2.1) and the guest runs unprivileged | D-0009 | Kernel vulnerabilities are out of scope; patch cadence is the operator’s |
| T-VM-03 | D | One app starves another (noisy neighbour) | Each microVM gets its own vCPU and memory allocation from resources, and virtio I/O can be throttled with token buckets | ../contracts/engine-trait.md, docs/research/cloud-hypervisor.md | CPU shares are not a hard cap on every host; a sustained load still affects neighbours |
| T-VM-04 | T | One app reaches another app’s network | Each app has its own TAP on the node bridge and a private address; isolation between projects is enforced with nftables rules generated with the network | D-0008, ../contracts/engine-trait.md, design choice of this document for the nftables default-deny | A misgenerated rule set can open a path; rules must be derived from desired state and tested |
| T-VM-05 | T | A malicious image compromises the node during preparation | Images are referenced with a digest and verified before conversion; the OCI-to-ext4 pipeline resolves layer paths inside the rootfs with openat2 and RESOLVE_IN_ROOT (D-0061); the catalog pins images with verifiable signatures — mitigation pending: the catalog format and signature scheme are decided at M4 planning (D-0070) | D-0026, D-0061, ../contracts/engine-trait.md | Conversion runs as root on the node; a parser bug in the image path is host code, which RESOLVE_IN_ROOT narrows but does not remove |
| T-VM-06 | I | A volume snapshot leaks guest secrets | A Cloud Hypervisor snapshot contains full guest RAM and no disk contents, so RAM must be treated as secret-bearing; the chunk engine encrypts chunks | docs/research/cloud-hypervisor.md (F-0035 finding), D-0023 | Snapshot contents include whatever the app had in memory, including keys |
| T-VM-07 | E | The guest’s init is subverted to escape the microVM | The minimal init is PID 1 inside the guest and trusts nothing from the guest application; the app runs as PID 2 under the configured user | ../contracts/engine-trait.md, D-0008 | The init is Quoro code inside a trusted boundary; a bug there is a guest-internal bug, not a host escape |
| T-VM-08 | D | A guest exhausts host disk with its rootfs or logs | Per-VM disk size from the VM spec, and node.disk_low is emitted when free disk falls below the node’s threshold | ../contracts/README.md event types, ../contracts/node-agent-protocol.md | A full disk affects every app on that node before anyone reacts |
| T-VM-09 | E | Docker on the node gives a container a path to the host | There is no Docker daemon on nodes; apps are microVMs | D-0008, D-0034 | Builds in M4 run inside a builder microVM, not on the host (Hypothesis for the isolation of that builder until specified) |
(h) Ingress and TLS
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-ING-01 | S | Someone attaches a hostname they do not control and obtains a certificate for it | A domain belongs to one app and is attached by a holder of app:update; ACME still requires control of the name to issue | D-0024, ../contracts/README.md scopes | An operator can attach a hostname that ACME later validates through a DNS record they do control; the certificate then covers a name they own, which is not an attack |
| T-ING-02 | I | The ACME account key or a domain private key is stolen from the node | Keys are root-only on the node that terminates TLS | D-0024, assumption 2.1 | Root on that node reads them; that node is trusted |
| T-ING-03 | S | A stolen Cloudflare Tunnel token lets an attacker join the tunnel | The token is stored encrypted on the primary; a tunnel token authorizes a connector, not the API | D-0024, D-0025 | A rogue connector can receive and answer traffic for the tunnel’s published hostnames |
| T-ING-04 | T | Health checks are spoofed so traffic goes to a dead node | Over a tunnel, an HTTPS monitor with Host = the app hostname reaches the app through the connector; the HTTP_STATUS route only proves connector liveness and must not be used as app health | F-0046, docs/research/cloudflare-lb-and-tunnel.md, D-0024 | Replicas of one tunnel UUID count as one endpoint, so a monitor can pass while another replica’s local app is down |
| T-ING-05 | I | Plaintext leaves the app and crosses an untrusted network | TLS terminates at the edge or at Cloudflare; after that the hop to the guest is on the node bridge | D-0024 | Design choice of this document: the node-local hop is treated as trusted; if it is not, per-app TLS inside the guest is the operator’s option |
| T-ING-06 | T | Host header confusion routes a request to the wrong app | Routing is by hostname from the verified route list in desired state, never by a client-supplied path or header beyond the hostname | ../contracts/node-agent-protocol.md route shape, D-0024 | A shared hostname between apps cannot happen because a domain belongs to one app |
| T-ING-07 | D | A single edge is a traffic single point of failure | Documented; mitigations are Cloudflare as the traffic edge with the small host as witness only, or two edges with DNS failover | D-0018 | A self-hosted single edge loses all app traffic when it dies, while the apps keep running |
| T-ING-08 | T | An expired or wrong certificate serves traffic | cert_status is modelled (pending, issued, renewing, failed) and certificate.expiring warns before expiry | ../contracts/README.md, D-0024 | Renewal failure is visible but the app is unreachable until it is fixed |
| T-ING-09 | S | Domain takeover after an app is deleted | Domains belong to an app and are removed with it; certificate material stays with the node until it is cleaned | ../contracts/README.md relationships, D-0024 | A stale DNS record pointing at an old IP is the operator’s to remove |
(i) Providers and secrets
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-PRV-01 | E | A Cloudflare API token with account-wide scope is used to change unrelated DNS | Providers sit behind interfaces with per-provider credentials; the token should be scoped to the zone and to the products used | D-0025, design choice of this document for the least-privilege default | A user who pastes a global token gets global authority; Quoro cannot narrow it after the fact |
| T-PRV-02 | I | Backup credentials can also delete the audit copy | Backup and audit-copy credentials are separate: the audit-copy credential is its own put-only object_store provider entry (D-0051) | D-0051, ../contracts/audit-log-format.md, D-0025 | The separation is a deployment property; both live on the same primary |
| T-PRV-03 | T | An S3-compatible provider silently ignores conditional writes, giving two nodes the same lease | The provider adapter probes the capability at setup and refuses to enable the arbiter if the probe fails | F-0038, docs/research/r2-conditional-writes.md, D-0017 | Community MinIO and Backblaze B2 status is Hypothesis; the probe is the only defence |
| T-PRV-04 | I | Stored backups are readable by the provider | Chunk-engine chunks are encrypted client-side; SQLite and template backups go through that same chunk engine from consistent snapshots, so nothing is stored unencrypted (D-0069) | D-0023, D-0069, F-0040 | Litestream with SSE-C may be added later as an optional backend (D-0069); such a backend would rely on the provider for encryption, with a Quoro-held key sent per request |
| T-PRV-05 | D | A provider rate limit (R2 allows 1 write per second per key) stalls the off-site copy | Segment keys are unique, so the per-key limit does not bind the audit copy; retries back off | F-0037, ../contracts/audit-log-format.md | A provider-wide outage pauses the copy; the local chain continues |
| T-PRV-06 | I | Secrets in the change log are readable to a replica node | Replicated secret columns are encrypted with the cluster key, and a replica holds ciphertext | D-0013, ../contracts/node-agent-protocol.md | How a promoted replica obtains the cluster key is decided with manual promotion in M5b (D-0067); custody determines who can read history |
| T-PRV-07 | T | A provider changes a DNS record outside Quoro | Desired state is the source of truth and reconciliation rewrites drift | D-0011, D-0025 | Drift between reconciliations is unobserved unless a route check reports it |
| T-PRV-08 | I | Provider secrets read back through the API or the audit log | Provider secrets are write-only, encrypted with the data-encryption key in secret_enc, never returned by a read call, redacted from audit parameters, and reachable only under the human-only provider:manage scope | D-0051, D-0050 | A compromised primary holding the DEK can decrypt them |
(j) Webhooks
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-WHK-01 | S | A forged webhook delivery wakes an agent or triggers a receiver action | Quoro-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the webhook secret>; receivers reject a signature older than 5 minutes | D-0032, ../contracts/README.md request headers | The receiver must implement verification; Quoro cannot enforce it on a third party |
| T-WHK-02 | S | A captured delivery is replayed inside the 5 minute window | The timestamp window bounds it; deliveries also carry Quoro-Event-Id and Quoro-Delivery-Id for the receiver to dedupe | ../contracts/README.md | A receiver that does not dedupe can be replayed within the window |
| T-WHK-03 | I | The webhook secret leaks | The secret is write-only, shown once, and stored encrypted | ../contracts/README.md entity fields, D-0031 | A secret in a delivery log on the receiver is the receiver’s problem |
| T-WHK-04 | E | Server-side request forgery: a webhook URL points at an internal address | URLs must be https, except http://localhost for testing; design choice of this document: private and link-local ranges are refused unless the operator opts in | ../contracts/README.md entity fields, design choice of this document | DNS rebinding against an allowed host is not covered by a URL check |
| T-WHK-05 | T | A compromised node manufactures events that wake agents and spend money | Events are recorded with source, incidents are subject-scoped, and incident grants are non-destructive | D-0032, ../contracts/permission-model.md, T-NODE-09 | False incidents produce false work; cost, not compromise |
| T-WHK-06 | D | Retry storms from a failing receiver amplify load | Exponential backoff from 10 s doubling to 1 h, at most 10 attempts, delivery records carry status, response_code, error | ../contracts/README.md webhook delivery fields | A receiver that returns 500 quickly still costs 10 attempts per event |
| T-WHK-07 | E | One event wakes several runners that act on the same incident concurrently | Each gets its own incident grant with the same selector; the audit shows each call | D-0030, D-0032 | Two agents can interleave restarts; the grants do not serialize |
(k) Telemetry
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-LOG-01 | I | App logs contain secrets and are read by a viewer or an agent | Logs stay on the node and are read on demand under app:logs; every agent read is audited | D-0014, D-0031, ../contracts/README.md scope flags | app:logs is a broad read of everything the app printed, including tokens |
| T-LOG-02 | I | A caller reads logs of an app outside their selector | List results are filtered to matching resources, never refused wholesale | ../contracts/permission-model.md | A selector with {"all": true} reads every log in the installation |
| T-LOG-03 | T | Log content injects markup or terminal escapes into the panel | Design choice of this document: log lines are rendered as text, never as HTML, and control characters are escaped in the terminal view | design choice of this document | A terminal emulator still interprets some sequences; the M4 terminal is scoped and audited |
| T-LOG-04 | D | A telemetry query is used to reach a node the caller cannot otherwise reach | The primary forwards the query over the existing node session and answers node_unreachable (504) when it cannot | ../contracts/README.md error codes | The query path is the same session the control plane already has |
| T-LOG-05 | D | Log growth fills the node disk | Retention is local and the node emits node.disk_low | D-0014, ../contracts/README.md event types | Detection is after the fact; a fast log flood can fill a disk between checks |
| T-LOG-06 | E | The M4 web terminal becomes a root shell with no trail | Terminal sessions require a scope, are recorded in the audit log, and run inside the app’s guest | ../product/roadmap.md M4 scope, D-0031 | The terminal gives whatever the guest gives; an agent with terminal access can act inside the app unlogged beyond the session record |
(l) Supply chain and releases
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-SUP-01 | T | A tampered download is piped into a root shell | Binaries are signed and builds reproducible, so the installer can verify the signature before running the binary | D-0027 | The verification step is code in the installer; if it is skipped, the pipe still runs |
| T-SUP-02 | T | A build host is compromised and produces a malicious, correctly signed binary | Reproducible builds let anyone rebuild from source and compare; a mismatch is detectable | D-0027 | Detection needs someone to rebuild; the signature alone would not catch it |
| T-SUP-03 | S | The release signing key is stolen | It is a release-pipeline secret, never on the operator’s host; releases are signed, so key custody is the control | D-0027 | A stolen key mints valid signatures; rotation and revocation are not defined in M0 |
| T-SUP-04 | T | An upstream catalog image is replaced with a different one | The catalog pins images by digest with verifiable signatures — mitigation pending: the catalog format and signature scheme are decided at M4 planning, before the first template ships (D-0070) | D-0026, D-0070 | A publisher that signs a malicious image passes every check; the signature proves origin, not intent |
| T-SUP-05 | T | A dependency upgrade breaks a security invariant (for example MCP auth) | rmcp is pinned to a minor version and majors are budgeted; the API spec is frozen and diffed in CI | F-0041, F-0042, D-0048, D-0036 | A pinned old version accumulates unpatched issues; the upgrade budget is a process, not a guarantee |
| T-SUP-06 | E | A contributor merges a backdoor | Review before merge, one maintainer merges, and contracts are standalone pull requests | D-0004, D-0036 | Review is human; a subtle change can pass |
| T-SUP-07 | T | The panel client is generated from a hand-edited spec that diverged from the server | The hand-written ../contracts/openapi.yaml is frozen and CI fails on a semantic diff against the utoipa output; orval generates the panel client from it | D-0048, F-0042 | CI compares documents, not behaviour; a handler can still differ from its annotation |
(m) Availability
| Id | S | Threat | Mitigation | Reference | Residual risk |
|---|---|---|---|---|---|
| T-AVL-01 | D | The control plane dies and apps stop | Nodes store desired state locally and reconcile without the control plane, and they keep self-restarting apps | D-0011, D-0010 | Changes are blocked until a primary exists again |
| T-AVL-02 | D | The primary is lost and nobody can change anything | Replica nodes hold a copy and can be promoted manually | D-0012, D-0016 | Manual promotion is an operator action; until it happens the cluster is read-only |
| T-AVL-03 | D | Two nodes both believe they are primary after a network cut | Automatic election is not allowed below three votes, and Quoro says so instead of pretending | D-0016, D-0017 | The operator may still force a promotion by hand and create a split; that is a human error the product warns about |
| T-AVL-04 | D | The witness sits in the same failure domain as a node and votes with it | The requirement is stated in the product and the panel; the witness must be outside the nodes’ failure domain | D-0017 | Nothing enforces placement; a home PC next to a node is accepted if the operator says so |
| T-AVL-05 | D | A single edge loses all app traffic | Two edges with DNS failover, or Cloudflare as the traffic edge with the host as witness only | D-0018 | Both alternatives add a provider dependency |
| T-AVL-06 | D | A provider outage removes the arbiter and stops stateful apps | The outage fences expiring leases by design; local-mode leases keep none/cold apps running | D-0047, D-0019, docs/research/r2-conditional-writes.md | warm and hot apps lose availability during a long outage |
| T-AVL-07 | D | The primary’s disk fills with change log and audit records | node.disk_low exists for nodes; the primary is a node too | D-0013, D-0031, ../contracts/README.md event types | No archival policy in M0-M6; growth is bounded only by the operator |
| T-AVL-08 | D | A crash-heavy app consumes restart budget and hides a real fault | Restart backoff from 1 s doubling to 5 min, reset after 10 minutes healthy; app.down after the failure threshold | ../contracts/node-agent-protocol.md, ../contracts/README.md event types | A crash loop is reported, not fixed; it still burns CPU on the node |
| T-AVL-09 | D | Upgrading the binary stops apps | The node agent restart does not restart VMs, because VMMs are separate processes and are rediscovered | D-0011, ../contracts/engine-trait.md | A VMM crash during the upgrade window is not covered by the agent restart |
7. Mitigation map
The map is the reverse view: which threats each accepted decision answers. It is not exhaustive for decisions with a single narrow threat.
| Decision | Threats it mitigates |
|---|---|
| D-0011 (autonomous nodes, dial-out) | T-AVL-01, T-NODE-05, T-API-03 (no inbound path to a node to attack), T-AVL-09 |
| D-0013 (numbered hash-chained log) | T-NODE-06, T-PRV-06, T-AUD-06, T-AVL-02 (a replica can verify what it holds), T-AUD-01 (the same chain discipline) |
| D-0017 (three votes, witness) | T-AVL-03, T-AVL-04, T-LEASE-08, T-PRV-03 (the arbiter must be probed before it votes) |
| D-0019 (mandatory lease-based fencing) | T-LEASE-01, T-LEASE-02, T-LEASE-07, T-LEASE-10, T-LEASE-05 (tokens do the work, not reads), T-VM-06 (shared storage writes carry tokens) |
| D-0026 (curated catalog, pinned signed images) | T-VM-05, T-SUP-04 |
| D-0027 (signed binaries, reproducible builds) | T-SUP-01, T-SUP-02, T-SUP-03 |
| D-0029 (agents as separate identities) | T-MCP-03, T-AUD-04, T-MCP-07, T-API-06 |
| D-0030 (scoped, time-limited grants, JIT approval) | T-MCP-01, T-MCP-02, T-MCP-04, T-MCP-05, T-MCP-11, T-GRT-01 to T-GRT-09, T-API-09 |
| D-0031 (append-only audit, same path for MCP) | T-AUD-01 to T-AUD-10, T-MCP-07, T-MCP-08, T-MCP-10, T-API-06, T-NODE-10, T-GRT-10, T-SUP-07 (the frozen spec and its CI diff support it, D-0048) |
| D-0032 (detect, notify, wake, act) | T-WHK-01 to T-WHK-07, T-MCP-06, T-NODE-10, T-GRT-05 |
| D-0012 (one primary, replicas) | T-API-03, T-AVL-02, T-NODE-14 (it names the trust concentration instead of hiding it) |
| D-0014 (telemetry stays on nodes) | T-LOG-01 (blast radius), T-LOG-02, T-PRV-04 (logs are not copied to a provider) |
| D-0018 (edge as third voter) | T-AVL-05, T-AVL-03 |
| D-0024 (ingress with ACME or Tunnel) | T-ING-01 to T-ING-09 except availability-only rows |
| D-0025 (provider interfaces) | T-PRV-01, T-PRV-02, T-PRV-07, T-PRV-03 (a probe per provider is possible only with an interface) |
| D-0007/D-0008 (own engine, microVM per app, no Docker) | T-VM-01 to T-VM-09 |
| D-0010 (independent nodes) | T-AVL-03, T-API-03 |
| D-0047 (lease modes, fencing tokens on every shared write) | T-LEASE-02, T-LEASE-03, T-LEASE-05, T-LEASE-09, T-AVL-06, T-VM-06 |
| D-0048 (frozen spec, CI diff, pinned rmcp) | T-SUP-05, T-SUP-07 |
| D-0050 (cluster keys, DEK storage and rotation) | T-PRV-06, T-PRV-08 |
D-0051 (human-only providers resource, write-only secrets) | T-PRV-01, T-PRV-02, T-PRV-08 |
| D-0053 (off-site copy in M5a, object-lock probe) | T-AUD-06, T-PRV-05 |
| D-0054 (setup token, listener and pin model) | T-API-11 |
D-0059 (embedded reverse proxy behind the Ingress interface) | T-ING-01 to T-ING-09 (the proxy and its certificate handling are Quoro code; Caddy remains a fallback backend) |
D-0061 (own init, own OCI-to-ext4 pipeline with RESOLVE_IN_ROOT) | T-VM-05 (layer paths cannot escape the rootfs), T-VM-07 (PID 1 is Quoro’s own init) |
| D-0063 (kernel WireGuard configured over netlink) | Boundary 9, node <-> node: replication traffic and chunks cross it under per-node keys instead of trusting the network |
| D-0069 (own chunk engine for SQLite and template backups) | T-PRV-04, T-AUD-06 (consistent snapshots make the copy worth restoring) |
8. Residual risks and open items
8.1 A root operator on the primary before the off-site audit copy exists
D-0031 asks for an off-site copy with object lock. The copy ships in M5a with the S3-compatible path, gated by an object-lock probe per provider (D-0053). Until the first deployment of that copy, an operator with root on the primary can rewrite or truncate the log and nothing outside the machine contradicts them. The hash chain still catches a careless edit, because verification reports the exact seq. It does not catch a careful rewrite of the tail or of the whole file.
This is the single largest gap in the model. It is a scheduling fact, not a design flaw, and it should be stated as such in the M3 demo: “the log is tamper-evident, not tamper-proof, until the off-site copy exists”.
8.2 Single primary until promotion exists
Until M5b (manual promotion, D-0067) and M6 (automatic election), one primary is a single point of write failure and a single point of trust. Losing it means no changes until an operator acts; compromising it means full control (T-NODE-14). Replica nodes reduce the data-loss risk, not the authority risk.
8.3 Cluster key custody and how a promoted replica obtains it
Replicated secret columns are encrypted with the cluster key (D-0013, ../contracts/node-agent-protocol.md).
A promoted replica must obtain that key or it cannot read the state it now owns. The algorithms, the
storage and the rotation of the cluster keys are decided (D-0050; architecture section 6, “Cluster
keys”); custody on promotion is decided together with manual promotion in M5b (D-0067). That is why
promotion is not offered before M5b: a replica promoted earlier would own state it cannot decrypt.
8.4 Object lock portability
The off-site audit copy depends on object-lock support in compliance mode, which is not portable
across S3-compatible providers (F-0038). The contract requires the adapter to probe and to refuse to
report “locked” when the probe fails (../contracts/audit-log-format.md). A provider that passes the
probe but degrades later is not covered. Object-lock support per provider is Hypothesis: it is not
in docs/research/.
8.5 Database backups and encryption
Litestream 0.5.x has no client-side encryption (F-0040), so a SQLite backup taken through it would
rely on provider-side SSE-C or SSE-KMS. D-0069 settles the default instead: SQLite and catalog-template
backups use Quoro’s own chunk engine with client-side encryption, from consistent snapshots taken with
the SQLite backup API or VACUUM INTO. Litestream is not used. A Litestream backend with SSE-C may be
added later as an optional path for a lower RPO; it would then rely on the provider for encryption,
with a Quoro-held key sent per request, and pinning the older 0.3.x line with age stays a
Hypothesis on its maintenance state.
8.6 Hypotheses carried by this document
| Hypothesis | Where it matters | What would settle it |
|---|---|---|
Monotonic clock drift between machines is bounded well below max(1000 ms, duration_ms / 10) | T-LEASE-04, lease timing | A lab measurement of drift under load and after suspend |
| Cloud Hypervisor’s device model plus seccomp and Landlock make guest escape a hypervisor-bug problem, not a configuration problem | T-VM-01 | A lab escape test and a review of the per-VM sandbox flags |
| rmcp does not validate inbound tokens for a server, so Quoro must | T-MCP-09 | Reading the pinned rmcp version’s server code |
| A provider passes the object-lock probe and keeps honouring it | T-AUD-03, 8.4 | A per-provider test that writes, locks and tries to delete |
| The M4 builder microVM is isolated from the host the same way an app microVM is | T-VM-09 | The M4 build brief and a test that the builder cannot reach the node filesystem |
Cloudflare LB failover over a tunnel is app-level only with the Host monitor | T-ING-04 | F-0046 already narrows it; a lab test would confirm the replica caveat |
| No Docker on nodes means no container escape path | T-VM-09 | Trivially true by construction; re-check if a container engine is ever added |
8.7 Items this document deliberately does not decide
- Retention of audit records and of app logs. Both are “never deleted” in M0-M6; a policy needs its own decision.
- Key rotation for the release signing key and node credentials. The signing-key and data-encryption-key rotation of the cluster keys is defined (D-0050; architecture section 6, “Cluster keys”); the release key’s rotation is not defined.
- Federation or multi-installation trust. Out of scope (D-0034).
- The threat model of the managed offering.
9. Security testing on the lab
Functional checks only. The lab is nested virtualization and is not valid for performance work
(../product/roadmap.md). Each item below is a check a person can run over SSH on the lab hosts, and
each names the threat it exercises.
M0 — contracts
| Check | Threat |
|---|---|
Take the desired_state message from the protocol contract, change one field, keep the old signature, and confirm the verification step rejects it | T-NODE-04 |
Send a desired_state with rev lower than the stored one and confirm it is rejected | T-NODE-05 |
Revoke a local lease and confirm the node runs the self-fence path and emits node.fenced | T-LEASE-01 |
Expire a test-only short expiring lease and confirm the node fences before valid_until + margin | T-LEASE-10, T-LEASE-04 |
Make a forced stop fail during a fence and confirm the node emits node.fence_failed, not node.fenced, and starts no stateful VM | T-LEASE-01 |
M1 — installer and engine
| Check | Threat |
|---|---|
| Modify one byte of the published binary and confirm the installer refuses to run it | T-SUP-01 |
| Rebuild from source and compare the artifact hash with the published one | T-SUP-02 |
| From inside a guest, attempt to read a host file path and to reach another VM’s address; both must fail | T-VM-01, T-VM-04 |
Fill a guest disk and confirm node.disk_low fires for the node | T-VM-08 |
M2 — node agent and ingress
| Check | Threat |
|---|---|
| Present a revoked or unknown node credential to the session endpoint and confirm the connection is refused | T-NODE-03 |
| Reuse a join token twice and confirm the second enrollment fails | T-NODE-01 |
| Connect with a mismatched TLS certificate and confirm pinning rejects the session | T-NODE-02 |
| Change a hostname in the panel to a domain the operator does not control and confirm ACME fails rather than issuing | T-ING-01 |
| Delete an app and confirm its route stops resolving to the old guest | T-ING-06 |
M3 — agents, grants, audit
| Check | Threat |
|---|---|
An agent with a read-only grant attempts a restart: refused, reason scope_missing, refusal audited | T-GRT-01, T-GRT-10 |
An agent attempts a delete without approval: approval_required, no deletion, approval request pending | T-MCP-01, T-GRT-02 |
Approve, then call with changed parameters: refused because params_hash does not match | T-GRT-08 |
Reuse the approval grant: the second call is refused with grant_exhausted | T-GRT-02 |
An agent attempts approval:decide or grant:write: refused with human_only and audited | T-MCP-05, T-GRT-01 |
Revoke a grant and confirm the next call is refused with grant_revoked | T-GRT-04 |
Edit a copy of the audit log and confirm audit.verify reports the exact seq and audit.chain_broken fires | T-AUD-01, T-AUD-10 |
| Send a webhook delivery with a stale timestamp and with a wrong HMAC; the receiver rejects both | T-WHK-01, T-WHK-02 |
Create a webhook with http:// to a private address and confirm it is refused | T-WHK-04 |
| Read an app’s env values through the API and confirm only names come back | T-API-07 |
Inspect an audit record for a call that carried a secret in params and confirm [redacted] | T-AUD-05 |
M4 — builds, terminal, console
| Check | Threat |
|---|---|
| Run the web terminal and confirm the session appears in the audit log with the acting identity | T-LOG-06 |
| Print a line with terminal escape sequences from an app and confirm the panel renders it as text | T-LOG-03 |
| Run a build and confirm the builder cannot read the node’s credential file | T-VM-09 |
M5a — replicas, edge, backups
| Check | Threat |
|---|---|
| Run the object-lock probe against the configured bucket and confirm the panel reports locked or not-locked truthfully | T-AUD-03, T-PRV-03 |
| Attempt to delete a locked audit segment with the audit-copy credential: refused | T-AUD-09, T-PRV-02 |
| Read a backup chunk from the bucket and confirm it is ciphertext | T-PRV-04 |
| Restore a primary snapshot taken through the SQLite backup API and confirm the off-site copy is ciphertext, encrypted client-side with the cluster key | T-PRV-04 |
M5b — witness, leases, fencing
| Check | Threat |
|---|---|
| Cut lab-1 off from lab-2 and lab-3: lab-1 self-fences its stateful apps and they stop | T-LEASE-02 |
| While lab-1 holds the lease, ask to start the warm copy on lab-2: refused | T-LEASE-01, T-LEASE-08 |
| Reconnect lab-1: it starts nothing stateful, its copy becomes the warm copy, and the quarantined snapshot is in the audit log | T-NODE-06, T-LEASE-09 |
Make the arbiter unreachable, if an external arbiter is used, and confirm the node reports uncertain and does not fence before valid_until | T-LEASE-03, T-LEASE-07 |
| Present a stale fencing token to the chunk store and confirm the write is rejected | T-LEASE-01, T-LEASE-05 |
Let several expiring leases on one node expire at the same time and confirm every fence runs concurrently and finishes within its own bound, t_send + D (D-0075) | T-LEASE-01, T-LEASE-02 |
| Crash a node in the middle of a fence and confirm that, after restart, its stateful apps stay stopped and no lease is granted elsewhere until an operator acknowledges the fence (D-0075) | T-LEASE-02, T-LEASE-09 |
| Ask for manual promotion while the old primary may still hold expiring leases: refused until an operator acknowledges the fence (D-0067) | T-AVL-02, T-AVL-03 |
M6 — election and hot
| Check | Threat |
|---|---|
| Cut the network into a minority partition and confirm no node in it accepts writes or claims to be primary | T-AVL-03 |
| Power off the primary and confirm a new primary takes writes with no human action | T-AVL-02 |
| After the partition heals, confirm writes succeeded in the majority and the audit chain still verifies | T-AUD-01, T-AVL-03 |
10. References
- Decisions and findings: the decision ledger, mirrored as ADRs in
../adr/. - Contracts:
../contracts/README.md,../contracts/openapi.yaml,../contracts/events.schema.json,../contracts/node-agent-protocol.md,../contracts/audit-log-format.md,../contracts/permission-model.md,../contracts/db-schema.sql,../contracts/engine-trait.md. - Architecture and product: architecture.md,
../product/prd.md,../product/roadmap.md,../glossary.md. - Research:
docs/research/cloud-hypervisor.md,docs/research/r2-conditional-writes.md,docs/research/durable-objects.md,docs/research/cloudflare-lb-and-tunnel.md,docs/research/rust-api-and-mcp-tooling.md,docs/research/vps-nested-virt.md.
Security Policy
Quoro runs microVMs, deploys applications and hands scoped access to AI agents. Security reports are taken seriously and handled privately.
Reporting a vulnerability
Do not open a public issue, pull request or discussion for a security problem.
Report it privately through GitHub Security Advisories:
- Go to the repository’s Security tab: https://github.com/OmarAlex24/quoro/security
- Click Report a vulnerability.
- Describe the issue in the advisory form.
Direct link: https://github.com/OmarAlex24/quoro/security/advisories/new
This keeps the report private between you and the maintainer until a fix and an advisory are ready.
What to include
- A description of the issue and the impact you believe it has.
- The affected version, commit or date you tested, and the configuration used.
- Steps to reproduce, ideally minimal. Proof-of-concept code or a packet capture is welcome.
- Any suggested fix or mitigation, if you have one.
- How you would like to be credited in the advisory, or whether you prefer to stay anonymous.
Do not include secrets, credentials or personal data belonging to others in the report.
What to expect
- Acknowledgement: within 7 days of the report, on a best-effort basis. This is a volunteer project and there is no service-level agreement.
- We will confirm the report, agree on a disclosure timeline with you, and keep you updated while a fix is developed.
- Please give us a reasonable window to ship a fix before any public disclosure.
- Credit is given in the published advisory unless you ask otherwise.
Supported versions
None yet. Quoro has no releases. This section will list the supported versions once releases exist.
Release integrity
Every release binary will be signed, and builds will be reproducible. Quoro is installed with a curl | sh installer and controls infrastructure, so an unsigned or non-reproducible release is not acceptable. Verification instructions will be published together with the first release; the signing mechanism has not been chosen yet.
Scope
Because Quoro gives AI agents scoped, time-limited access to infrastructure, the following are in scope and treated as security issues, not feature requests:
- Authorization and grants — privilege escalation, bypassing a grant, extending a grant beyond its scope or lifetime, or acting after a grant expires.
- Agent scope escapes — an agent reaching resources, hosts or operations outside the scope it was granted.
- Audit log integrity — tampering with, omitting or forging audit log entries, or breaking the append-only property.
- Isolation — escaping a microVM, crossing the boundary between apps or tenants, or interfering with another workload.
- The installer and release chain — supply-chain attacks, signature or reproducibility failures.
- Secrets and credentials — exposure, leakage into logs or audit entries, or insecure storage.
Vulnerabilities in third-party dependencies should be reported upstream as well; tell us if Quoro’s use of the dependency makes the issue exploitable here.
Architecture Decision Records
These ADRs mirror the accepted decisions of the Quoro project ledger. They are generated from the maintainer’s decision ledger; do not edit them by hand. To change a decision, open an issue proposing a new decision that supersedes it; once the maintainer accepts it, a new ADR is generated.
Some decisions about internal coordination or business planning are kept private, so numbering has gaps.
| ADR | Title | Status | Tags | Date |
|---|---|---|---|---|
| 0001 | Project name is Quoro | Accepted | product | 2026-11-02 |
| 0002 | Quoro unifies hypervisor and app deploy in one self-hosted tool, operable by AI agents | Accepted | product | 2026-11-02 |
| 0003 | Start as a self-use / experimenter tool; enterprise or managed offering later | Accepted | product | 2026-11-02 |
| 0004 | License: AGPL-3.0 core, enterprise code in ee/ under a separate license, CLA from the first external contributor | Accepted | product, oss | 2026-11-02 |
| 0005 | Repos: public quoro repository (English) + private coordination repository | Accepted | product, process | 2026-11-02 |
| 0007 | Own engine on Cloud Hypervisor + KVM (not Incus, not an own VMM) | Accepted | engine, architecture | 2026-11-02 |
| 0008 | One microVM per app; no Docker on nodes | Accepted | engine, architecture | 2026-11-02 |
| 0009 | MVP requires a KVM-capable host; container fallback is a later Engine implementation | Accepted | engine, product | 2026-11-02 |
| 0010 | Independent nodes orchestrated by Quoro (Dokploy-style), not Incus-style clustering | Accepted | architecture, node-agent | 2026-11-02 |
| 0011 | Nodes are autonomous and dial out | Accepted | architecture, node-agent, reliability | 2026-11-02 |
| 0012 | Same binary on every node; one primary is the single source of truth, others are replicas | Accepted | architecture, control-plane, reliability | 2026-11-02 |
| 0013 | State replication is continuous over an append-only, numbered, hash-chained log | Accepted | architecture, control-plane, reliability | 2026-11-02 |
| 0014 | Telemetry (app logs, metrics) stays on each node; only state is replicated | Accepted | architecture, observability | 2026-11-02 |
| 0015 | SQLite first; state stored as a numbered change log | Accepted | architecture, control-plane | 2026-11-02 |
| 0016 | Leader election roadmap: single node in MVP, manual promotion in M5, Raft auto-failover with >=3 votes later | Accepted | reliability, control-plane | 2026-11-02 |
| 0017 | HA requires at least 3 votes; with 2 nodes Quoro tells the user and offers a witness | Accepted | reliability, witness | 2026-11-02 |
| 0018 | The edge/LB can be the third voter (role witness+edge of the same binary) | Accepted | reliability, witness, ingress | 2026-11-02 |
| 0019 | Fencing is mandatory and lease-based; node agent self-fences from M0 | Accepted | reliability, node-agent, fencing | 2026-11-02 |
| 0020 | Per-app HA policies: none / cold / warm / hot, with cost shown in the panel | Accepted | reliability, ha-policy | 2026-11-02 |
| 0021 | N+1 capacity indicator: ‘if node A dies, do its apps fit on the others?’ | Accepted | reliability, ha-policy, panel | 2026-11-02 |
| 0022 | Failback is never automatic or immediate; returning node rejoins as replica and handover is planned | Accepted | reliability, failback | 2026-11-02 |
| 0023 | Sync engine is our own: control-plane log replication + a content-addressed chunk engine for volumes | Accepted | sync, reliability, backups | 2026-11-02 |
| 0024 | Ingress: reverse proxy with ACME for public-IP hosts, Cloudflare Tunnel for homelabs behind NAT | Accepted | ingress | 2026-11-02 |
| 0025 | Provider interfaces (DNS, LB, S3-compatible storage) with Cloudflare as the first implementation, not the only one | Accepted | providers, oss | 2026-11-02 |
| 0026 | Curated catalog in the repo instead of running an own image registry (for now) | Accepted | catalog, security, oss | 2026-11-02 |
| 0027 | Signed binaries and reproducible builds are mandatory | Accepted | security, release, oss | 2026-11-02 |
| 0028 | Baseline stack (to be confirmed in the architecture doc) | Superseded by 0056 | stack | 2026-11-02 |
| 0029 | Agents are first-class identities, separate from humans | Accepted | agents, security | 2026-11-02 |
| 0030 | Grants: scope + resource selector + TTL (+ optional max uses); incident-scoped; JIT approval for destructive actions | Accepted | agents, security, auth | 2026-11-02 |
| 0031 | Audit log: append-only, hash-chained, optionally copied to S3/R2 with object lock; MCP uses the same path | Accepted | agents, audit, security | 2026-11-02 |
| 0032 | AI-native loop: detect -> notify -> wake agent -> act within limits | Accepted | agents, events | 2026-11-02 |
| 0033 | Milestones M0-M6; HA and backups out of MVP but the MVP architecture prepares them | Accepted | roadmap | 2026-11-02 |
| 0034 | Out of scope for now | Accepted | roadmap, scope | 2026-11-02 |
| 0036 | Anti-drift rules | Accepted | process | 2026-11-02 |
| 0037 | Git conventions | Accepted | process, git | 2026-11-02 |
| 0040 | Write the bases before any code | Accepted | process, docs | 2026-11-02 |
| 0042 | Repo AGENTS.md must carry the maintainer’s working rules, because non-Claude agents never see the maintainer’s CLAUDE.md | Accepted | process, agents-md | 2026-11-02 |
| 0044 | Realtime transport (WebSockets vs SSE) stays open until the architecture doc | Superseded by 0057 | roadmap, stack | 2026-11-02 |
| 0047 | Leases have two modes: local (HA none/cold, never expire on control-plane loss) and expiring (warm/hot, self-fence on expiry) | Accepted | reliability, node-agent, fencing | 2026-11-02 |
| 0048 | API contract source of truth: hand-written docs/contracts/openapi.yaml is frozen; utoipa output is diffed against it in CI; orval generates the panel client | Accepted | stack, api | 2026-11-02 |
| 0049 | Agent runner (D-0032 wake): reference runner in examples/agent-runner/, owned by the mcp workstream, outside the quoro binary | Accepted | agents, mcp, events | 2026-11-02 |
| 0050 | Cluster keys: separate Ed25519 signing key and XChaCha20-Poly1305 data-encryption key, generated at setup, stored as 0600 files outside SQLite, plus a node-local key | Accepted | security, control-plane, node-agent | 2026-11-02 |
| 0051 | Provider credentials: a human-only providers resource (API + replicated table with secret_enc) added to the contract before the M0 freeze | Accepted | providers, ingress, security | 2026-11-02 |
| 0052 | Local persistent volumes (non-replicated) ship with M5a, before the cold-backup DoD; until then the PRD states no persistent app data | Accepted | engine, sync, roadmap | 2026-11-02 |
| 0053 | Off-site audit-log copy (D-0031) ships in M5a with the S3-compatible path, gated by an object-lock probe per provider | Accepted | audit, roadmap, security | 2026-11-02 |
| 0054 | Host layout and CLI: one TLS listener for API/panel/MCP on 8443, self-signed primary certificate pinned by join commands, /var/lib/quoro + /etc/quoro/quoro.toml, one-time setup token for /setup | Accepted | installer, control-plane, security | 2026-11-02 |
| 0055 | Lab TLS source for the M2 deploy DoD: Cloudflare Tunnel with a test domain; ACME exercised separately via DNS-01 against a test zone | Accepted | ingress, lab, roadmap | 2026-11-02 |
| 0056 | Baseline stack without noVNC: consoles are text-only (xterm.js) because Cloud Hypervisor has no VNC or display device | Accepted | stack, engine, console | 2026-11-02 |
| 0057 | Realtime transport: SSE for one-way streams, WebSockets for bidirectional sessions | Accepted | stack, architecture | 2026-11-02 |
| 0059 | Q-0001: embedded Rust reverse proxy behind the Ingress interface; Caddy admin API as fallback backend | Accepted | ingress | 2026-11-02 |
| 0060 | Q-0002: Dockerfile-only builds in M4, run inside a builder microVM; re-verify Railpack/Nixpacks before M4 | Accepted | builds | 2026-11-02 |
| 0061 | Q-0003: write our own minimal init and OCI-to-ext4 pipeline, with in-root path resolution as a required hardening item | Accepted | engine | 2026-11-02 |
| 0062 | Q-0004: reflink snapshots + full chunk scan for M5a cold backups; spike lvm-thin vs btrfs before M5b warm | Accepted | sync, engine | 2026-11-02 |
| 0063 | Q-0005: kernel WireGuard configured by Quoro over netlink; mesh deferred to M5a when cross-node traffic starts | Accepted | networking | 2026-11-02 |
| 0064 | First public release is an M3 preview (v0.1, experimental, not production); M4 complete is the first general release | Accepted | product, roadmap | 2026-11-02 |
| 0065 | Docs stay Markdown in the repo until the first public release; pick docs-site tooling during M3 | Accepted | docs | 2026-11-02 |
| 0067 | Manual promotion of a replica node to primary ships in M5b, alongside leases and fencing | Accepted | roadmap, reliability | 2026-11-02 |
| 0068 | PRD HA table costs confirmed: none = no reserved resources; cold = backup storage and restore time | Accepted | product, reliability | 2026-11-02 |
| 0069 | SQLite and catalog-template backups use Quoro’s own chunk engine with client-side encryption, from consistent snapshots (SQLite backup API / VACUUM INTO) | Accepted | sync, security | 2026-11-02 |
| 0070 | Curated catalog format, owner, location and signature scheme are decided at M4 planning, before the first template ships | Accepted | catalog | 2026-11-02 |
| 0071 | Engine trait async methods return Send futures (fn … -> impl Future<Output = …> + Send), via a standalone contract PR before the M0 freeze | Accepted | engine, contracts | 2026-11-02 |
| 0072 | Lease timing: a node renews immediately after a first grant and treats the lease as held only after that answer; held expiring leases survive an agent restart within the same boot by persisting remaining validity against boot_id and CLOCK_BOOTTIME | Accepted | reliability, contracts | 2026-11-02 |
| 0073 | Fencing tokens change only on ownership change: renewals by the same holder keep the token; a grant to a new holder or after expiry or revoke needs a strictly higher token | Accepted | reliability, contracts | 2026-11-02 |
| 0074 | A failed forced stop escalates: SIGKILL the VMM process; if the VM may still run, the node never emits node.fenced, emits node.fence_failed, marks itself failed and keeps the lease unstartable | Accepted | reliability, contracts | 2026-11-02 |
| 0075 | Concurrent fences and a mid-fence crash test are mandatory before expiring leases ship (M5b) | Accepted | reliability | 2026-11-02 |
| 0076 | License identifier is AGPL-3.0-only | Accepted | product, governance | 2026-11-02 |
| 0077 | Governance defaults for the public repo (Q-0023 items 2-6, 8, 9) | Accepted | governance, docs | 2026-11-02 |
| 0078 | A promoted replica obtains the cluster signing and data-encryption keys because both are replicated to every replica node, wrapped to that node’s node-local key | Accepted | security, reliability | 2026-11-02 |
| 0079 | Add protocol error code fence_failed and a human-only operator acknowledgement that clears a node’s failed mark | Accepted | reliability, contracts | 2026-11-02 |
| 0080 | Dependency license policy for the AGPL-3.0-only core: an allowlist of permissive and AGPL-compatible licenses enforced in CI with cargo-deny, starting in M1 | Accepted | governance, security | 2026-11-02 |
| 0081 | Release signing: keyless GitHub artifact attestations (Sigstore, SLSA build provenance) verified by a sha256-pinned gh in install.sh | Accepted | installer, security, release | 2026-11-02 |
| 0082 | One-off workflow_dispatch dry run of the release workflow: it attests and uploads workflow artifacts only, with no tag and no release | Accepted | installer, security, release | 2026-11-02 |
| 0083 | VM disk_gb minimum is 1 in openapi.yaml (CreateVmRequest and Vm), matching db-schema CHECK (disk_gb >= 1) | Accepted | control-plane, contracts | 2026-11-02 |
| 0084 | M1 keeps every microVM isolated from every other; an additive contract change carrying a project or isolation group on NetAttach and guest DNS on NodeNetwork lands in a standalone PR at the start of M2, before any multi-VM app feature | Accepted | engine, networking, contracts | 2026-11-02 |
| 0086 | cargo-deny checks the targets Quoro ships (x86_64 and aarch64 Linux, gnu and musl), not every target; the ADR 0080 license allowlist is unchanged | Accepted | governance, security | 2026-11-02 |
| 0088 | quoro.service runs as root while the Cloud Hypervisor engine is in-process (M1) | Superseded by 0096 | installer, engine, security | 2026-11-02 |
| 0089 | Privilege split in M2: the API and panel run unprivileged and only the node agent keeps the privileges the engine needs | Accepted | security, installer, engine | 2026-11-02 |
| 0090 | Release builds and reproduce-release.sh run inside the same digest-pinned Ubuntu 24.04 container image with snapshot-pinned packages, so any host with podman or docker reproduces the published artifact byte for byte | Accepted | release, security, installer | 2026-11-02 |
| 0094 | Non-primary nodes get only a per-node tunnel run token from the primary; the account API token never leaves the primary | Accepted | ingress, providers, contract | 2026-11-03 |
| 0095 | Add provider_id to domains and audit_exports and a node-to-tunnel-provider mapping so DELETE /providers/{id} can return 409 while referenced | Accepted | providers, contract | 2026-11-03 |
| 0096 | M2 process model: quoro.service is the unprivileged control plane (User=quoro-api, empty capability bounding set) and quoro-node.service is the root node agent with KillMode=process; node data in /var/lib/quoro-node | Accepted | security, installer, engine, m2 | 2026-11-03 |
| 0098 | AppResources.disk_gb minimum is 1 in openapi.yaml, matching vms CHECK (disk_gb >= 1) | Accepted | contracts, control-plane, apps | 2026-11-03 |
| 0099 | Add domain_id to desired_state spec.routes[] so node certificate.* events can name their domain | Accepted | contracts, apps, ingress, m2 | 2026-11-03 |
| 0102 | M3 demo agent runner uses the built-in deterministic policy; an LLM backend (Claude, Anthropic API) stays optional, configured at the runner with the operator’s own key, never required by the DoD | Accepted | agents, m3 | 2026-11-03 |
| 0103 | A webhook delivery from a webhook with wake carries data.wake = {agent_id, grant_id} in the incident.opened payload (additive, schema_version 1) | Accepted | contracts, agents, m3 | 2026-11-03 |
| 0104 | Ratify the provisional webhook and event contract choices from #116 | Accepted | contracts, m3 | 2026-11-03 |
| 0105 | M3 ships without deleteAgent; disabling an agent (PATCH) is the lockout; delete semantics are decided later | Accepted | agents, m3 | 2026-11-03 |
| 0106 | Add GET /api/v1/agents/{agent_id}/keys to openapi.yaml so the panel can list an agent’s keys | Accepted | contracts, panel, agents, m3 | 2026-11-03 |
| 0107 | Let operators and viewers resolve user ids to names (a read path for users) | Accepted | contracts, panel, m3 | 2026-11-03 |
| 0109 | Docs site: mdBook built from the repo’s Markdown, published to GitHub Pages together with the v0.1.0 tag | Accepted | docs, m3 | 2026-11-03 |
| 0111 | user:read stays human-only; WebhookDelivery exposes the test flag; creating users is M4 work | Accepted | contracts, m3 | 2026-11-03 |
| 0113 | M4 web terminal path: exec through quoro-init over virtio-vsock, relayed by the root node agent | Accepted | m4, terminal, engine, contracts | 2026-11-04 |
| 0114 | M4 terminal and console access: app:terminal human-only (owner, operator); vm:console grantable read-only (owner, operator); agents get a non-interactive app:exec later via approval | Accepted | m4, terminal, console, security, contracts | 2026-11-04 |
| 0115 | M4 session audit: an open record plus a session-end record carrying a SHA-256 of a node-local, size-capped transcript | Accepted | m4, terminal, audit, contracts | 2026-11-04 |
| 0116 | M4 VM console: read-only; serial in socket mode with the node agent as its only client from vm.create and a node-side ring per VM; viewers fan out from the node | Accepted | m4, console, engine, contracts | 2026-11-04 |
| 0117 | Builder interface: a node-agent Builder trait (Dockerfile first, Railpack later) that returns build events and a node-local OCI layout | Accepted | builds, m4, contracts | 2026-11-04 |
| 0118 | Builds run on the target node in an ephemeral builder microVM (one per build) with a persistent per-app work disk for the BuildKit cache | Accepted | builds, m4, contracts | 2026-11-04 |
| 0119 | Build log transport: tagged serial-console lines out of the builder VM, kept on the node, streamed to the panel over SSE via a new stream_open kind build_logs | Accepted | builds, m4, contracts | 2026-11-04 |
| 0120 | M4 builds and metrics reuse existing scopes; M4 adds no MCP tool | Accepted | contracts, m4 | 2026-11-04 |
| 0121 | Built image reference build.quoro.invalid/<app_id>:<build_id>; Git app image is empty before first build | Accepted | contracts, m4 | 2026-11-04 |
| 0122 | Git apps: createApp takes exactly one of image/source; source cannot be added or removed in M4; createDeployment 409 for Git apps | Accepted | contracts, m4 | 2026-11-04 |
| 0123 | M4 build scope: public HTTPS repos only, trigger api only, one active build per app, fixed default limits | Accepted | contracts, m4 | 2026-11-04 |
| 0124 | Build lifecycle runs through desired state; primary emits build.* events; Deployment.phase build is never reported | Accepted | contracts, m4 | 2026-11-04 |
| 0125 | Builder VM networking: egress_only with no isolation group | Accepted | contracts, m4 | 2026-11-04 |
| 0126 | Terminal exec channel: vsock port 1024, framed [u8 type][u32 BE len][payload], quoro-init config v2 with exec_port | Accepted | contracts, m4 | 2026-11-04 |
| 0127 | Console: Engine::console superseded by serial_output; node ring of two 512 KiB segments; console streams outlive VM restarts | Accepted | contracts, m4 | 2026-11-04 |
| 0128 | Transcripts: terminal only, 16 MiB cap closes session (transcript_limit), 30 days and 1 GiB per node, fetch human-only with audit:read and always audited | Accepted | contracts, m4 | 2026-11-04 |
| 0129 | Metrics: host-side VMM RSS and CPU, 5 s sampling, 10-minute in-memory ring, SSE samples carry no id | Accepted | contracts, m4 | 2026-11-04 |
| 0130 | Public site layout on quoro.dev: the mdBook docs site serves quoro.dev from GitHub Pages at the v0.1.0 release; the installer gets a short URL quoro.dev/install.sh later | Accepted | docs, release | 2026-11-04 |
| 0132 | M4 interactive: ratify contract readings taken while implementing terminal and console sessions | Accepted | terminal, console, m4, contracts | 2026-11-04 |
| 0133 | Keep an app’s last failed or cancelled build in spec.builds so the node keeps its log | Accepted | builds, m4, contracts | 2026-11-04 |
| 0134 | Curated catalog: one TOML file per template under catalog/ in the public repo, embedded in the signed release binary; images pinned by digest; upstream cosign signatures verified in CI when a template is added or bumped, not on nodes | Accepted | catalog, security, m4 | 2026-11-04 |
| 0135 | Q-0099: invited users change their own password through a human-only self-service operation (current and new password), added in a standalone contract PR during M4; no must_change_password flag yet | Accepted | contracts, users, m4 | 2026-11-04 |
| 0136 | Q-0101: pre-M4 VMs get the terminal and console only after a redeploy; the release notes say so and the panel shows a ‘redeploy to enable’ hint instead of the action; the node agent never recreates a VM on its own | Accepted | m4, engine, panel | 2026-11-04 |
ADR 0001: Project name is Quoro
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0001
- Tags: product
- Supersedes: none
- Superseded by: none
- Related: none
Context
Must be easy to pronounce in English; nahuatl names rejected. Free on crates.io and npm; quoro.dev/.sh appear free (registrar + trademark check still pending).
Decision
Project name is Quoro.
CLI binary can be quoro (short alias to decide later).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0001 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0002: Quoro unifies hypervisor and app deploy in one self-hosted tool, operable by AI agents
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0002
- Tags: product
- Supersedes: none
- Superseded by: none
- Related: none
Context
Today self-hosters stack Proxmox -> Debian VM -> Dokploy/Coolify: two dashboards, two mental models, nothing designed for agents. Harvester+Rancher and Cozystack unify both layers but are Kubernetes-heavy and aimed at hosting providers.
Decision
Quoro unifies hypervisor and app deploy in one self-hosted tool, operable by AI agents.
One interface for creating/managing VMs and deploying services (push-to-deploy, domains, TLS, logs). MCP and events are first-class from day one. The agent is ‘one more operator’: it reads the same state as the panel, acts through the same API, and is logged the same way as a human.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0002 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0003: Start as a self-use / experimenter tool; enterprise or managed offering later
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0003
- Tags: product
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer’s answer to ‘own use or product?’: anyone can install it on a box they have and experiment; monetize later.
Decision
Start as a self-use / experimenter tool; enterprise or managed offering later.
Target users: indie devs and advanced homelabbers; small startups wanting Railway/Vercel experience on own hardware/VPS; teams already operating with agents.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0003 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0004: License: AGPL-3.0 core, enterprise code in ee/ under a separate license, CLA from the first external contributor
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0004
- Tags: product, oss
- Supersedes: none
- Superseded by: none
- Related: none
Context
Future managed/enterprise plans: AGPL prevents clouds reselling Quoro as a service without sharing changes; CLA keeps the option to sell commercial licenses of the core.
Decision
License: AGPL-3.0 core, enterprise code in ee/ under a separate license, CLA from the first external contributor.
Model similar to GitLab / Cal.com open-core layout.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0004 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0005: Repos: public quoro repository (English) + private coordination repository
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0005
- Tags: product, process
- Supersedes: none
- Superseded by: none
- Related: none
Context
Open source needs English for an international audience; business strategy (enterprise, pricing) and coordination journal should not be public.
Decision
Repos: public quoro repository (English) + private coordination repository.
Both repos created 2026-11-01 with an initial README commit.
Some operational details were redacted for the public repository. Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0005 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0007: Own engine on Cloud Hypervisor + KVM (not Incus, not an own VMM)
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0007
- Tags: engine, architecture
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer wants to own the whole orchestration with minimal, auditable, Rust dependencies. Incus is a full Go daemon (black box). Writing a VMM from scratch (rust-vmm) is years of work and a huge security surface.
Decision
Own engine on Cloud Hypervisor + KVM (not Incus, not an own VMM).
We depend on KVM (kernel) and Cloud Hypervisor (Rust VMM with REST API; license/details to verify). We write: images, disks/snapshots, networking (bridges, TAP, nftables, IPs), console, lifecycle. Engine sits behind an Engine trait so other backends (Firecracker, containers) can be added.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0007 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0008: One microVM per app; no Docker on nodes
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0008
- Tags: engine, architecture
- Supersedes: none
- Superseded by: none
- Related: none
Context
VM-level isolation matters when agents execute actions on real infra; removes Docker from nodes. Similar general architecture to Fly.io (reference only, not copying the product).
Decision
One microVM per app; no Docker on nodes.
Deploy converts an OCI image into a root disk and boots it in a microVM with a minimal init. Fast microVM boot makes the ‘warm’ HA policy nearly as good as ‘hot’ (boot time to verify).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0008 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0009: MVP requires a KVM-capable host; container fallback is a later Engine implementation
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0009
- Tags: engine, product
- Supersedes: none
- Superseded by: none
- Related: none
Context
Many VPS do not expose nested virtualization; a container fallback (e.g. youki, Rust OCI runtime) is more work and weaker isolation.
Decision
MVP requires a KVM-capable host; container fallback is a later Engine implementation.
Supported in MVP: bare metal or VPS with nested virt. Fallback mode deferred.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0009 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0010: Independent nodes orchestrated by Quoro (Dokploy-style), not Incus-style clustering
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0010
- Tags: architecture, node-agent
- Supersedes: none
- Superseded by: none
- Related: none
Context
Works across VPS from different providers over the internet; Incus-style distributed DB needs 3 nodes for quorum and suffers on WAN latency.
Decision
Independent nodes orchestrated by Quoro (Dokploy-style), not Incus-style clustering.
Mesh between nodes over WireGuard (implementation to decide). Design principle: if the control plane dies, apps keep running.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0010 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0011: Nodes are autonomous and dial out
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0011
- Tags: architecture, node-agent, reliability
- Supersedes: none
- Superseded by: none
- Related: none
Context
Reliability is non-negotiable: the data plane must not depend on the panel. Dial-out works behind NAT and lets the control plane live anywhere.
Decision
Nodes are autonomous and dial out.
Each node agent stores its desired state locally and reconciles it; apps keep running and self-restart if the control plane disappears. Nodes open the connection to the control plane, never the reverse.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0011 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0012: Same binary on every node; one primary is the single source of truth, others are replicas
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0012
- Tags: architecture, control-plane, reliability
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer’s idea: every node has its own API, one is master, everything is copied to it; keeps Quoro truly self-hosted (porting the control plane to Cloudflare Workers/Vercel would defeat self-hosting).
Decision
Same binary on every node; one primary is the single source of truth, others are replicas.
Binary includes API + panel + engine. Roles: node, witness, edge (combinable). All writes go through the primary; replicas hold a copy and serve reads, and forward writes to the primary. If the primary dies, apps keep running; only changes are blocked until a replica is promoted. The static panel can be hosted anywhere, but the stateful API stays self-hosted.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0012 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0013: State replication is continuous over an append-only, numbered, hash-chained log
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0013
- Tags: architecture, control-plane, reliability
- Supersedes: none
- Superseded by: none
- Related: none
Context
Periodic sync would lose everything since the last sync when the primary dies, including the audit log.
Decision
State replication is continuous over an append-only, numbered, hash-chained log.
Every state change is a numbered entry; replicas ask ‘give me everything after N’ and verify the hash chain (tamper-evident).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0013 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0014: Telemetry (app logs, metrics) stays on each node; only state is replicated
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0014
- Tags: architecture, observability
- Supersedes: none
- Superseded by: none
- Related: none
Context
Shipping all logs to the primary would saturate it and make it the weak point.
Decision
Telemetry (app logs, metrics) stays on each node; only state is replicated.
Panel queries node telemetry through the primary on demand; optional archive to S3/R2.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0014 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0015: SQLite first; state stored as a numbered change log
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0015
- Tags: architecture, control-plane
- Supersedes: none
- Superseded by: none
- Related: none
Context
‘One binary, one command’ conflicts with requiring Postgres; single leader in MVP; supporting two DBs with sqlx compile-time macros is painful.
Decision
SQLite first; state stored as a numbered change log.
Bonus: Cloudflare D1 is SQLite-based. Backup of the control plane itself to S3/R2 (Litestream was suggested; to verify vs own chunk engine).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0015 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0016: Leader election roadmap: single node in MVP, manual promotion in M5, Raft auto-failover with >=3 votes later
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0016
- Tags: reliability, control-plane
- Supersedes: none
- Superseded by: none
- Related: none
Context
Automatic election with only 2 nodes causes split-brain (both become primary after a network cut).
Decision
Leader election roadmap: single node in MVP, manual promotion in M5, Raft auto-failover with >=3 votes later.
Manual promotion = command/button, or an agent with human approval. Automatic failover only with a majority (Raft).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0016 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0017: HA requires at least 3 votes; with 2 nodes Quoro tells the user and offers a witness
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0017
- Tags: reliability, witness
- Supersedes: none
- Superseded by: none
- Related: none
Context
Majority voting needs an odd number of voters; the witness only breaks ties and stores no data.
Decision
HA requires at least 3 votes; with 2 nodes Quoro tells the user and offers a witness.
Witness options: (1) lightweight free daemon on any machine, must be in a different failure domain than the nodes (a home PC next to node A fails together with it); (2) optional external lease arbiter: Cloudflare Durable Object (strong consistency) or S3/R2 conditional writes (to verify). The self-hosted witness is free and first-class.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0017 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0018: The edge/LB can be the third voter (role witness+edge of the same binary)
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0018
- Tags: reliability, witness, ingress
- Supersedes: none
- Superseded by: none
- Related: none
Context
It sits in a third location, sees both nodes, and decides where traffic goes, so its health view is what users experience. The maintainer’s idea: 2 VPS + 1 small LB VPS.
Decision
The edge/LB can be the third voter (role witness+edge of the same binary).
Caveat: a single edge is a traffic SPOF. Mitigations: Cloudflare as the traffic edge with the small VPS only as witness, or two edges with DNS failover.
Some wording was changed for the public repository. Some operational details were redacted for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0018 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0019: Fencing is mandatory and lease-based; node agent self-fences from M0
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0019
- Tags: reliability, node-agent, fencing
- Supersedes: none
- Superseded by: none
- Related: none
Context
Starting a stateful app on node B while it still runs isolated on A produces two diverging copies (data corruption).
Decision
Fencing is mandatory and lease-based; node agent self-fences from M0.
A node that loses its lease stops its own stateful apps. The same lease mechanism serves primary election and per-app failover. The node-agent protocol must include self-fencing from M0 so it is not redesigned later.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0019 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0020: Per-app HA policies: none / cold / warm / hot, with cost shown in the panel
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0020
- Tags: reliability, ha-policy
- Supersedes: none
- Superseded by: none
- Related: none
Context
Zero downtime requires reserving resources on other nodes; users choose the trade-off per app.
Decision
Per-app HA policies: none / cold / warm / hot, with cost shown in the panel.
none: nothing on other nodes. cold: backups only (RPO since last backup, RTO = restore). warm: data continuously replicated, image pulled, stopped (RPO seconds, RTO = microVM boot; costs disk). hot: running + replicating (near-zero RPO/RTO; costs disk+RAM+CPU). Async replication by default; synchronous optional for nearby nodes (adds latency to every write). Suggested defaults: stateless apps get replicas behind the LB when >=2 nodes; stateful apps get warm. Stateless apps: replicas on several nodes, edge LB stops sending traffic to the dead node (zero downtime).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0020 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0021: N+1 capacity indicator: ‘if node A dies, do its apps fit on the others?’
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0021
- Tags: reliability, ha-policy, panel
- Supersedes: none
- Superseded by: none
- Related: none
Context
Users must be told how much space/RAM HA reserves; no self-hosted tool does this well; ideal for an agent to watch.
Decision
N+1 capacity indicator: ‘if node A dies, do its apps fit on the others?’.
Example: ‘yes’ / ‘no, you are missing 6 GB of RAM’.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0021 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0022: Failback is never automatic or immediate; returning node rejoins as replica and handover is planned
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0022
- Tags: reliability, failback
- Supersedes: none
- Superseded by: none
- Related: none
Context
Node A does not have the data B collected while A was down; A may also hold writes B never received (two histories).
Decision
Failback is never automatic or immediate; returning node rejoins as replica and handover is planned.
Sequence: (1) A returns fenced, checks lease, starts nothing stateful. (2) A joins as replica of B (B is now source of truth). (3) A’s divergent writes are quarantined in a separate snapshot and recorded in the audit log, never silently dropped. (4) Incremental resync from the last common point; A becomes warm. (5) Default: no failback, roles simply swap. (6) If A is preferred: planned switchover (‘relevo inteligente’): wait for lag ~0, wait for a low-traffic window (user-chosen or agent-proposed with approval), freeze writes for seconds, final sync, move lease, LB shifts traffic, drain connections. Planned switchover loses no data; data loss of seconds only happens in surprise failures. Stateless: LB slow-starts traffic back to A.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0022 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0023: Sync engine is our own: control-plane log replication + a content-addressed chunk engine for volumes
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0023
- Tags: sync, reliability, backups
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer wants everything own and auditable; one engine serves both replication and backups.
Decision
Sync engine is our own: control-plane log replication + a content-addressed chunk engine for volumes.
Volumes: snapshot -> split into content-addressed chunks (dedup, encrypted) -> ship only new chunks. To another node = warm replication; to S3/R2 = cold backup. Start with incremental snapshots every few seconds (RPO = interval). Exception: databases use their native replication orchestrated by Quoro (Postgres streaming, Litestream for SQLite), offered as catalog templates; block-level copies of a running DB are only crash-consistent.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0023 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0024: Ingress: reverse proxy with ACME for public-IP hosts, Cloudflare Tunnel for homelabs behind NAT
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0024
- Tags: ingress
- Supersedes: none
- Superseded by: none
- Related: none
Context
The two target users need different ingress; Tunnel avoids opening ports and TLS fights.
Decision
Ingress: reverse proxy with ACME for public-IP hosts, Cloudflare Tunnel for homelabs behind NAT.
Proxy implementation open (Caddy via admin API is the proven option; embedded Rust proxy is the alternative). Multiple cloudflared replicas on the same tunnel give basic failover; Cloudflare paid LB adds real health checks (pricing/behavior to verify). Both behind a common interface.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0024 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0025: Provider interfaces (DNS, LB, S3-compatible storage) with Cloudflare as the first implementation, not the only one
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0025
- Tags: providers, oss
- Supersedes: none
- Superseded by: none
- Related: none
Context
Otherwise the community reads it as vendor lock-in.
Decision
Provider interfaces (DNS, LB, S3-compatible storage) with Cloudflare as the first implementation, not the only one.
Backups target any S3-compatible store (R2, B2, MinIO).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0025 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0026: Curated catalog in the repo instead of running an own image registry (for now)
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0026
- Tags: catalog, security, oss
- Supersedes: none
- Superseded by: none
- Related: none
Context
Running a registry makes us responsible for the supply chain and costs infra. A catalog is also a community contribution engine (like Coolify one-click services).
Decision
Curated catalog in the repo instead of running an own image registry (for now).
Templates point to pinned upstream images with verifiable signatures.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0026 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0027: Signed binaries and reproducible builds are mandatory
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0027
- Tags: security, release, oss
- Supersedes: none
- Superseded by: none
- Related: none
Context
Installer is curl | sh and the tool controls infrastructure.
Decision
Signed binaries and reproducible builds are mandatory.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0027 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0028: Baseline stack (to be confirmed in the architecture doc)
- Status: Superseded by ADR 0056
- Date: 2026-11-02
- Ledger id: D-0028
- Tags: stack
- Supersedes: none
- Superseded by: ADR 0056
- Related: none
Context
Rust for everything touching infra; React panel embedded in the binary.
Decision
Baseline stack (to be confirmed in the architecture doc).
Control plane/API: Rust Axum + Tokio, sqlx + SQLite. Node agent: Rust static binary. MCP: rmcp (official Rust SDK). API contract: OpenAPI via utoipa -> TS client (orval or openapi-ts). Panel: React + TanStack Router/Query/Table, shadcn/ui + Tailwind, embedded with rust-embed. Consoles/metrics: xterm.js, noVNC, uPlot. Realtime: WebSockets or SSE. bollard (Docker) no longer needed since there is no Docker on nodes. The maintainer prefers minimal dependencies; every dependency must be justified.
Some wording was changed for the public repository.
Consequences
- Superseded by ADR 0056; this decision is no longer binding and is kept for history.
Generated from decision D-0028 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0029: Agents are first-class identities, separate from humans
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0029
- Tags: agents, security
- Supersedes: none
- Superseded by: none
- Related: none
Context
Know which agent did what, when, on whose behalf and why.
Decision
Agents are first-class identities, separate from humans.
Agent entity: name, model, owner. Every action records who (agent), on behalf of whom (human who authorized), why (event/incident that woke it).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0029 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0030: Grants: scope + resource selector + TTL (+ optional max uses); incident-scoped; JIT approval for destructive actions
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0030
- Tags: agents, security, auth
- Supersedes: none
- Superseded by: none
- Related: none
Context
Agents must have very well defined scopes and not forever.
Decision
Grants: scope + resource selector + TTL (+ optional max uses); incident-scoped; JIT approval for destructive actions.
Example: app:restart on project=blog for 1h. When an event wakes an agent it gets an incident grant with just the needed scopes, auto-expiring. Destructive/irreversible actions are off by default: the agent files an approval request, a human approves in the panel, the agent gets a short-lived extra grant. Tokens are short-lived and refreshed against the grant; revoking the grant locks the agent out within seconds. Scope examples: read, restart, redeploy, delete.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0030 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0031: Audit log: append-only, hash-chained, optionally copied to S3/R2 with object lock; MCP uses the same path
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0031
- Tags: agents, audit, security
- Supersedes: none
- Superseded by: none
- Related: none
Context
Everything must be auditable and loggable; tampering must be evident.
Decision
Audit log: append-only, hash-chained, optionally copied to S3/R2 with object lock; MCP uses the same path.
Entry: who, on behalf of whom, triggering event, tool/endpoint, parameters, result, agent’s stated justification. Off-site copy out of reach of whoever operates the server. MCP has no separate path: same service layer, same log.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0031 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0032: AI-native loop: detect -> notify -> wake agent -> act within limits
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0032
- Tags: agents, events
- Supersedes: none
- Superseded by: none
- Related: none
Context
Events close the loop by waking an agent.
Decision
AI-native loop: detect -> notify -> wake agent -> act within limits.
Detect: node agent + health checks report state/metrics. Notify: configurable webhooks per event (VM down, build failed, disk full, certificate expiring). Wake: webhook starts an agent (e.g. Claude Agent SDK) with Quoro’s MCP and incident context. Act: only what its grant allows; destructive needs human approval.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0032 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0033: Milestones M0-M6; HA and backups out of MVP but the MVP architecture prepares them
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0033
- Tags: roadmap
- Supersedes: none
- Superseded by: none
- Related: none
Context
Reliability is non-negotiable, but HA is expensive; scope is the main project risk.
Decision
Milestones M0-M6; HA and backups out of MVP but the MVP architecture prepares them.
M0 Contracts: repo, CI, cargo workspace, OpenAPI spec, DB schema, event schema, node-agent protocol (incl. self-fencing) + spike ‘boot a microVM from an OCI image’. M1 Skeleton: one-command installer, engine create/start/stop/delete, basic panel. M2 Deploy: node agent, deploy an OCI image with domain + TLS, live logs. M3 AI-native: MCP read + restart/redeploy, scoped grants, audit log, event webhooks. M4 Full experience: Git builds (Dockerfile; Railpack/Nixpacks to verify), metrics, web terminal, VM console. M5a: stateless replicas + edge LB + backups to S3/R2 (cold). M5b: witness, leases, fencing, replicated volumes (warm), capacity indicator. M6: automatic failover + hot. M1-M3 is already a self-explaining demo: ‘I told an agent the app was down, it restarted it, and it is in the audit log’.
Some operational details were redacted for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0033 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0034: Out of scope for now
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0034
- Tags: roadmap, scope
- Supersedes: none
- Superseded by: none
- Related: none
Context
Each could eat the project.
Decision
Out of scope for now.
Live migration; distributed storage (Ceph); complex networking (SDN, VLANs); multi-tenant with billing; writing an own VMM.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0034 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0036: Anti-drift rules
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0036
- Tags: process
- Supersedes: none
- Superseded by: none
- Related: none
Context
Keep the project from losing its direction.
Decision
Anti-drift rules.
Accepted ADRs are law; changing one requires a new ADR approved by the maintainer. Contracts freeze after M0; contract changes are standalone PRs reviewed on their own. Each workstream owns its paths. GitHub Issues + Milestones are the task source of truth (each PR closes an issue). Progress is reported in a fixed format (done / next / blocked / decisions needed). Each milestone ends with a demo on the test lab and the maintainer’s approval before the next starts. Done = runs on the lab, not ‘it compiles’.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0036 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0037: Git conventions
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0037
- Tags: process, git
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer’s working rules.
Decision
Git conventions.
Commits <type>: <description>. gh stack only within a workstream; parallel workstreams are separate PRs. The maintainer merges. PR test plans contain only checked boxes. If the previous PR was merged, new branch + PR from the updated base.
Some operational details were redacted for the public repository. Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0037 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0040: Write the bases before any code
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0040
- Tags: process, docs
- Supersedes: none
- Superseded by: none
- Related: none
Context
So no point is lost before parallel work starts.
Decision
Write the bases before any code.
Documents: decision ledger (written from the design conversation; the maintainer reviews), PRD v2, one ADR per decision, architecture (roles, control vs data plane, flows), contracts (OpenAPI, event schema, node-agent protocol, audit-log format, permission model, Engine trait), threat model, roadmap with verifiable DoD per milestone, one brief per workstream (owned paths, contracts consumed, DoD, forbidden actions), glossary, OSS files (LICENSE AGPL, CONTRIBUTING + CLA, SECURITY.md, CoC), repo AGENTS.md/CLAUDE.md. Every ‘to verify’ claim is researched before entering a document; unconfirmed ones are marked as hypotheses. Pipeline: verification -> parallel drafts -> consistency and completeness reviews -> maintainer review -> first docs PR.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0040 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0042: Repo AGENTS.md must carry the maintainer’s working rules, because non-Claude agents never see the maintainer’s CLAUDE.md
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0042
- Tags: process, agents-md
- Supersedes: none
- Superseded by: none
- Related: none
Context
Non-Claude implementers do not receive the maintainer’s CLAUDE.md; only Claude agents do.
Decision
Repo AGENTS.md must carry the maintainer’s working rules, because non-Claude agents never see the maintainer’s CLAUDE.md.
Include at least: verify by running it before calling anything done; search before creating files/components; commit format <type>: <description>; only checked boxes in PR test plans; print target environment before any DB write; visual work avoids the generic look (no cream backgrounds, no italic words in headlines, no 01/02/03 or monospace labels, no pill buttons) and is checked rendered; ambiguous or destructive actions are confirmed first.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0042 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0044: Realtime transport (WebSockets vs SSE) stays open until the architecture doc
- Status: Superseded by ADR 0057
- Date: 2026-11-02
- Ledger id: D-0044
- Tags: roadmap, stack
- Supersedes: none
- Superseded by: ADR 0057
- Related: none
Context
Listed as ‘WebSockets o SSE’ in the original PRD without a decision.
Decision
Realtime transport (WebSockets vs SSE) stays open until the architecture doc.
Consequences
- Superseded by ADR 0057; this decision is no longer binding and is kept for history.
Generated from decision D-0044 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0047: Leases have two modes: local (HA none/cold, never expire on control-plane loss) and expiring (warm/hot, self-fence on expiry)
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0047
- Tags: reliability, node-agent, fencing
- Supersedes: none
- Superseded by: none
- Related: none
Context
D-0019 says a node that loses its lease stops its stateful apps and must self-fence from M0. D-0011 and the M2 demo say apps keep running when the control plane is gone. With one control plane (M1-M4), a lease that expires when the control plane is unreachable would stop every stateful app whenever the panel is down.
Decision
Leases have two modes: local (HA none/cold, never expire on control-plane loss) and expiring (warm/hot, self-fence on expiry).
Every stateful app has a lease from M0, and the node-agent protocol carries lease_grant / lease_renew / lease_revoke and the self-fence path from M0. mode=local: issued for HA policy none or cold, where no other copy can start; it never expires because the arbiter is unreachable; it ends only by explicit lease_revoke (move, delete, planned switchover) or by an operator declaring the node lost (manual fence acknowledgement, required before a cold restore elsewhere). mode=expiring (M5b+): issued for warm/hot; duration-based on the node’s monotonic clock with a safety margin; node states held -> uncertain (renewal failing, arbiter unreachable: keep running, start nothing new) -> expired (self-fence: stop stateful VMs) ; revoked = explicit. Fencing tokens are monotonic per lease and carried on every write to shared storage (chunk store, object store, replication stream); receivers reject stale tokens. Stateless apps need no lease. Detailed in docs/contracts/node-agent-protocol.md.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0047 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0048: API contract source of truth: hand-written docs/contracts/openapi.yaml is frozen; utoipa output is diffed against it in CI; orval generates the panel client
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0048
- Tags: stack, api
- Supersedes: none
- Superseded by: none
- Related: none
Context
D-0028 says OpenAPI via utoipa (code-first); D-0033/D-0036 make the OpenAPI spec an M0 contract that freezes before code exists. Both hold only if the hand-written spec is the reference and the generated one must match it.
Decision
API contract source of truth: hand-written docs/contracts/openapi.yaml is frozen; utoipa output is diffed against it in CI; orval generates the panel client.
M0 freezes docs/contracts/openapi.yaml. The control plane annotates handlers with utoipa; CI generates the document and fails on any semantic diff against the frozen file. Every response carries a non-empty description (works around utoipa #1636 / orval rejection, F-0042). TS client: orval (MIT, 8.x) over @hey-api/openapi-ts (pre-1.0), both handle 3.1 (docs/research/rust-api-and-mcp-tooling.md). Pin rmcp to a minor version and budget major upgrades (F-0041). This refines D-0028’s ‘orval or openapi-ts’; the noVNC removal is decided separately in D-0056.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0048 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0049: Agent runner (D-0032 wake): reference runner in examples/agent-runner/, owned by the mcp workstream, outside the quoro binary
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0049
- Tags: agents, mcp, events
- Supersedes: none
- Superseded by: none
- Related: none
Context
D-0032 says a webhook starts an agent (e.g. Claude Agent SDK) with Quoro’s MCP and incident context, but no workstream owns the receiver and no document specifies it; the M3 demo cannot be built without it.
Decision
Agent runner (D-0032 wake): reference runner in examples/agent-runner/, owned by the mcp workstream, outside the quoro binary.
Options: (a) reference runner shipped as an example (examples/agent-runner/), not in the quoro binary; owned by the mcp workstream; one M3 DoD box. (b) built into the quoro binary: Quoro would hold LLM-provider credentials and an agent runtime on infrastructure nodes, a larger attack surface and more dependencies (D-0028 ‘every dependency justified’). (c) leave it to users: the M3 demo has no runner. Runner contract for (a): it receives a webhook delivery and verifies Quoro-Signature (contracts README, Request headers); reads the event and WebhookWake.agent_id plus incident_id from the payload; holds that agent’s own agent key (akey_), configured by the operator at the runner, because Quoro never sends credentials in a webhook; mints a 5-minute access token via POST /api/v1/auth/agent-token; acts through Quoro’s MCP endpoint within the incident grant the webhook created, passing incident_id and justification on every mutating tool. Accepted option: (a).
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0049 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0050: Cluster keys: separate Ed25519 signing key and XChaCha20-Poly1305 data-encryption key, generated at setup, stored as 0600 files outside SQLite, plus a node-local key
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0050
- Tags: security, control-plane, node-agent
- Supersedes: none
- Superseded by: none
- Related: none
Context
The contracts used ‘cluster key’ both to sign desired_state (Ed25519) and to encrypt app_env.value_enc and webhooks.secret_enc; one Ed25519 key cannot encrypt, and no document fixes algorithms, generation, storage, backup, rotation or custody on promotion. M2 needs both (signed desired_state, encrypted env).
Decision
Cluster keys: separate Ed25519 signing key and XChaCha20-Poly1305 data-encryption key, generated at setup, stored as 0600 files outside SQLite, plus a node-local key.
(1) cluster signing key, Ed25519, signs desired_state and lease messages; nodes pin its public key at enrollment. (2) cluster data-encryption key (DEK), 256-bit, AEAD XChaCha20-Poly1305, encrypts *_enc columns; the column stores nonce||ciphertext and a key id so rotation can re-encrypt lazily. (3) node-local key per node, encrypts the node’s local copy of desired-state secrets at rest. Generation: at first start of the primary (before /setup), never derived from a password. Storage: files mode 0600 owned by the quoro user in the data directory, outside the SQLite file, so a database copy alone does not reveal secrets. Backup: the operator must back up the key files separately from the database; losing the DEK loses every secret (state it in the installer output and docs). Rotation: signing-key rotation is a key-change message signed by the old key that nodes accept; DEK rotation re-encrypts rows under a new key id. Custody on promotion (ties to Q-0024): (a) replicate both keys to replicas, wrapped to each replica’s node key, so a promoted replica keeps signing with the same key; (b) re-key on promotion and have nodes accept the new key through an operator-confirmed re-pin. Algorithms, generation, storage, backup and rotation are decided as stated. Custody on promotion stays open and is settled together with Q-0024 (recommended: (a)).
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0050 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0051: Provider credentials: a human-only providers resource (API + replicated table with secret_enc) added to the contract before the M0 freeze
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0051
- Tags: providers, ingress, security
- Supersedes: none
- Superseded by: none
- Related: none
Context
D-0024/D-0025 need Cloudflare API tokens, tunnel tokens, DNS zones and S3 credentials, but no API, table or CLI says where they are entered, stored, scoped or rotated; the M2 Tunnel path needs one.
Decision
Provider credentials: a human-only providers resource (API + replicated table with secret_enc) added to the contract before the M0 freeze.
Options: (a) a providers resource before the freeze: POST/GET/DELETE /api/v1/providers (kind: dns | tunnel | lb | object_store; config; secret write-only), new human-only scope provider:manage, table providers(id, kind, name, config_json, secret_enc, created_at, rotated_at) replicated through the change log and encrypted with the cluster data-encryption key; secrets never returned by the API and redacted from audit params; the audit-copy credential is a separate put-only provider entry (threat model T-PRV-02). (b) M2 supports one path whose credential is supplied at install time (installer flag or a 0600 file), and the providers resource is a post-freeze standalone contract PR before M5a. Accepted option: (a), because the M2 DoD already allows the Tunnel path and a post-freeze PR for a security-relevant resource is the riskier order.
Some wording was changed for the public repository. Some operational details were redacted for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0051 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0052: Local persistent volumes (non-replicated) ship with M5a, before the cold-backup DoD; until then the PRD states no persistent app data
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0052
- Tags: engine, sync, roadmap
- Supersedes: none
- Superseded by: none
- Related: none
Context
Volumes were shape-only until M5, desired_state.volumes is [] before M5, and no endpoint creates or attaches a volume, yet the M5a DoD backs up a stateful app with data; from M2 to M4 a database app loses its data on every redeploy.
Decision
Local persistent volumes (non-replicated) ship with M5a, before the cold-backup DoD; until then the PRD states no persistent app data.
Options: (a) M5a: local non-replicated volumes (create, attach to an app, survive redeploy) arrive with cold backups; standalone post-freeze contract PR before M5a; PRD non-goals say ‘no persistent app data before M5a’. (b) M4: volumes ship with the full experience, so the first public release (Q-0006) can run a database; adds scope to M4. (c) M2: volumes with the first deploy; adds scope to the riskiest milestone. Accepted option: (a), the minimal reading of D-0033.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0052 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0053: Off-site audit-log copy (D-0031) ships in M5a with the S3-compatible path, gated by an object-lock probe per provider
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0053
- Tags: audit, roadmap, security
- Supersedes: none
- Superseded by: none
- Related: none
Context
audit-log-format.md, permission-model.md, db-schema.sql and architecture say M5a, while the roadmap says D-0033 assigns it to no milestone; D-0031’s off-site copy has no DoD anywhere, and object-lock support per provider is unresearched.
Decision
Off-site audit-log copy (D-0031) ships in M5a with the S3-compatible path, gated by an object-lock probe per provider.
Options: (a) M5a, with the first S3-compatible client (cold backups): DoD ‘export one audit segment with object lock to a bucket; an overwrite or delete is refused; tail truncation of the local log is detected against the exported copy’. (b) M3, with the audit log itself: needs an S3 client two milestones early. (c) leave unscheduled. Prerequisite for (a): a VERIFY research task on object lock in compliance mode for R2, S3, B2 and MinIO before M5a. Accepted option: (a).
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0053 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0054: Host layout and CLI: one TLS listener for API/panel/MCP on 8443, self-signed primary certificate pinned by join commands, /var/lib/quoro + /etc/quoro/quoro.toml, one-time setup token for /setup
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0054
- Tags: installer, control-plane, security
- Supersedes: none
- Superseded by: none
- Related: none
Context
No document fixed the listener port and its coexistence with ingress on 80/443, the primary’s TLS certificate source, data/config paths, the config format or the CLI command set; POST /api/v1/setup is public while the users table is empty, so anyone who reaches the listener first can claim owner.
Decision
Host layout and CLI: one TLS listener for API/panel/MCP on 8443, self-signed primary certificate pinned by join commands, /var/lib/quoro + /etc/quoro/quoro.toml, one-time setup token for /setup.
Listener: one HTTPS port (default 8443, configurable) for API, panel and MCP; ingress owns 80/443 on the same host. Primary TLS: a self-signed certificate generated at install; the join command carries its SHA-256 fingerprint (the pin model of architecture section 5); rotating it invalidates outstanding join commands, which the CLI states; an operator may replace it with an ACME certificate later. Paths: data /var/lib/quoro (SQLite, key files 0600), config /etc/quoro/quoro.toml (TOML), logs via journald. CLI (binary quoro): ‘quoro serve –role node|witness|edge’ (systemd), ‘quoro version’, ‘quoro join <url> –token –fingerprint’, ‘quoro mcp stdio’ (MCP over stdio with an agent key), ‘quoro setup-token’ (prints the one-time token). First run: the installer prints a one-time setup token; POST /api/v1/setup requires it (header Quoro-Setup-Token); single use, expires after 1 hour; add a threat-model row for an unclaimed setup. Alternatives: ACME for the primary listener from install (needs a public name, fails behind NAT); /setup bound to localhost only (breaks remote installs). Accepted as stated.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0054 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0055: Lab TLS source for the M2 deploy DoD: Cloudflare Tunnel with a test domain; ACME exercised separately via DNS-01 against a test zone
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0055
- Tags: ingress, lab, roadmap
- Supersedes: none
- Superseded by: none
- Related: none
Context
The lab VMs have no public IP, so ACME HTTP-01 cannot run there; the roadmap left the choice open.
Decision
Lab TLS source for the M2 deploy DoD: Cloudflare Tunnel with a test domain; ACME exercised separately via DNS-01 against a test zone.
Options: (a) Cloudflare Tunnel with a test domain: matches D-0024’s homelab-behind-NAT case; needs a tunnel token (stored as a provider credential, D-0051). (b) ACME DNS-01 against a test zone: exercises the ACME path from the lab; needs a DNS provider token and DNS-01 support in M2. (c) a lab-only internal CA: exercises neither real path. Accepted: (a) for the M2 DoD; (b) as a separate ingress task before M2 closes; never (c). Needs a test domain the maintainer controls.
Some wording was changed for the public repository. Some operational details were redacted for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0055 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0056: Baseline stack without noVNC: consoles are text-only (xterm.js) because Cloud Hypervisor has no VNC or display device
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0056
- Tags: stack, engine, console
- Supersedes: ADR 0028
- Superseded by: none
- Related: none
Context
Q-0010 research (docs/research/cloud-hypervisor.md): Cloud Hypervisor v54 has no VNC, SPICE, virtual GPU or framebuffer device; consoles are serial/virtio-console only. noVNC in D-0028 would have nothing to attach to. The maintainer accepted it on 2026-11-02.
Decision
Baseline stack without noVNC: consoles are text-only (xterm.js) because Cloud Hypervisor has no VNC or display device.
Same as D-0028 except the consoles line. Control plane/API: Rust Axum + Tokio, sqlx + SQLite. Node agent: Rust static binary. MCP: rmcp (official Rust SDK). API contract: OpenAPI via utoipa -> TS client (orval or openapi-ts). Panel: React + TanStack Router/Query/Table, shadcn/ui + Tailwind, embedded with rust-embed. Consoles: xterm.js attached to the microVM serial or virtio-console UNIX socket, relayed by the node agent (or a vsock in-guest agent). No noVNC: Cloud Hypervisor has no graphical console; a graphical console would need a guest-side VNC server or GPU passthrough and is out of scope until an Engine with a display device exists. Metrics: uPlot. Realtime: decided separately in D-0057. bollard not needed. Every dependency must be justified.
Some wording was changed for the public repository. Some operational details were redacted for the public repository.
Consequences
- Replaces ADR 0028.
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0056 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0057: Realtime transport: SSE for one-way streams, WebSockets for bidirectional sessions
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0057
- Tags: stack, architecture
- Supersedes: ADR 0044
- Superseded by: none
- Related: none
Context
D-0044 left WebSockets vs SSE open until the architecture doc. The two carry different traffic: most panel streams are server-to-client, a few sessions are interactive. The maintainer accepted it on 2026-11-02.
Decision
Realtime transport: SSE for one-way streams, WebSockets for bidirectional sessions.
SSE (text/event-stream) for server-to-client streams: the event stream (GET /api/v1/events/stream), live app logs (GET /api/v1/apps/{app_id}/logs/stream), deployment progress. Reason: plain HTTP, passes reverse proxies and Cloudflare without upgrade handling, auto-reconnect with Last-Event-ID maps directly to the event/log seq cursor. WebSockets for bidirectional interactive sessions only: web terminal and VM console (M4, xterm.js). The node-agent dial-out session (node-agent-protocol.md) also uses a WebSocket, but that is a protocol choice outside D-0044’s panel scope. axum serves both natively (axum sse module and ws feature; not re-verified in docs/research, hypothesis). Cost: two mechanisms instead of one; benefit: no WebSocket for the common read-only case. Referenced in docs/architecture/architecture.md section Realtime transport.
Some wording was changed for the public repository.
Consequences
- Replaces ADR 0044.
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0057 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0059: Q-0001: embedded Rust reverse proxy behind the Ingress interface; Caddy admin API as fallback backend
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0059
- Tags: ingress
- Supersedes: none
- Superseded by: none
- Related: none
Context
D-0012 one binary, D-0028 minimal dependencies, D-0007 rejected a Go daemon black box; the proxy needs health-aware routing and slow-start for D-0020/D-0022.
Decision
Q-0001: embedded Rust reverse proxy behind the Ingress interface; Caddy admin API as fallback backend.
Options: (a) Caddy driven via its admin API: proven ACME (HTTP-01/TLS-ALPN-01, renewals), on-demand TLS; but a second Go process to ship, sign and update (D-0027 covers only our binary), and routing logic split across two processes. (b) Embedded Rust proxy (hyper + rustls + an ACME client crate): one binary, full control of health/slow-start/drain; more code we own, ACME edge cases are ours. Crate maturity was not researched (hypothesis) -> research task before M2. Accepted option: (b), behind the same Ingress trait that the Cloudflare Tunnel backend implements, keeping (a) as an alternative backend if the M2 spike shows ACME is too costly.
Some wording was changed for the public repository. Some operational details were redacted for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0059 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0060: Q-0002: Dockerfile-only builds in M4, run inside a builder microVM; re-verify Railpack/Nixpacks before M4
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0060
- Tags: builds
- Supersedes: none
- Superseded by: none
- Related: none
Context
D-0033 starts M4 with Dockerfile builds; D-0008 forbids Docker on nodes, so builds cannot use a host Docker daemon. Railpack and Nixpacks were not researched in the bases pipeline.
Decision
Q-0002: Dockerfile-only builds in M4, run inside a builder microVM; re-verify Railpack/Nixpacks before M4.
Options: Dockerfile only; Railpack; Nixpacks. Accepted option: Dockerfile only for M4, executed by a BuildKit daemon inside an ephemeral builder microVM (hypothesis: not yet tried in Cloud Hypervisor), output pushed to a node-local OCI layout and fed to the normal OCI->rootfs path. Railpack/Nixpacks: hypothesis that Nixpacks is in maintenance with Railpack as its successor; verify before M4 and add a zero-config builder as a second Builder implementation if it holds.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0060 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0061: Q-0003: write our own minimal init and OCI-to-ext4 pipeline, with in-root path resolution as a required hardening item
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0061
- Tags: engine
- Supersedes: none
- Superseded by: none
- Related: none
Context
M0 spike (PR #15) booted two OCI images end to end on the test lab with a 1-dependency Rust init and a ~1.4k-line pipeline; the only hard part found is resolving absolute symlinks inside the rootfs
Decision
Q-0003: write our own minimal init and OCI-to-ext4 pipeline, with in-root path resolution as a required hardening item.
Evidence: spikes/oci-microvm (PR #15). The init (libc only, 413 KiB static) covers most of engine-trait.md section 6 (mounts, env, user, workdir, tagged stdout/stderr on hvc0, reaping, SIGTERM forwarding, exit status, FATAL then poweroff); network config and the ACPI power button remain. The pipeline (oci-client + tar + flate2 + sha2 + mkfs.ext4 -d) converts alpine in 1.7 s (1.5 s pull), handles whiteouts/opaque dirs and User resolution. Gaps to close in crates/engine: resolve layer paths inside the rootfs (openat2 RESOLVE_IN_ROOT) instead of refusing absolute-symlink parents; zstd layers; config delivery via config disk or cmdline instead of baking /quoro/config into the rootfs; oci-client friction is acceptable in the async node agent. Kernel: start from the prebuilt cloud-hypervisor/linux vmlinux config, but build and pin our own reproducibly (D-0027). Reuse alternatives (Kata agent, umoci/skopeo) not needed on this evidence.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0061 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0062: Q-0004: reflink snapshots + full chunk scan for M5a cold backups; spike lvm-thin vs btrfs before M5b warm
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0062
- Tags: sync, engine
- Supersedes: none
- Superseded by: none
- Related: none
Context
F-0035: Cloud Hypervisor snapshots hold RAM and device state, not disk contents, and expose no dirty bitmap; the chunk engine needs its own point-in-time disk primitive. Warm (RPO seconds, D-0020) needs changed-block information, cold backups do not.
Decision
Q-0004: reflink snapshots + full chunk scan for M5a cold backups; spike lvm-thin vs btrfs before M5b warm.
Options: (a) own block dirty tracking: needs a host block layer we control (e.g. dm-era or our own vhost-user-blk backend), large; (b) lvm-thin snapshots + thin_delta; (c) ZFS send (out-of-tree module on Debian, hypothesis); (d) btrfs snapshots/send; (e) reflink file copy (XFS/btrfs) + content-defined chunking of the whole file: simple, reads the full volume per run. Accepted option: (e) for M5a cold backups (interval in minutes); before M5b run a spike comparing (b) and (d) for few-second intervals. Engine trait exposes snapshot_volume / diff hooks so the primitive is swappable. The spike chooses the primitive for M5b.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0062 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0063: Q-0005: kernel WireGuard configured by Quoro over netlink; mesh deferred to M5a when cross-node traffic starts
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0063
- Tags: networking
- Supersedes: none
- Superseded by: none
- Related: none
Context
Hosts already need kernel >= 6.8 for Cloud Hypervisor (F-0036) and WireGuard has been in mainline since 5.6 (hypothesis, not re-verified), so the kernel module is always present; writing our own WireGuard is a crypto risk.
Decision
Q-0005: kernel WireGuard configured by Quoro over netlink; mesh deferred to M5a when cross-node traffic starts.
Options: (a) kernel WireGuard driven by Quoro via netlink (no wg-quick), per-node keypair, peers and allowed-IPs distributed in desired state; (b) userspace boringtun over TUN: portable, slower, one more dependency; (c) own implementation: rejected for security. NAT: two nodes behind NAT cannot reach each other directly; at least one publicly reachable peer (public node or edge) acts as relay hub, or traffic falls back to the dial-out path. The control-plane session does not depend on the mesh (TLS dial-out, D-0011), so M2 (single app node) does not need the mesh; it is first needed for app replicas and replication in M5a. Accepted option: (a), with (b) only as a fallback backend.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0063 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0064: First public release is an M3 preview (v0.1, experimental, not production); M4 complete is the first general release
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0064
- Tags: product, roadmap
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. M3 already shows the whole AI-native story (D-0033); self-use/experimenter audience first (D-0003).
Decision
First public release is an M3 preview (v0.1, experimental, not production); M4 complete is the first general release.
Tag v0.1.0 at M3 as an experimental preview with signed builds (D-0027); announce broadly only at M4. Domains and trademark checks (Q-0008) must be done before the M3 tag.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0064 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0065: Docs stay Markdown in the repo until the first public release; pick docs-site tooling during M3
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0065
- Tags: docs
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. No docs site is needed before there is something to install.
Decision
Docs stay Markdown in the repo until the first public release; pick docs-site tooling during M3.
Revisit at M3 planning together with the v0.1 preview (Q-0006).
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0065 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0067: Manual promotion of a replica node to primary ships in M5b, alongside leases and fencing
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0067
- Tags: roadmap, reliability
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. Promotion must first fence the old primary’s leases (D-0019); M5b is where witness, leases and fencing land.
Decision
Manual promotion of a replica node to primary ships in M5b, alongside leases and fencing.
Roadmap: add a manual-promotion DoD item to M5b; promotion refuses while the old primary may still hold expiring leases unless an operator acknowledges the fence.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0067 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0068: PRD HA table costs confirmed: none = no reserved resources; cold = backup storage and restore time
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0068
- Tags: product, reliability
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. They follow directly from the policy definitions in D-0020.
Decision
PRD HA table costs confirmed: none = no reserved resources; cold = backup storage and restore time.
Keep the PRD cells; add restore time to cold.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0068 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0069: SQLite and catalog-template backups use Quoro’s own chunk engine with client-side encryption, from consistent snapshots (SQLite backup API / VACUUM INTO)
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0069
- Tags: sync, security
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. Keeps encryption keys away from providers and avoids the unmaintained Litestream 0.3 line; D-0023 already makes the chunk engine ours. Losing Litestream’s ~1 s RPO is acceptable for the cold policy.
Decision
SQLite and catalog-template backups use Quoro’s own chunk engine with client-side encryption, from consistent snapshots (SQLite backup API / VACUUM INTO).
Litestream with SSE-C may be added later as an optional backend for users who want lower RPO. Control-plane state replication stays the change log (D-0013).
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0069 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0070: Curated catalog format, owner, location and signature scheme are decided at M4 planning, before the first template ships
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0070
- Tags: catalog
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. Nothing needs the catalog before M4; the signature scheme must be chosen together with D-0027 release signing.
Decision
Curated catalog format, owner, location and signature scheme are decided at M4 planning, before the first template ships.
Tracked as an M4 planning item; threat-model items T-SUP-04/T-VM-05 stay pending until then.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0070 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0071: Engine trait async methods return Send futures (fn … -> impl Future<Output = …> + Send), via a standalone contract PR before the M0 freeze
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0071
- Tags: engine, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. The control plane and node agent must spawn engine calls on tokio from generic code.
Decision
Engine trait async methods return Send futures (fn … -> impl Future<Output = …> + Send), via a standalone contract PR before the M0 freeze.
Also adopt as additive contract items the derives and impls M0 added (Clone/Debug/PartialEq/Eq on VmSpec and friends, MacAddr([u8;6]), ConsoleIo blanket impl, Display/Error for EngineError).
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0071 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0072: Lease timing: a node renews immediately after a first grant and treats the lease as held only after that answer; held expiring leases survive an agent restart within the same boot by persisting remaining validity against boot_id and CLOCK_BOOTTIME
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0072
- Tags: reliability, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. Removes the receipt-time assumption and keeps warm apps running across agent upgrades (fence only when boot_id changed).
Decision
Lease timing: a node renews immediately after a first grant and treats the lease as held only after that answer; held expiring leases survive an agent restart within the same boot by persisting remaining validity against boot_id and CLOCK_BOOTTIME.
Late renewal = one unanswered renewal interval D/3, and session loss moves held expiring leases to uncertain, as M0 implements. Contract update in the pre-freeze contract PR; implementation before M5b.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0072 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0073: Fencing tokens change only on ownership change: renewals by the same holder keep the token; a grant to a new holder or after expiry or revoke needs a strictly higher token
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0073
- Tags: reliability, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. Standard fencing-token semantics; keeps the equality check in node-agent-protocol 6.4 valid across renewals.
Decision
Fencing tokens change only on ownership change: renewals by the same holder keep the token; a grant to a new holder or after expiry or revoke needs a strictly higher token.
Update 5.5/5.12 in the pre-freeze contract PR and the M0 lease machine accordingly. A revoke with a lower token than the current one is refused with bad_message (as M0 does).
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0073 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0074: A failed forced stop escalates: SIGKILL the VMM process; if the VM may still run, the node never emits node.fenced, emits node.fence_failed, marks itself failed and keeps the lease unstartable
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0074
- Tags: reliability, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. node.fenced must mean the VM is not running; anything else could let a second copy start elsewhere (D-0019).
Decision
A failed forced stop escalates: SIGKILL the VMM process; if the VM may still run, the node never emits node.fenced, emits node.fence_failed, marks itself failed and keeps the lease unstartable.
The primary treats fence_failed like node loss: no re-grant elsewhere until an operator acknowledges the fence. Contract update in the pre-freeze contract PR.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0074 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0075: Concurrent fences and a mid-fence crash test are mandatory before expiring leases ship (M5b)
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0075
- Tags: reliability
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. The sequential M0 fencer can break the 8.4 bound once several expiring leases exist.
Decision
Concurrent fences and a mid-fence crash test are mandatory before expiring leases ship (M5b).
Tracked as an M5b prerequisite issue.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0075 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0076: License identifier is AGPL-3.0-only
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0076
- Tags: product, governance
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendations that closed these open questions on 2026-11-02. With a CLA in place the maintainer can relicense if needed; -only avoids binding the project to future FSF license versions it has not reviewed.
Decision
License identifier is AGPL-3.0-only.
Set license = AGPL-3.0-only in the Cargo workspace metadata and use SPDX headers with that identifier.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0076 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0077: Governance defaults for the public repo (Q-0023 items 2-6, 8, 9)
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0077
- Tags: governance, docs
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommended governance defaults on 2026-11-02.
Decision
Governance defaults for the public repo (Q-0023 items 2-6, 8, 9).
(2) CLA drops Apache’s ‘contrary to the public benefit’ clause (Quoro is not a nonprofit). (3) Remove ‘ee/ contributions are by invitation only’ from CONTRIBUTING.md (not in D-0004); say ee/ code is under its separate license and contributors should open an issue first. (5) Keep the reworded Context in the public ADR for D-0042. (6) Keep ADR 0005 under its public title. (8) SECURITY.md: acknowledge reports within 7 days, best effort, no SLA. (9) Enable GitHub private vulnerability reporting on the public repository.
Some wording was changed for the public repository. Some operational details were redacted for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0077 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0078: A promoted replica obtains the cluster signing and data-encryption keys because both are replicated to every replica node, wrapped to that node’s node-local key
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0078
- Tags: security, reliability
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-02. Keeps manual promotion (M5b) simple and preserves the signing-key pins nodes took at enrollment (D-0050); re-keying on every promotion would force operator re-pins during an incident.
Decision
A promoted replica obtains the cluster signing and data-encryption keys because both are replicated to every replica node, wrapped to that node’s node-local key.
Option (a) of the contracts README/architecture section 18. Wrapped keys travel in replicated state; a node’s node-local key never leaves it. Re-keying stays available as a deliberate operator action (signing_key_rotate). Implement before M5b.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0078 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0079: Add protocol error code fence_failed and a human-only operator acknowledgement that clears a node’s failed mark
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0079
- Tags: reliability, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-02. M0 reuses bad_message for a failed fence and has no way to clear the failed mark (D-0074).
Decision
Add protocol error code fence_failed and a human-only operator acknowledgement that clears a node’s failed mark.
Standalone contract PR in M1: new error code fence_failed in node-agent-protocol; endpoint POST /api/v1/nodes/{node_id}/fence-ack with a human-only scope, audited; until acknowledged, the primary re-grants nothing that node held.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0079 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0080: Dependency license policy for the AGPL-3.0-only core: an allowlist of permissive and AGPL-compatible licenses enforced in CI with cargo-deny, starting in M1
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0080
- Tags: governance, security
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-02. Nothing checks dependency licenses today; rmcp is moving from MIT to Apache-2.0 and the Cloud Hypervisor reading is still a hypothesis (it is a separate binary, not linked).
Decision
Dependency license policy for the AGPL-3.0-only core: an allowlist of permissive and AGPL-compatible licenses enforced in CI with cargo-deny, starting in M1.
Initial allowlist: MIT, Apache-2.0, Apache-2.0 WITH LLVM-exception, BSD-2-Clause, BSD-3-Clause, ISC, Zlib, Unicode-3.0, MPL-2.0 (file-level copyleft, allowed), AGPL-3.0-only for our own crates. cargo-deny also checks advisories and duplicate versions. Exceptions need a recorded decision.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0080 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0081: Release signing: keyless GitHub artifact attestations (Sigstore, SLSA build provenance) verified by a sha256-pinned gh in install.sh
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0081
- Tags: installer, security, release
- Supersedes: none
- Superseded by: none
- Related: none
Context
D-0027 requires signed binaries and the M1 DoD says the release verifies against the published key; the scheme was left to the installer workstream (architecture section 17). Keyless needs no secret from the maintainer and removes the long-lived key that T-SUP-03 says can be stolen.
Decision
Release signing: keyless GitHub artifact attestations (Sigstore, SLSA build provenance) verified by a sha256-pinned gh in install.sh.
Compared: (A) Ed25519 key (minisign or raw openssl pkeyutl) held as a GitHub Actions secret: install.sh embeds the public key and verifies with openssl, which every Debian host with ca-certificates has; zero extra download, works offline; costs: the maintainer must generate and upload the secret, key custody, rotation and revocation are undefined (T-SUP-03 residual), no transparency log. (B) Keyless: the tag-triggered release workflow runs actions/attest-build-provenance (id-token: write, attestations: write); Sigstore public-good Fulcio issues a short-lived cert bound to the workflow identity (repo, .github/workflows/release.yml, refs/tags/vX), the signature is logged in Rekor, and the SLSA provenance covers the binary, the tar.gz and SHA256SUMS. The bundle (.sigstore.jsonl) is a release asset. Verification: gh attestation verify <file> –bundle <bundle> –repo OWNER/quoro –signer-workflow OWNER/quoro/.github/workflows/release.yml –source-ref refs/tags/vX. Checked on 2026-11-02: gh 2.103.3 verifies a bundle with no GitHub auth (fetches the Sigstore TUF root), and fails on a tampered file. cosign was rejected as the installer verifier: its linux-amd64 binary is 146 MB vs 15 MB for the gh tarball. install.sh downloads gh pinned by version and sha256 embedded in the script, verifies, then deletes it; trust anchor is install.sh fetched over TLS from GitHub, the same strength as an embedded public key. Costs of B: 15 MB extra download and network access to Sigstore at install; the trust roots are GitHub OIDC + Sigstore instead of a key the maintainer holds; signatures can only be minted by the workflow, so the verify path cannot be exercised end to end before a tagged run (proposal: allow a one-off workflow_dispatch dry run that attests and uploads workflow artifacts only, no tag, no release, if the maintainer agrees). The M1 DoD wording ‘verifies against the published key’ becomes ‘verifies against the published signer identity and the Sigstore trust root’. Recommendation: B (keyless); it does not contradict D-0027. Implemented in #29/#30 as B.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0081 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0082: One-off workflow_dispatch dry run of the release workflow: it attests and uploads workflow artifacts only, with no tag and no release
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0082
- Tags: installer, security, release
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-02. The keyless verify path (ADR 0081) cannot be exercised end to end before a tagged run; a dry run proves install-time verification before the first tag.
Decision
One-off workflow_dispatch dry run of the release workflow: it attests and uploads workflow artifacts only, with no tag and no release.
Run once from main after the M1 integration epic lands. It creates a public Rekor transparency-log entry bound to the workflow identity; that is expected. Tags and releases still need the maintainer’s explicit OK.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0082 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0083: VM disk_gb minimum is 1 in openapi.yaml (CreateVmRequest and Vm), matching db-schema CHECK (disk_gb >= 1)
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0083
- Tags: control-plane, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-02. The API allowed 0 and the store refused it; the control plane already answers 422 for 0.
Decision
VM disk_gb minimum is 1 in openapi.yaml (CreateVmRequest and Vm), matching db-schema CHECK (disk_gb >= 1).
Standalone contract PR. ‘No data disk’ is not a feature today; if it becomes one it gets its own field and decision.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0083 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0084: M1 keeps every microVM isolated from every other; an additive contract change carrying a project or isolation group on NetAttach and guest DNS on NodeNetwork lands in a standalone PR at the start of M2, before any multi-VM app feature
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0084
- Tags: engine, networking, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-02. The frozen Engine trait has no carrier for a VM’s project or its DNS servers; full isolation is the safe side of engine-trait.md 3.3.
Decision
M1 keeps every microVM isolated from every other; an additive contract change carrying a project or isolation group on NetAttach and guest DNS on NodeNetwork lands in a standalone PR at the start of M2, before any multi-VM app feature.
Until then the CH engine isolates all VMs (bridge port isolation plus an nftables forward drop) and takes guest DNS from its own node configuration. Same-project VM-to-VM traffic is impossible in M1; this is documented, not a bug.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0084 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0086: cargo-deny checks the targets Quoro ships (x86_64 and aarch64 Linux, gnu and musl), not every target; the ADR 0080 license allowlist is unchanged
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0086
- Tags: governance, security
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer chose to limit the check to shipped targets on 2026-11-02. webpki-root-certs (CDLA-Permissive-2.0) enters the graph only for wasm32 through oci-client -> reqwest -> rustls-platform-verifier and never reaches the shipped binary; checking every target failed CI on a crate Quoro does not ship.
Decision
cargo-deny checks the targets Quoro ships (x86_64 and aarch64 Linux, gnu and musl), not every target; the ADR 0080 license allowlist is unchanged.
deny.toml [graph] targets = x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-gnu, aarch64-unknown-linux-musl. Adding a shipped target adds it here. Refines D-0080 (scope of the check); the allowlist and the no-exceptions rule stay.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0086 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0088: quoro.service runs as root while the Cloud Hypervisor engine is in-process (M1)
- Status: Superseded by ADR 0096
- Date: 2026-11-02
- Ledger id: D-0088
- Tags: installer, engine, security
- Supersedes: none
- Superseded by: ADR 0096
- Related: none
Context
The CH engine creates a bridge, TAPs and nftables rules (CAP_NET_ADMIN), writes ip_forward, and converts OCI layers with ownership preserved (gated on euid==0 in ImageStore); the engine suite was only validated as root. User=quoro with no capabilities cannot run it.
Decision
quoro.service runs as root while the Cloud Hypervisor engine is in-process (M1).
Implemented in the M1 integration PRs: unit without User=, hardened with NoNewPrivileges, ProtectHome, PrivateTmp, ProtectSystem=full and RestartPreventExitStatus=78 (host check refusal); installer no longer creates a quoro user; /var/lib/quoro root 0700; setup-token runs as root. Alternative: User=quoro + AmbientCapabilities (NET_ADMIN, CHOWN, FOWNER, DAC_OVERRIDE, MKNOD) plus a capability-aware ownership check in ImageStore; rejected for M1 because CHOWN+DAC_OVERRIDE is root-equivalent anyway and it is untested. Revisit when the node agent splits from the control plane (M2): an unprivileged API process talking to a privileged node agent.
Consequences
- Superseded by ADR 0096; this decision is no longer binding and is kept for history.
Generated from decision D-0088 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0089: Privilege split in M2: the API and panel run unprivileged and only the node agent keeps the privileges the engine needs
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0089
- Tags: security, installer, engine
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted root for M1 (ADR 0088) on 2026-11-02; the root service is an M1-only trade-off.
Decision
Privilege split in M2: the API and panel run unprivileged and only the node agent keeps the privileges the engine needs.
Lands when the node agent splits from the control plane in M2; the M2 plan must include it and a test that the API process holds no capabilities.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0089 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0090: Release builds and reproduce-release.sh run inside the same digest-pinned Ubuntu 24.04 container image with snapshot-pinned packages, so any host with podman or docker reproduces the published artifact byte for byte
- Status: Accepted
- Date: 2026-11-02
- Ledger id: D-0090
- Tags: release, security, installer
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer chose a pinned container on 2026-11-02. A rebuild on another distribution showed that the host C compiler (GCC 13.3 on CI vs 14.2 on Debian) changes the quoro binary, so reproduction across hosts fails the M1 DoD step 4.
Decision
Release builds and reproduce-release.sh run inside the same digest-pinned Ubuntu 24.04 container image with snapshot-pinned packages, so any host with podman or docker reproduces the published artifact byte for byte.
Base image pinned by digest; apt from snapshot.ubuntu.com at a fixed timestamp (musl-tools, gcc and every build package at exact versions); the Rust toolchain and Node stay pinned as today. CI runs the build in that container; reproduce-release.sh runs the identical container with podman (or docker). Proven when a rebuild on a separate test host matches the CI artifact.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0090 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0094: Non-primary nodes get only a per-node tunnel run token from the primary; the account API token never leaves the primary
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0094
- Tags: ingress, providers, contract
- Supersedes: none
- Superseded by: none
- Related: none
Context
Smallest blast radius: a node holds a credential that can only run its own tunnel (Q-0090 option a).
Decision
Non-primary nodes get only a per-node tunnel run token from the primary; the account API token never leaves the primary.
The primary creates or looks up each node’s Cloudflare tunnel through the providers API credential, writes DNS CNAMEs and the tunnel ingress config itself, and sends the node only {tunnel_id, token} in a new desired_state field (e.g. spec.ingress.tunnel), stored with the node-local key like env values. ACME DNS-01 TXT records for public nodes are written by the primary on the node’s behalf. Ships as a standalone contract PR before epic #64 wires domains.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0094 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0095: Add provider_id to domains and audit_exports and a node-to-tunnel-provider mapping so DELETE /providers/{id} can return 409 while referenced
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0095
- Tags: providers, contract
- Supersedes: none
- Superseded by: none
- Related: none
Context
openapi already promises the 409; the schema must make the reference checkable (Q-0091 option a).
Decision
Add provider_id to domains and audit_exports and a node-to-tunnel-provider mapping so DELETE /providers/{id} can return 409 while referenced.
Same standalone contract PR as the tunnel-token decision. The providers delete handler then replaces its TODO with the reference check.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0095 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0096: M2 process model: quoro.service is the unprivileged control plane (User=quoro-api, empty capability bounding set) and quoro-node.service is the root node agent with KillMode=process; node data in /var/lib/quoro-node
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0096
- Tags: security, installer, engine, m2
- Supersedes: ADR 0088
- Superseded by: none
- Related: none
Context
D-0088 was M1-only (engine in-process). D-0089 requires the split in M2; this records the concrete shape so D-0088 is marked superseded.
Decision
M2 process model: quoro.service is the unprivileged control plane (User=quoro-api, empty capability bounding set) and quoro-node.service is the root node agent with KillMode=process; node data in /var/lib/quoro-node.
Implements D-0089 (issue #63). quoro.service: User/Group quoro-api (dedicated system user; ‘quoro’ is not reused because it is a login user on some hosts), CapabilityBoundingSet= and AmbientCapabilities= empty, NoNewPrivileges, ProtectSystem=strict with ReadWritePaths=/var/lib/quoro, PrivateDevices, RestrictAddressFamilies inet/unix; runs ‘quoro serve’ with [node] local=false. quoro-node.service: root, ‘quoro node’, RestartPreventExitStatus=78, KillMode=process so agent restarts never stop VMMs (the engine adopts them), After=quoro.service without Requires (D-0011). Node data dir /var/lib/quoro-node (sibling of /var/lib/quoro, which D-0054 named for data; the control plane cannot read node secrets). /var/lib/quoro owned quoro-api 0700, /etc/quoro/quoro.toml root:quoro-api 0640. The primary’s agent enrolls via a local join file the control plane writes until the primary holds a node credential. ‘quoro setup-token’ run as root drops to the data dir owner. Installer: QUORO_ROLE=primary|node with QUORO_JOIN_URL/TOKEN/FINGERPRINT for nodes. Tests: unit-file assertions and a Linux check that a running quoro serve has zero CapInh/CapPrm/CapEff/CapAmb; lab check of /proc/<MainPID>/status for the installed unit.
Some wording was changed for the public repository.
Consequences
- Replaces ADR 0088.
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0096 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0098: AppResources.disk_gb minimum is 1 in openapi.yaml, matching vms CHECK (disk_gb >= 1)
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0098
- Tags: contracts, control-plane, apps
- Supersedes: none
- Superseded by: none
- Related: none
Context
Same mismatch D-0083 fixed for Vm/CreateVmRequest: the apps API spec allows disk_gb 0 but every app instance is a vms row whose CHECK refuses 0; the M2 apps handlers refuse 0 with validation_failed meanwhile.
Decision
AppResources.disk_gb minimum is 1 in openapi.yaml, matching vms CHECK (disk_gb >= 1).
Standalone contract PR: set AppResources.disk_gb minimum: 1 in docs/contracts/openapi.yaml. Until then createApp/updateApp answer validation_failed for disk_gb 0 and the openapi diff keeps minimum 0 in the generated schema.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0098 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0099: Add domain_id to desired_state spec.routes[] so node certificate.* events can name their domain
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0099
- Tags: contracts, apps, ingress, m2
- Supersedes: none
- Superseded by: none
- Related: none
Context
events.schema.json requires data.domain_id and a domain subject for certificate.issued/failed/expiring, but spec.routes[] carries only hostname, app_id, vm_ids, port and tls_mode; the node cannot emit those events without inventing an id. Today the M2 node agent logs the transition and reports cert_status in status_report.routes (which the primary maps to domains.cert_status), but buffers no certificate.* event.
Decision
Add domain_id to desired_state spec.routes[] so node certificate.* events can name their domain.
An additive optional field spec.routes[].domain_id in node-agent-protocol.md 5.11, filled by the primary’s desired-state builder. Ships as a standalone contract PR.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0099 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0102: M3 demo agent runner uses the built-in deterministic policy; an LLM backend (Claude, Anthropic API) stays optional, configured at the runner with the operator’s own key, never required by the DoD
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0102
- Tags: agents, m3
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-03. The deterministic policy already passed the runner end to end on the test lab, needs no key and costs nothing; an LLM backend is a later choice.
Decision
M3 demo agent runner uses the built-in deterministic policy; an LLM backend (Claude, Anthropic API) stays optional, configured at the runner with the operator’s own key, never required by the DoD.
The reference runner keeps the pluggable decision step from #118. The M3 demo and DoD run on the deterministic policy. A Claude backend may ship as an option behind the interface; Quoro never stores the LLM key; the operator configures it at the runner.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0102 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0103: A webhook delivery from a webhook with wake carries data.wake = {agent_id, grant_id} in the incident.opened payload (additive, schema_version 1)
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0103
- Tags: contracts, agents, m3
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-03. Accepted option: the runner learns which agent to act as and which incident grant to read from the delivery itself.
Decision
A webhook delivery from a webhook with wake carries data.wake = {agent_id, grant_id} in the incident.opened payload (additive, schema_version 1).
events.schema.json data_incident_opened gains optional wake {agent_id (agt_), grant_id (grt_)}, present only on deliveries of a webhook with wake. Standalone contract PR, then the dispatcher fills it and the reference runner prefers data.wake.agent_id, falling back to its configured default agent.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0103 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0104: Ratify the provisional webhook and event contract choices from #116
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0104
- Tags: contracts, m3
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-03.
Decision
Ratify the provisional webhook and event contract choices from #116.
(1) deleteWebhook deletes its delivery history (db-schema ON DELETE CASCADE wins; fix the openapi prose). (2) testWebhook replays the newest event as a test delivery and answers 422 validation_failed on an empty event log; add 422 to its responses; a test delivery must never collide with real deliveries (#127). (3) UpdateWebhookRequest cannot clear wake; to stop a webhook from waking an agent, delete and recreate it (its signing secret rotates); document it. (4) list cursors for events and incidents are opaque strings; events page by seq ascending, incidents by id newest first; document both. Standalone contract PR.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0104 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0105: M3 ships without deleteAgent; disabling an agent (PATCH) is the lockout; delete semantics are decided later
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0105
- Tags: agents, m3
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-03. Deleting is not implemented and the schema cascade would erase grant and approval history (ADR 0031); disabling already blocks the agent.
Decision
M3 ships without deleteAgent; disabling an agent (PATCH) is the lockout; delete semantics are decided later.
The question of delete semantics stays open (recommendation on record: soft delete with agents.deleted_at). deleteAgent keeps answering as not implemented until then.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0105 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0106: Add GET /api/v1/agents/{agent_id}/keys to openapi.yaml so the panel can list an agent’s keys
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0106
- Tags: contracts, panel, agents, m3
- Supersedes: none
- Superseded by: none
- Related: none
Context
The frozen spec has createAgentKey and revokeAgentKey but no list; the panel agent screen cannot show which keys exist, when they were last used or which to revoke, unless the operator pastes a key id (found while building #104).
Decision
Add GET /api/v1/agents/{agent_id}/keys to openapi.yaml so the panel can list an agent’s keys.
GET /api/v1/agents/{agent_id}/keys, tag agents, x-quoro-scope agent:read, Limit+Cursor, 200 AgentKeyList {items: AgentKey[], next_cursor} (AgentKey already exists, never carries the secret). Until it lands the panel shows the key created in the current dialog (from the create response only) and a revoke-by-key-id form. Standalone contract PR per D-0036/D-0048; control-plane epic implements.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0106 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0107: Let operators and viewers resolve user ids to names (a read path for users)
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0107
- Tags: contracts, panel, m3
- Supersedes: none
- Superseded by: none
- Related: none
Context
GET /api/v1/users needs user:write (owner only), so an operator or viewer cannot turn owner_user_id, on_behalf_of, decided_by or created_by into a name; the M3 audit DoD asks the entry to show the owner and the on-behalf-of human.
Decision
Let operators and viewers resolve user ids to names (a read path for users).
Options: (a) a user:read scope in the role bundles (operator and viewer get it) on GET /api/v1/users; (b) embed {id,name} summaries for referenced users in AuditRecord/Agent/Approval responses. Accepted option: (a), the smaller change. Until then the panel shows the name for the owner (who can list users) and for the signed-in user (‘you’), and the id otherwise.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0107 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0109: Docs site: mdBook built from the repo’s Markdown, published to GitHub Pages together with the v0.1.0 tag
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0109
- Tags: docs, m3
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-03. D-0065 asked to pick the tooling during M3. mdBook is Rust-native, static and needs no runtime service.
Decision
Docs site: mdBook built from the repo’s Markdown, published to GitHub Pages together with the v0.1.0 tag.
Build the book in CI from docs/ without moving the Markdown; publishing the site happens only with the v0.1.0 release approval.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0109 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0111: user:read stays human-only; WebhookDelivery exposes the test flag; creating users is M4 work
- Status: Accepted
- Date: 2026-11-03
- Ledger id: D-0111
- Tags: contracts, m3
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-03.
Decision
user:read stays human-only; WebhookDelivery exposes the test flag; creating users is M4 work.
(1) user:read is in every human role bundle and refused to agents (human_only), as contract PR #133 shipped; agents never read human emails. (2) Additive contract change: WebhookDelivery gains a boolean ‘test’ (false for real deliveries) backed by webhook_deliveries.test (migration 0004); the panel marks test deliveries. (3) A path to create and invite users (createUser and its panel screen) is planned in M4; until then a second user cannot be created and lab proofs needing one stay on integration tests.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0111 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0113: M4 web terminal path: exec through quoro-init over virtio-vsock, relayed by the root node agent
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0113
- Tags: m4, terminal, engine, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
Runs inside the app’s guest (T-LOG-06), multi-session, resize, exit codes, vsock back-pressure; no new privilege in the unprivileged API (D-0089/D-0096). Measured on the test lab: uname -a 21 ms RTT, 56 MB/s, VMM RSS flat under a 10 s reader stall.
Decision
M4 web terminal path: exec through quoro-init over virtio-vsock, relayed by the root node agent.
Every VM gets --vsock cid=3,socket=<vm dir>/vsock.sock (hybrid vsock, no vhost). quoro-init listens on one fixed port after switch_root, spawns a PTY session per connection with the app’s env/workdir/user via a self re-exec helper (pre_exec is unsafe; workspace forbids unsafe), using the vsock crate (0.5.5, Apache-2.0) since rustix 1.1.6 has no safe vsock address. quoro-init-config v2. Node never creates <socket>_<port> listeners, so the guest cannot dial the host. Rejected: shell on node, mounting disks, SSH, serial getty, network agent. Images without a shell: only programs the image ships; a toolbox disk (BusyBox, GPL-2.0) is after M4. Evidence: docs/research/m4-terminal-and-console.md (PR #155, issue #151).
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0113 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0114: M4 terminal and console access: app:terminal human-only (owner, operator); vm:console grantable read-only (owner, operator); agents get a non-interactive app:exec later via approval
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0114
- Tags: m4, terminal, console, security, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
A PTY shell reads every app secret and acts outside single audited API calls; console output is kernel and init diagnostics, closer to logs, and helps agents diagnose failed boots (D-0032).
Decision
M4 terminal and console access: app:terminal human-only (owner, operator); vm:console grantable read-only (owner, operator); agents get a non-interactive app:exec later via approval.
New scopes in permission-model.md: app:terminal (human-only flag in M4, owner+operator bundles, not viewer) and vm:console (grantable, owner+operator; viewer unchanged in M4). Open flow: POST /api/v1/apps/{app_id}/terminal-sessions or POST /api/v1/vms/{vm_id}/console-sessions -> 201 {id trm_, single-use 30 s ticket, websocket_path}; GET /api/v1/interactive-sessions/{id}/ws?ticket= upgrade with Origin check; DELETE to end. Long-lived sessions are re-checked: close on identity disabled, role change, cookie/token expiry, grant revoke, 15 min idle, 8 h max. A later app:exec (one argv, captured output, approval grant) is a separate decision. Evidence: docs/research/m4-terminal-and-console.md (PR #155, issue #151).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0114 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0115: M4 session audit: an open record plus a session-end record carrying a SHA-256 of a node-local, size-capped transcript
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0115
- Tags: m4, terminal, audit, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
One audit record per call does not describe a minutes-long session; parsing commands from a PTY is unreliable; shipping transcripts to the primary contradicts D-0014 and puts secrets in the replicated, exported audit log. A hash in the chained close record makes transcript tampering evident (D-0031).
Decision
M4 session audit: an open record plus a session-end record carrying a SHA-256 of a node-local, size-capped transcript.
Open: the normal record for the POST (target vm/app, params rows, cols, command, user). New recorded action session.end written by the primary on any close, same actor/on_behalf_of/channel, params {session_id, kind terminal|console, duration_ms, bytes_in, bytes_out, exit_code?, close_reason, transcript_sha256?, transcript_bytes?}. Transcript = timed input+output (asciicast-style) on the node, capped; access needs audit:read; retention and fetch path to decide in the contract PR. Transcripts contain secrets by design. Evidence: docs/research/m4-terminal-and-console.md (PR #155, issue #151).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0115 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0116: M4 VM console: read-only; serial in socket mode with the node agent as its only client from vm.create and a node-side ring per VM; viewers fan out from the node
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0116
- Tags: m4, console, engine, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
CH v54 socket mode keeps a 1 MiB ring until a client attaches (consumed on replay) and a new connection silently takes over from the current one, so viewers must never connect directly. Today’s file= serial.log is truncated at every VMM start and unbounded. Nothing in the guest reads ttyS0, so input is useless.
Decision
M4 VM console: read-only; serial in socket mode with the node agent as its only client from vm.create and a node-side ring per VM; viewers fan out from the node.
Engine-trait change: VmSpec carries both the app-log virtio-console and a serial socket (ConsoleMode is exclusive today); Engine::console reads the node ring plus live. Ring: e.g. two 512 KiB segments in the VM dir, survives VMM restarts (keeps the previous boot). CH’s own 1 MiB ring covers node-agent restarts (KillMode=process). Keep loglevel=4 (init diagnostics are the boot output; serial costs ~30 KB/s of guest CPU on the lab). Node protocol: stream_open kinds terminal {vm_id, rows, cols, argv?, user?} and console {vm_id, tail_bytes?}; stream_data.data base64 <=64 KiB; new stream_input {stream_id, seq, data?, resize?, eof?} (refused on console); new stream_ack {stream_id, acked_bytes} with a 256 KiB window so a busy terminal cannot starve heartbeats; stream_end exit_code. Panel WS: binary frames = bytes, text frames = JSON control (resize, exit, closed reason, error). Minimal fallback: tail today’s serial.log read-only. Evidence: docs/research/m4-terminal-and-console.md (PR #155, issue #151).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0116 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0117: Builder interface: a node-agent Builder trait (Dockerfile first, Railpack later) that returns build events and a node-local OCI layout
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0117
- Tags: builds, m4, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
M4 builds spike (PR #156, docs/research/m4-builds.md): BuildKit in a builder microVM works; Railpack is a BuildKit gateway frontend that runs in the same VM, so both builders differ only in frontend options and a prepare step.
Decision
Builder interface: a node-agent Builder trait (Dockerfile first, Railpack later) that returns build events and a node-local OCI layout.
Builder is owned by the node agent, not the Engine; it uses the Engine to run the builder VM. build(BuildSpec) -> stream of BuildEvent {Phase, Line{stream, vertex, text}, Step} and Result<BuildOutput{layout path, manifest_digest}, BuildFailure{phase, step: FailedStep{vertex, name, error, dockerfile_line}, excerpt}>. BuildSpec: build_id (bld_), source (Git{url, ref, resolved commit} | Upload), frontend (Dockerfile{path, target} | Railpack), context_dir, build_args, secrets (names), limits {vcpus, memory_mb, timeout, cache_gb, output_gb}, cache_key per app. States: queued, preparing, building, exporting, importing, succeeded, failed, cancelled. A success starts a deployment with trigger=build (already in openapi). FakeBuilder for tests. Additive Engine-trait items in a standalone PR: prepare_image from a local OCI layout (cached by manifest digest), a scratch/work disk on VmSpec before M5a volumes, cgroup2 mounted by the init, extra disks unmounted before power-off. Git: host-side clone onto the work disk by default (node resolves ref to SHA and handles credentials); BuildKit’s own git context works as a fallback.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0117 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0118: Builds run on the target node in an ephemeral builder microVM (one per build) with a persistent per-app work disk for the BuildKit cache
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0118
- Tags: builds, m4, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
Spike: boot about 0.3 s, buildkitd ready in 0.2-0.4 s, warm rebuilds 2-4 s with the cache on a persistent work disk vs 27-57 s cold; layout produced and imported on the same node needs no registry (nothing pushed).
Decision
Builds run on the target node in an ephemeral builder microVM (one per build) with a persistent per-app work disk for the BuildKit cache.
Builder VM: pinned moby/buildkit image (by digest, prepared by the normal prepare_image path), rootful buildkitd daemonless pattern, –oci-worker-net=host, about 2 vCPU / 1-2 GiB (Node built at 384 MiB, Go peaked at 650 MB). Work disk per app per node, capped by cache_gb and pruned with buildctl prune –keep-storage after each build, deleted with the app. One concurrent build per node by default; builds count against node capacity; timeout stops the VM. Builder VM gets its own isolation group (ADR 0084) with egress only.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0118 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0119: Build log transport: tagged serial-console lines out of the builder VM, kept on the node, streamed to the panel over SSE via a new stream_open kind build_logs
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0119
- Tags: builds, m4, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
Spike: buildctl output on stdout is already tagged by quoro-init on the serial console like app logs; –progress=rawjson yields the failing vertex (name, error, timing) as JSON; init status region carries the exit code. No vsock or guest agent needed.
Decision
Build log transport: tagged serial-console lines out of the builder VM, kept on the node, streamed to the panel over SSE via a new stream_open kind build_logs.
Node parses rawjson into BuildEvents and keeps plain lines as the human log next to the build’s work dir (ADR 0014). Control plane: GET /api/v1/builds/{id}/logs (paginated, finished builds) and GET /api/v1/builds/{id}/logs/stream (SSE, ADR 0057) backed by node-agent stream_open kind build_logs {build_id, tail}. Replicated: only build status and failed_step (vertex, name, error, Dockerfile line, a few excerpt lines), so the panel shows the failure when the node is down. vsock stays a later option.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0119 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0120: M4 builds and metrics reuse existing scopes; M4 adds no MCP tool
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0120
- Tags: contracts, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
Conservative: no new grantable surface beyond app:terminal and vm:console
Decision
M4 builds and metrics reuse existing scopes; M4 adds no MCP tool.
startBuild/cancelBuild need app:deploy, build records app:read, build logs app:logs; node metrics node:read, app metrics app:read. MCP tools stay unchanged in M4 (README states it).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0120 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0121: Built image reference build.quoro.invalid/<app_id>:<build_id>; Git app image is empty before first build
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0121
- Tags: contracts, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
Reserved .invalid TLD can never be pulled; keeps apps.image NOT NULL
Decision
Built image reference build.quoro.invalid/<app_id>:<build_id>; Git app image is empty before first build.
spec.vms[].build_id names the local root disk; nodes never pull build.quoro.invalid references.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0121 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0122: Git apps: createApp takes exactly one of image/source; source cannot be added or removed in M4; createDeployment 409 for Git apps
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0122
- Tags: contracts, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
Avoids mixed-mode apps until there is a use case
Decision
Git apps: createApp takes exactly one of image/source; source cannot be added or removed in M4; createDeployment 409 for Git apps.
Until the builds epic lands, createApp refuses a source with 422 and updateApp with source answers 409 (no Git app exists yet).
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0122 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0123: M4 build scope: public HTTPS repos only, trigger api only, one active build per app, fixed default limits
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0123
- Tags: contracts, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
Smallest surface for the DoD; private repos and push triggers need credential and webhook design
Decision
M4 build scope: public HTTPS repos only, trigger api only, one active build per app, fixed default limits.
Limits {vcpus 2, memory_mb 2048, timeout_s 1800, cache_gb 10, output_gb 10}; a second build while one is active answers 409; startBuild and cancelBuild answer 202; build log on node capped 8 MiB, last 20 builds per app; getBuildLogs max 1000 lines per page.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0123 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0124: Build lifecycle runs through desired state; primary emits build.* events; Deployment.phase build is never reported
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0124
- Tags: contracts, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
Keeps one node-control path and a single writer for replicated build records
Decision
Build lifecycle runs through desired state; primary emits build.* events; Deployment.phase build is never reported.
spec.builds[] with a cancelled flag; nodes report status_report.builds[]; a failed build starts no deployment, so no deployment.failed with phase build. Build secrets travel only in signed desired state, written to the work disk for the build only, never logged.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0124 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0125: Builder VM networking: egress_only with no isolation group
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0125
- Tags: contracts, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
Untrusted Dockerfiles must not reach other VMs or host services
Decision
Builder VM networking: egress_only with no isolation group.
No other VM, no host address except NodeNetwork::dns on port 53, no 169.254.0.0/16; egress_only with an isolation group is Conflict. Engine creates a missing work disk as ext4 and never removes it; the node agent deletes it with the app.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0125 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0126: Terminal exec channel: vsock port 1024, framed [u8 type][u32 BE len][payload], quoro-init config v2 with exec_port
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0126
- Tags: contracts, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
Fixes the engine-init wire format before the terminal epic
Decision
Terminal exec channel: vsock port 1024, framed [u8 type][u32 BE len][payload], quoro-init config v2 with exec_port.
Host types 1 open(JSON argv,user,rows,cols) 2 input 3 resize 4 close; guest 129 output 130 exit 131 error; 64 KiB frame cap; v1 configs still accepted without exec listener. Default command /bin/sh, TERM=xterm-256color. createTerminalSession takes no justification header (human-only); createConsoleSession and endInteractiveSession do; endInteractiveSession allowed for the opener and owners.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0126 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0127: Console: Engine::console superseded by serial_output; node ring of two 512 KiB segments; console streams outlive VM restarts
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0127
- Tags: contracts, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
A second client on the CH serial socket silently takes it over
Decision
Console: Engine::console superseded by serial_output; node ring of two 512 KiB segments; console streams outlive VM restarts.
console answers Conflict for socket-mode serial. Agent console sessions end when the 5-minute access token expires.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0127 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0128: Transcripts: terminal only, 16 MiB cap closes session (transcript_limit), 30 days and 1 GiB per node, fetch human-only with audit:read and always audited
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0128
- Tags: contracts, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
Transcripts hold every secret typed or printed in a session
Decision
Transcripts: terminal only, 16 MiB cap closes session (transcript_limit), 30 days and 1 GiB per node, fetch human-only with audit:read and always audited.
Console sessions have no transcript. The local interactive_sessions table closes orphaned sessions at primary start-up with close_reason primary_restarted; the open record carries session_id in params.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0128 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0129: Metrics: host-side VMM RSS and CPU, 5 s sampling, 10-minute in-memory ring, SSE samples carry no id
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0129
- Tags: contracts, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
No guest agent; nothing stored or replicated (ADR 0014)
Decision
Metrics: host-side VMM RSS and CPU, 5 s sampling, 10-minute in-memory ring, SSE samples carry no id.
Reconnecting clients re-read history from the GET endpoint.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0129 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0130: Public site layout on quoro.dev: the mdBook docs site serves quoro.dev from GitHub Pages at the v0.1.0 release; the installer gets a short URL quoro.dev/install.sh later
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0130
- Tags: docs, release
- Supersedes: none
- Superseded by: none
- Related: none
Context
D-0109 publishes the docs site with v0.1.0; the domain now exists. One site keeps it simple until M4 adds a landing page.
Decision
Public site layout on quoro.dev: the mdBook docs site serves quoro.dev from GitHub Pages at the v0.1.0 release; the installer gets a short URL quoro.dev/install.sh later.
At v0.1.0: a Pages deploy job runs on the published release (never on push), builds docs/ with the pinned mdBook, and serves it at quoro.dev (CNAME, Cloudflare DNS record pointing at GitHub Pages, HTTPS enforced). The install one-liner keeps the raw GitHub URL until a stable path exists on the site. A landing page and docs.quoro.dev split can come with the M4 general release.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0130 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0132: M4 interactive: ratify contract readings taken while implementing terminal and console sessions
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0132
- Tags: terminal, console, m4, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
The M4 contract leaves these cases open; the code follows the readings below and a later change should be a deliberate contract PR, not drift.
Decision
M4 interactive: ratify contract readings taken while implementing terminal and console sessions.
- A max_uses grant consumed by the console open itself closes the session at the next re-check (5 s) with access_revoked, per permission-model 7.5 read literally (exhausted grant closes). Alternative: exempt the authorizing grant’s use by the open. 2) session.end repeats the opener’s accountability columns; when an owner ends someone else’s session only ended_by names the owner. 3) A spent ticket answers 409 only while the session already has its WebSocket; a ticket burnt by a failed Origin check or presented for another session answers 401. 4) A transcript fetch for an unknown or console session is 404 and not audited (no vm target to name); an agent refusal names the session’s VM when the session exists. 5) A terminal that ends (completed/vm_stopped) before its first data answers 409 at the open. 6) An agent’s DELETE of its own session requires Quoro-Justification (validation_failed otherwise) and the justification is written on session.end. 7) Engine: SerialMode::Off keeps the legacy serial.log so pre-M4 specs keep boot logs; serial_output on a stopped VM replays the ring. 8) stream_input eof shuts the engine io, which the CH relay turns into CLOSE (SIGHUP), not a ^D; the control plane never sends eof today.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0132 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0133: Keep an app’s last failed or cancelled build in spec.builds so the node keeps its log
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0133
- Tags: builds, m4, contracts
- Supersedes: none
- Superseded by: none
- Related: none
Context
node-agent-protocol.md 6.6 keeps an app’s build log and cache disk only while the app appears in spec.vms or spec.builds, and 6.6 line 682 removes a build from spec.builds once its terminal status is recorded. A Git app whose build failed has no VM, so on the lab the failed build’s log was deleted seconds after the failure (GET /builds/{id}/logs answered 404). That breaks the M4 DoD ‘a failing build shows the failing step and its log in the panel’.
Decision
Keep an app’s last failed or cancelled build in spec.builds so the node keeps its log.
Implemented in #173 without changing contract text: line 682 only gives a lower bound for how long a build stays and an exit rule for succeeded builds. The primary keeps, per app, the newest build that reached the node (started_at set) when it ended failed or cancelled. That entry is sent marked cancelled and without secrets, so the node never runs it again, even after losing its row. The primary ignores reports about it, because it is terminal. It leaves when a newer build of the app starts (the primary re-sends the desired state on a build’s first start), or when the app is deleted. Builds that failed on the primary, or were cancelled before starting, are not kept: they have no log on the node. Needs a decision: accept this reading, or tighten 6.6 wording in a separate contract PR.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0133 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0134: Curated catalog: one TOML file per template under catalog/ in the public repo, embedded in the signed release binary; images pinned by digest; upstream cosign signatures verified in CI when a template is added or bumped, not on nodes
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0134
- Tags: catalog, security, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
D-0070 requires the catalog format, owner, location and signature scheme to be decided at M4 planning, before the first template ships; threat-model items T-SUP-04 and T-VM-05 rely on it. D-0026 binds templates to pinned upstream images with verifiable signatures.
Decision
Curated catalog: one TOML file per template under catalog/ in the public repo, embedded in the signed release binary; images pinned by digest; upstream cosign signatures verified in CI when a template is added or bumped, not on nodes.
Format: catalog/<name>/template.toml, one per template: name, description, upstream homepage, image as repo@sha256:<digest> (a tag alone is rejected), default resources (vcpus, memory_mb, disk_gb), port, env vars (name, default, required, secret flag), health check path, and a signer block: cosign keyless (certificate identity regexp + OIDC issuer) or a cosign public key in catalog/<name>/cosign.pub. Notation is not supported; an upstream that does not sign its images cannot be in the catalog (D-0026 requires verifiable signatures). Location and trust: the catalog/ directory in the public repo (D-0026), compiled into the quoro binary at release, so its integrity is the release’s integrity (ADR 0027, D-0081 attestations); no runtime download, no registry of our own. A later refresh channel would need its own decision. Signature verification: a CI job runs the cosign CLI (pinned version, digest-pinned in the workflow) against each new or changed template, verifying the image digest’s signature against the declared signer; the PR cannot merge otherwise. Nodes do not verify signatures at runtime: the digest pin already fixes the bytes (T-SUP-04: a replaced upstream tag cannot change a pinned digest), and the only maintained Rust verifier (sigstore 0.14) calls itself experimental and would add a large dependency tree under the ADR 0080 license policy. Residual risk stated in the threat model: verification happens at admission time; a signer that signs a malicious image passes (already in T-SUP-04). Bumps: Renovate-style digest bumps are PRs that rerun the check. Owner: a new catalog workstream owning catalog/ and its CI check; the control plane reads the embedded catalog (an API listing templates and a createApp-from-template path are a contract change, standalone PR) and the panel shows it. Milestone: M4 only decides (roadmap); the format, the CI check and the first templates ship as a later milestone item, scheduled when the maintainer picks it (proposed: M5a, alongside the edge). Threat model update: T-SUP-04 and T-VM-05 move from mitigation pending to this scheme when it ships.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0134 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0135: Q-0099: invited users change their own password through a human-only self-service operation (current and new password), added in a standalone contract PR during M4; no must_change_password flag yet
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0135
- Tags: contracts, users, m4
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-04. M4 is the first general release, and an invited user who can never rotate the password the owner handed over is a security gap.
Decision
Q-0099: invited users change their own password through a human-only self-service operation (current and new password), added in a standalone contract PR during M4; no must_change_password flag yet.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0135 in the project decision ledger. Do not edit by hand; changes go through a new decision.
ADR 0136: Q-0101: pre-M4 VMs get the terminal and console only after a redeploy; the release notes say so and the panel shows a ‘redeploy to enable’ hint instead of the action; the node agent never recreates a VM on its own
- Status: Accepted
- Date: 2026-11-04
- Ledger id: D-0136
- Tags: m4, engine, panel
- Supersedes: none
- Superseded by: none
- Related: none
Context
The maintainer accepted the recommendation on 2026-11-04. A restart that silently changed a VM’s devices would break the rule that a restart keeps the stored spec; a redeploy is the explicit way to change it.
Decision
Q-0101: pre-M4 VMs get the terminal and console only after a redeploy; the release notes say so and the panel shows a ‘redeploy to enable’ hint instead of the action; the node agent never recreates a VM on its own.
Some wording was changed for the public repository.
Consequences
- This decision is binding for all contributors and agents. Changing it requires a new ADR approved by the maintainer (see ADR 0036).
Generated from decision D-0136 in the project decision ledger. Do not edit by hand; changes go through a new decision.
Research
Sourced answers to the VERIFY questions that the bases documents depend on (D-0040: every “to verify” claim is researched before it enters a document).
Each file gives the question id, a short answer, findings with a source URL and access date for every claim, the versions checked, implications for Quoro, open risks, and a source list. Every claim carries a label:
- Confirmed: stated by a primary source (official docs, pricing page, repository, registry) that was fetched on the access date.
- Hypothesis: inferred, taken from a secondary or old source, or not stated directly. Do not design on a Hypothesis without checking it first.
Research date: 2026-11-02 for the files answering Q-0010 to Q-0015; later spikes give their own date in the file. Versions and prices change, so re-check them before relying on them in a later milestone.
| Question | File | Short answer |
|---|---|---|
| Q-0010 Cloud Hypervisor: license, API, boot, snapshot, virtio-fs, consoles | cloud-hypervisor.md | v54.0, Apache-2.0 AND BSD-3-Clause, HTTP/JSON API over a UNIX socket. Snapshots hold RAM and device state but not disks, and there is no incremental API. virtio-fs needs an external virtiofsd. Consoles are text-only: there is no VNC or framebuffer. |
| Q-0011 R2 conditional writes as a lease arbiter | r2-conditional-writes.md | R2 supports atomic If-Match / If-None-Match PUT with a single winner. Limits: 1 write/s per key, no conditional delete, no TTL, so fencing tokens are required. Support is not portable across S3 providers (B2 appears to lack it). |
| Q-0012 Durable Objects as a lease arbiter | durable-objects.md | A single-threaded actor with strongly consistent storage and alarms for a server-side TTL. It fits the Workers Free plan at 5 s renewal. It needs a Cloudflare account and a deployed Worker, and fencing tokens are still required. |
| Q-0013 Cloudflare Load Balancing and cloudflared replicas | cloudflare-lb-and-tunnel.md | Load Balancing is a paid add-on starting at $5/month. An HTTPS monitor using the app hostname checks the app, but there are no TCP monitors for tunnels, and the LB sees all replicas of one tunnel as a single endpoint. Replicas (up to 25) are identical copies routed to the nearest one, with no app health check. |
| Q-0014 rmcp, Litestream to R2, utoipa to a TS client | rust-api-and-mcp-tooling.md | rmcp 3.8 is official but changes fast. Litestream 0.5.17 supports R2 but dropped client-side encryption. utoipa 6 (OpenAPI 3.1) works with orval and openapi-ts, with one known orval break. |
| Q-0015 VPS providers with nested virtualization | vps-nested-virt.md | Nested KVM is the exception. Hetzner Cloud, Hostinger, Contabo VPS and netcup VPS say no. AWS (7i/8i families, opt-in), GCP (Intel), Azure (Dv3/Dv4), OCI Intel VMs, OVHcloud Public Cloud, Contabo VDS, Vultr VX1 and DigitalOcean say yes. Bare metal is the reliable path. Always probe /dev/kvm with a real boot. |
| M4 spike (#151): web terminal and VM console | m4-terminal-and-console.md | Web terminal: exec through Quoro’s own init over virtio-vsock, relayed by the node agent; human-only app:terminal in M4, audited as an open and a close record with a node-local transcript referenced by hash. VM console: read-only, serial in socket mode with the node agent as its only client and a node-side ring buffer, because Cloud Hypervisor serves one client and a new connection takes over. |
| M4 builds spike: BuildKit in a builder microVM, Railpack and Nixpacks | m4-builds.md | Works with additions. BuildKit v0.34.2 builds Dockerfiles inside a Cloud Hypervisor microVM with our kernel and init, and its gzip OCI layout boots through the engine’s layer and ext4 code once ImageStore reads local layouts. It needs a work disk, cgroup2 and logs on the tagged serial console. Nixpacks is in maintenance mode; Railpack (MIT) is a BuildKit frontend that runs in the same VM. |
Accepted decisions stay authoritative. Where a finding bears on a decision, the file’s “Implications for Quoro” section says so, and the conflict is raised in the project’s decision ledger rather than changed here.
Cloud Hypervisor: license, API, boot time, snapshot/restore, virtio-fs, consoles
- Question: Q-0010 Cloud Hypervisor license, REST API surface, boot time, snapshot/restore, virtio-fs, serial/VNC console support
- Researched: 2026-11-02 by agent:ds-research-1
- Status: answered
Short answer
Cloud Hypervisor (CH) is a Rust VMM for KVM and MSHV, dual-licensed Apache-2.0 AND BSD-3-Clause, governed as a Linux Foundation project, latest release v54.0 (2026-10-14), roughly every 6-9 weeks. It exposes an HTTP/JSON API over a UNIX socket (--api-socket) with 32 documented endpoints in an OpenAPI 3.0.1 spec, plus an optional, off-by-default D-Bus API and the ch-remote CLI client. It boots guests by direct kernel boot or firmware (rust-hypervisor-firmware or CLOUDHV edk2); the official site claims boot to userspace in under 100 ms, with no published methodology. Snapshot is pause + full guest-RAM dump (config.json, memory-ranges, state.json) and does not contain disk contents; restore is file-based, same-version only, with eager/on-demand/copy-on-write memory modes, and live migration over UNIX or TCP/mTLS. virtio-fs works only through an external virtiofsd vhost-user daemon and requires --memory shared=on; DAX is removed. Consoles are text-only: serial and virtio-console to tty, pty, file=, socket=, tcp=loopback, null or off. CH has no VNC, no GPU and no framebuffer device at all.
Findings
1. License
| Component | License (SPDX) | Source | Label |
|---|---|---|---|
| cloud-hypervisor | Apache-2.0 AND BSD-3-Clause | cloud-hypervisor/Cargo.toml license field: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/cloud-hypervisor/Cargo.toml (accessed 2026-11-02) | Confirmed |
| cloud-hypervisor repo files | Code: Apache-2.0; docs (*.md): CC-BY-4.0 | .reuse/dep5: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/.reuse/dep5 (accessed 2026-11-02) | Confirmed |
| Repo license texts | LICENSES/Apache-2.0.txt, LICENSES/BSD-3-Clause.txt, LICENSES/CC-BY-4.0.txt | https://api.github.com/repos/cloud-hypervisor/cloud-hypervisor/contents/LICENSES (accessed 2026-11-02) | Confirmed |
| virtiofsd | Apache-2.0 AND BSD-3-Clause | https://gitlab.com/virtio-fs/virtiofsd/-/raw/main/Cargo.toml (accessed 2026-11-02) | Confirmed |
| rust-hypervisor-firmware | Apache-2.0 (single LICENSE file) | https://raw.githubusercontent.com/cloud-hypervisor/rust-hypervisor-firmware/main/LICENSE (accessed 2026-11-02) | Confirmed |
- There is no single top-level
LICENSEfile; license texts live in a REUSE-styleLICENSES/directory, and GitHub’s license API returnslicense: nullfor the repo. The authoritative machine-readable statement is the crate manifest,Apache-2.0 AND BSD-3-Clause. Confirmed. Source: https://api.github.com/repos/cloud-hypervisor/cloud-hypervisor (accessed 2026-11-02) - Docs are separately licensed CC-BY-4.0 (documentation only, no effect on code use). Confirmed. Source:
.reuse/dep5(accessed 2026-11-02) - Both CH licenses are permissive, not copyleft. The FSF license list states Apache-2.0 “is a free software license, compatible with version 3 of the GNU GPL”. Confirmed (statement about Apache-2.0/GPLv3). Source: https://www.gnu.org/licenses/license-list.html#apache2 (accessed 2026-11-02)
- Hypothesis: an AGPL-3.0 project that runs CH as a separate process (spawn
cloud-hypervisor, talk to its socket) creates no derivative work and therefore no licensing conflict; and even linking CH crates into an AGPL-3.0 binary would be permissible because Apache-2.0 is GPLv3/AGPLv3-compatible and BSD-3-Clause is permissive. No CH or FSF source addresses this exact combination; AGPLv3 §13 network clause also only attaches to the AGPL-covered work itself. Treat as legal-review item, not as a settled fact.
2. Releases, cadence, governance
| Item | Value | Source | Label |
|---|---|---|---|
| Latest release | v54.0, published 2026-10-14 | https://api.github.com/repos/cloud-hypervisor/cloud-hypervisor/releases (accessed 2026-11-02) | Confirmed |
| Previous majors | v53.0 2026-07-12, v52.0 2026-05-14, v51.0 2026-02-20, v50.0 2025-12-19, v49.0 2025-11-09 | same releases API (accessed 2026-11-02) | Confirmed |
Version in main | 55.0.0 (unreleased dev version) | https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/cloud-hypervisor/Cargo.toml (accessed 2026-11-02) | Confirmed |
| Stated cadence | “a new major release is issued approximately every 6 weeks”; point releases on demand; a major gets fixes for ~12 weeks then EOL | https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/releases.md (accessed 2026-11-02) | Confirmed |
| Observed cadence | 6-9 weeks between v49..v54 (see dates above), i.e. slower than the stated 6 weeks | computed from releases API (accessed 2026-11-02) | Confirmed (dates) / Hypothesis (that this is the new normal) |
| Governance | “governed openly as part of the Linux Foundation”; site footer “Cloud Hypervisor a Series of LF Projects, LLC” | https://www.cloudhypervisor.org/ (accessed 2026-11-02) | Confirmed |
| Backers named on site | Alibaba, AMD, Ampere, ARM, ByteDance, Cyberus Technology, Intel, Microsoft, SAP, Tencent Cloud | same page (accessed 2026-11-02) | Confirmed |
| Leadership | maintainers team, “@rbradford taking the lead”; named domain experts for KVM, MSHV, ARM, RISC-V, SEV-SNP, live migration, VFIO/vhost-user, block/QCOW2 | https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/MAINTAINERS.md (accessed 2026-11-02) | Confirmed |
| Stability promise | API and CLI options not removed/changed in a breaking way without ≥2 major releases notice | https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/README.md (accessed 2026-11-02) | Confirmed |
3. REST API
- Transport: HTTP/JSON over a UNIX domain socket (or an inherited fd) selected with
--api-socket path=.../--api-socket fd=.... The API is available as soon as the binary starts, even with no VM configured. Confirmed. Source: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/api.md (accessed 2026-11-02) - The HTTP server is Firecracker’s
micro_httpcrate; requests are translated to an internal MPSC command API consumed by the VMM control loop. Confirmed. Source:docs/api.md(accessed 2026-11-02) - Spec: OpenAPI 3.0.1,
info.version: 0.3.0,servers: http://localhost/api/v1. All paths are under/api/v1; the spec document version is not the CH release version. Confirmed. Source: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/vmm/src/api/openapi/cloud-hypervisor.yaml (accessed 2026-11-02) - Methods are RPC-style:
GETfor reads,PUTfor every mutation. Confirmed. Source: same spec (accessed 2026-11-02)
All 32 paths in the current spec (source: vmm/src/api/openapi/cloud-hypervisor.yaml, accessed 2026-11-02; purposes from docs/api.md, accessed 2026-11-02). Confirmed.
| Endpoint | Method | Purpose |
|---|---|---|
/vmm.ping | GET | Check API availability; returns VMM version/state |
/vmm.shutdown | PUT | Shut the VMM process down |
/vm.info | GET | Dump VM configuration/state info |
/vm.counters | GET | Dump VM I/O and device counters |
/vm.balloon-stats | GET | Last virtio-balloon statistics sample from the guest |
/vm.create | PUT | Create (not boot) a VM from a VmConfig |
/vm.delete | PUT | Delete the VM |
/vm.boot | PUT | Boot a created VM |
/vm.pause | PUT | Pause vCPUs |
/vm.resume | PUT | Resume a paused VM |
/vm.shutdown | PUT | Graceful guest shutdown |
/vm.reboot | PUT | Reboot the guest |
/vm.power-button | PUT | Trigger ACPI power button |
/vm.resize | PUT | Add/remove vCPUs or memory |
/vm.resize-disk | PUT | Resize an attached disk |
/vm.resize-zone | PUT | Add/remove memory in a memory zone |
/vm.add-device | PUT | Hotplug a VFIO PCI device |
/vm.remove-device | PUT | Remove a hotplugged device |
/vm.add-disk | PUT | Hotplug a disk |
/vm.add-fs | PUT | Hotplug a virtio-fs (vhost-user-fs) device |
/vm.add-generic-vhost-user | PUT | Hotplug a generic vhost-user device |
/vm.add-pmem | PUT | Hotplug a virtio-pmem device |
/vm.add-net | PUT | Hotplug a network device |
/vm.add-vsock | PUT | Hotplug a vsock device |
/vm.add-vdpa | PUT | Hotplug a vDPA device |
/vm.add-user-device | PUT | Hotplug a userspace PCI device (vfio-user) |
/vm.snapshot | PUT | Snapshot a paused VM to a file:// directory |
/vm.restore | PUT | Restore a VM from a snapshot (optionally with new net FDs) |
/vm.coredump | PUT | Guest coredump (x86_64, guest_debug build feature only) |
/vm.nmi | PUT | Inject NMI |
/vm.send-migration | PUT | Start sending this VM to a migration target |
/vm.receive-migration | PUT | Prepare to receive a migration |
/vm.cancel-migration | PUT | Cancel an in-flight migration (new in v54) |
- D-Bus API: mirrors the REST API (same JSON payloads, drop-in replacement) and adds a D-Bus
Eventsignal fed by the event monitor. It is not compiled by default; requires thedbus_apicargo feature and--dbus-service-name/--dbus-object-path/--dbus-system-bus. Interface nameorg.cloudhypervisor.DBusApi1. Confirmed. Source:docs/api.md(accessed 2026-11-02) ch-remote: a separate CLI binary that drives the REST API over the socket (all doc examples usech-remote --api-socket=... <action>). It is shipped as a release asset of v54.0. Confirmed. Sources:docs/api.md,docs/snapshot_restore.md(accessed 2026-11-02) and thech-remoteasset in https://api.github.com/repos/cloud-hypervisor/cloud-hypervisor/releases (accessed 2026-11-02)- Versioning/stability: the API is covered by the stability guarantee (no breaking change without 2 major releases’ notice), but it is not semantically versioned per release: the URL is fixed at
/api/v1while the spec doc version stays0.3.0. New fields and endpoints are added per release under that same prefix. Confirmed (stability text) / Hypothesis (that/api/v1will never change; nothing states a v2 plan). - A v54 fix raised the HTTP request body limit from 50 KiB to 4 MiB and corrected status codes (400/404/409). Confirmed. Source: https://www.cloudhypervisor.org/ (v54.0 release notes, accessed 2026-11-02)
4. Boot time
- Official marketing claim: “Boot to userspace in less than 100ms with direct kernel boot”. Confirmed that the claim is published; it is a marketing claim with no stated hardware, image, kernel or measurement method. Hypothesis as a number we can design against. Source: https://www.cloudhypervisor.org/ (accessed 2026-11-02)
- No boot-time number appears in the README,
docs/releases.mdor thedocs/pages fetched. Confirmed (absence) for those pages. Sources: README,docs/releases.md,docs/performance_metrics.md(accessed 2026-11-02) - CH ships a performance harness that measures
boot_time_ms,boot_time_pmem_ms,boot_time_16_vcpus_msandrestore_latency_time_ms; the table’s “timeout 2 s” is a test timeout, not a boot-time target, and no results are published in the doc. Confirmed. Source: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/performance_metrics.md (accessed 2026-11-02) - Two boot paths: (a) direct kernel boot —
--kernelwith a PVH-enabled ELFvmlinuxor a bzImage on x86-64,Imageon AArch64, plus--cmdlineand optional--initrd; (b) firmware boot —--firmwarepointing at rust-hypervisor-firmware (hypervisor-fw, a lightweight Rust PVH firmware, also bootable via--kernel) or the edk2 UEFI buildsCLOUDHV.fd(x86-64) /CLOUDHV_EFI.fd(AArch64). Confirmed. Source: README (accessed 2026-11-02) - Since v54, AArch64 can do firmware-aided direct kernel boot (kernel/cmdline/initramfs handed to firmware via fw_cfg). Confirmed. Source: cloudhypervisor.org v54.0 notes (accessed 2026-11-02)
5. Snapshot/restore and live migration
- Snapshot requires the VM to be paused; the snapshot is a directory of three files:
config.json(VM config, human-editable),memory-ranges(full guest RAM image),state.json(per-device/VM state). Confirmed. Source: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/snapshot_restore.md (accessed 2026-11-02) - The snapshot does not contain disk contents. The doc’s restore example reuses the original disk images, and the offload section warns that to restore consistently “you should copy the disk images while the VM is still paused, and restore the daemon’s snapshot against those copies”. Confirmed. Source:
docs/snapshot_restore.md(accessed 2026-11-02) - Restore is
--restore source_url=file:///pathorvm.restore; the VM comes back paused unlessresume=true. Net devices created with explicit FDs need fresh FDs passed asnet_fds=[id@[fd,fd],...]. Confirmed. Source:docs/snapshot_restore.md(accessed 2026-11-02) - Restore memory modes: default
copy(eager full copy ofmemory-ranges),ondemand(userfaultfd, faults pages in on first access; rejects VFIO, vDPA, vfio-user and vhost-user devices; needs Linux ≥6.6), andcopyonwrite(map the snapshot memory file COW, share page cache across clones; falls back to eager copy for shared memory/hugepages/hotplug/zones/passthrough). Confirmed. Source:docs/snapshot_restore.md(accessed 2026-11-02) - Snapshotting a VM restored with
copyonwriteuses a reflink and then writes only pages changed since restore — but only if snapshot and restore source are on the same reflink-capable filesystem (XFS/btrfs). Confirmed. Source:docs/snapshot_restore.md(accessed 2026-11-02) - VFIO snapshot/restore is supported only for devices implementing the kernel VFIO migration v2 protocol (e.g. Mellanox NICs on
mlx5_vfio_pci). Confirmed. Source:docs/snapshot_restore.md(accessed 2026-11-02) - Snapshot/restore support for vhost-user devices (including virtio-fs migration) was added earlier and is present. Confirmed. Source: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/release-notes.md (line: “Snapshot/restore support for
vhost-userdevices has been filled out (#7908), including migration support forvirtio-fs(#7937)”; accessed 2026-11-02) - Cross-version support is not guaranteed: README states “Snapshot/restore is not supported across different versions” and “Live migration is not supported across different versions”. Confirmed (README statement). Source: README (accessed 2026-11-02)
- However
docs/live_migration.mdstates that since v54 migrations from the previous two releases (n-2) are accepted; backward migration is not supported. This contradicts the README sentence; the live_migration doc is newer and more specific. Confirmed (both statements exist). Source: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/live_migration.md and cloudhypervisor.org v54 notes (accessed 2026-11-02) - Live migration transports: UNIX socket (same host) and TCP with mutual TLS (
tls_dir) across hosts; UNIX transport is not encrypted and cannot use TLS. Parameters includedowntime_ms(default 300 ms),timeout_s(3600),connections(1-128),memory_mode(precopydefault,postcopy,memfds),preserve_source,zone_updates,vfio_fds/iommufd_fd,background_fetch_pages. Confirmed. Source:docs/live_migration.md(accessed 2026-11-02) - Postcopy migration and on-demand restore are rejected for VMs with VFIO, vDPA, vfio-user or vhost-user devices. vDPA dirty tracking is unavailable, so vDPA devices are suspended before transfer. Confirmed. Sources:
docs/live_migration.md, cloudhypervisor.org v54 notes (accessed 2026-11-02) - Offload snapshot/restore: CH can hand the snapshot stream to a user daemon over the migration protocol (
offload_daemonreference binary), usingmemory_mode=memfdsand aunix:URL, so snapshots can be encrypted/compressed/streamed to object storage. Requiresshared=onor hugepages for all guest memory. Confirmed. Source:docs/snapshot_restore.md(accessed 2026-11-02) - Restore/snapshot on another host: the file-based snapshot is portable in principle, but
config.jsonholds absolute paths that must exist on the target, versions must match, and CPUID compatibility is checked. Hypothesis (assembled from the path requirements indocs/snapshot_restore.mdand the migration/CPU-compatibility notes; no doc states “restore on another host is supported”). - Incremental/dirty-page tracking: there is no user-facing API to obtain a dirty bitmap or an incremental snapshot. CH tracks dirty pages internally for precopy migration (the
vm.migration-memory-iterationevent) and VFIO device writes during migration, but exposes neither a bitmap nor a “diff snapshot” endpoint. Hypothesis (strongly implied by the endpoint list and the event/migration docs; the docs never state it explicitly). Sources:docs/live_migration.md,vmm/src/api/openapi/cloud-hypervisor.yaml(accessed 2026-11-02) - Disks are protected by host-local advisory OFD locks (
F_OFD_SETLK),lock_granularity=byte-range(default) orfull; locks do not coordinate across hosts. Confirmed. Source: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/disk_locking.md (accessed 2026-11-02)
6. virtio-fs
- CH supports virtio-fs only as a vhost-user device: an external
virtiofsdprocess owns the backend and CH connects to its UNIX socket via--fs tag=<tag>,socket=<path>,num_queues=...,queue_size=...(or hotplugvm.add-fs). Confirmed. Sources: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/fs.md anddocs/device_model.md(accessed 2026-11-02) --memory shared=onis required: “Correct functioning of--fsrequires--memory shared=onto facilitate interprocess memory sharing.” Confirmed. Source:docs/fs.md(accessed 2026-11-02)- virtiofsd must be built from source in the CH docs (
cargo build --release,setcap cap_sys_admin+epi). Upstream is a Rust daemon: https://gitlab.com/virtio-fs/virtiofsd, licenseApache-2.0 AND BSD-3-Clause, latest tag v1.14.0 (2026-07-06). Confirmed. Sources:docs/fs.md(accessed 2026-11-02); https://gitlab.com/virtio-fs/virtiofsd/-/raw/main/Cargo.toml and https://gitlab.com/api/v4/projects/virtio-fs%2Fvirtiofsd/repository/tags (accessed 2026-11-02) - Recommended/typical options in CH docs:
--cache=never(default, avoids host page cache, better density),--cache=alwaysfor performance,--thread-pool-size=$N. Guest kernel needs virtio-fs support (Linux ≥5.10). Confirmed. Source:docs/fs.md(accessed 2026-11-02) - DAX is not available: “Given the DAX feature is not stable yet from a daemon standpoint, it is not available in Cloud Hypervisor.” The
daxoption was deprecated and then removed (#3889). Confirmed. Sources:docs/fs.mdandrelease-notes.md(accessed 2026-11-02) - CH also has a
--generic-vhost-userdevice, so any vhost-user backend CH does not model natively can still be attached (the fs tag then lives on the virtiofsd command line). Confirmed. Source:docs/device_model.md(accessed 2026-11-02)
7. Console and display
- Serial (
--serial, legacy 16550A on x86-64 / PL011 on AArch64) and virtio-console (--console, enabled by default) share the same backends:off,null,tty,pty,file=<path>,socket=<path>(UNIX socket) andtcp=<loopback_ip>:<port>(IPv4/IPv6 literal on loopback only, listener only, one client at a time, output buffered until a client connects, boot does not wait for a client). Confirmed. Source: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/device_model.md (accessed 2026-11-02) --console socket=<path>for virtio-console is new in v54; configurations that pass a socket path without selecting socket mode (and vice versa) are now rejected for both--serialand--console. Confirmed. Source: cloudhypervisor.org v54.0 release notes (accessed 2026-11-02)- A host process (e.g. a node agent) attaches by connecting to the UNIX socket and relaying bytes; this is the natural input for a browser terminal (xterm.js). The socket serves one client at a time and accepts a new client only after the current one disconnects. Confirmed (socket behaviour) / Hypothesis (that it is a plain byte stream suitable for direct relay; the docs say “UNIX socket” without stating the socket type).
- Boot loader/firmware interaction requires serial rather than virtio-console (
--serial tty --console off), because virtio-console can cut early boot messages. Confirmed. Source: README (accessed 2026-11-02) - TCP console live migration requires the listener address to be free on the destination; overriding the destination console address is not supported. Confirmed. Source:
docs/device_model.md(accessed 2026-11-02) - No VNC, no SPICE, no GPU and no framebuffer. The device model lists only serial, RTC/CMOS, I/O APIC, i8042, ACPI, PL061, virtio-{blk,console,iommu,net,pmem,rng,vsock}, vhost-user-{blk,fs,net,generic} and VFIO. There is no display device. Confirmed for the device model. Source:
docs/device_model.md(accessed 2026-11-02) vmm/src/config.rs(8233 lines, the full CLI surface) contains novnc,spice,virtio-gpuor display option; the onlygpumatches areVIRTIO_ID_GPU(for generic vhost-user device type mapping) and NVIDIAx_nv_gpudirect_cliqueVFIO passthrough options. Confirmed. Source: https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/vmm/src/config.rs (accessed 2026-11-02)- A graphical guest is only possible by passing through a physical GPU via VFIO (
--device) into a guest that runs its own display stack; CH itself still provides no virtual display. Hypothesis (follows from the absence of a display device plus the VFIO documentation).
8. Other engine-relevant facts
| Fact | Value | Source | Label |
|---|---|---|---|
| Hypervisors | KVM and Microsoft Hypervisor (MSHV) | README (accessed 2026-11-02) | Confirmed |
| Architectures | x86-64 and AArch64 main; riscv64 experimental with limited functionality | README + docs/riscv.md (accessed 2026-11-02) | Confirmed |
| Guest OS | 64-bit Linux; Windows 10 / Windows Server 2019 | README (accessed 2026-11-02) | Confirmed |
| Minimum host kernel | 6.8 (“For required KVM functionality and adequate performance”) | README (accessed 2026-11-02) | Confirmed |
| Hotplug | CPU, memory (resize + zones + virtio-mem), VFIO passthrough, virtio-{net,block,pmem,fs,vsock}, generic vhost-user, vDPA | README + docs/hotplug.md + API list (accessed 2026-11-02) | Confirmed |
| Networking | TAP created by CH (--net), macvtap via fd= on /dev/tapN, vhost-user-net (vhost_user=true,socket=), plus --net fd= for orchestrator-provided FDs | README, docs/macvtap-bridge.md, docs/device_model.md (accessed 2026-11-02) | Confirmed |
| Seccomp | Enabled by default, per-thread filters (vCPU, virtio, HTTP, main, VMM); disable with --seccomp false | docs/seccomp.md (accessed 2026-11-02) | Confirmed |
| Landlock | Optional --landlock sandbox, rules enumerated at vm_create; --landlock-rules for extra paths | docs/landlock.md (accessed 2026-11-02) | Confirmed |
| Rate limiting | Token-bucket I/O throttling on virtio-blk and virtio-net (bw_size/bw_one_time_burst/bw_refill_time, ops_size/ops_one_time_burst/ops_refill_time); fixed 100 ms cool-down makes real rates diverge from nominal | docs/io_throttling.md (accessed 2026-11-02) | Confirmed |
| Event monitor | --event-monitor, JSON events (e.g. vm.migration-*, vmm.started) | docs/event-monitor.md, v54 notes (accessed 2026-11-02) | Confirmed |
| Nested virt to guests | --cpus nested=on (x86_64 KVM/MSHV, aarch64 KVM); default off since v54 | docs/cpu.md + v54 notes (accessed 2026-11-02) | Confirmed |
| CH inside a nested guest | No official doc found on running CH as an L2 VMM. It is an ordinary KVM/MSHV userspace process, so it should work wherever the L1 exposes VMX/SVM and /dev/kvm, with performance and feature caveats. | no primary source | Hypothesis / open risk |
| Virtio transport | virtio-pci only; multiple PCI segments; pci_segment= / pci_device_id= placement | docs/device_model.md (accessed 2026-11-02) | Confirmed |
9. Comparison with Firecracker (sourced)
Both are Rust rust-vmm VMMs, but Firecracker is Apache-2.0 only, has a narrower device model — its design doc lists VirtIO Net, Block and Vsock (plus serial, i8042, balloon, entropy), with no virtio-fs and no PCI — and its API is also HTTP/JSON over a UNIX socket but with a flat, Swagger 2.0 resource API (/machine-config, /drives/{id}, /snapshot/create, /snapshot/load, …). Firecracker snapshots support full and diff snapshots, with diff snapshots marked developer preview and usable only when the base VM was booted/restored with track_dirty_pages=true; snapshots “must be resumed on a software and hardware configuration identical to what they were generated on”. Confirmed. Sources: https://raw.githubusercontent.com/firecracker-microvm/firecracker/main/README.md, https://raw.githubusercontent.com/firecracker-microvm/firecracker/main/docs/design.md, https://raw.githubusercontent.com/firecracker-microvm/firecracker/main/docs/snapshotting/snapshot-support.md, https://raw.githubusercontent.com/firecracker-microvm/firecracker/main/src/firecracker/swagger/firecracker.yaml, https://raw.githubusercontent.com/firecracker-microvm/firecracker/main/src/firecracker/Cargo.toml (accessed 2026-11-02)
Versions checked
| Component | Version | Release date | Source |
|---|---|---|---|
| Cloud Hypervisor | v54.0 | 2026-10-14 | https://api.github.com/repos/cloud-hypervisor/cloud-hypervisor/releases (accessed 2026-11-02) |
| Cloud Hypervisor (main branch) | 55.0.0 (unreleased) | n/a | https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/cloud-hypervisor/Cargo.toml (accessed 2026-11-02) |
| CH OpenAPI spec | 0.3.0 (document version) | current main | https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/vmm/src/api/openapi/cloud-hypervisor.yaml (accessed 2026-11-02) |
| virtiofsd | v1.14.0 | 2026-07-06 | https://gitlab.com/api/v4/projects/virtio-fs%2Fvirtiofsd/repository/tags (accessed 2026-11-02) |
| rust-hypervisor-firmware | 0.5.0 | 2024-11-30 (≈2 years old) | https://api.github.com/repos/cloud-hypervisor/rust-hypervisor-firmware/releases (accessed 2026-11-02) |
| CH edk2 / CLOUDHV | ch-97eeb7b09 | 2026-09-22 | https://api.github.com/repos/cloud-hypervisor/edk2/releases (accessed 2026-11-02) |
| Firecracker (comparison) | v1.17.0 | 2026-09-10 | https://api.github.com/repos/firecracker-microvm/firecracker/releases (accessed 2026-11-02) |
Implications for Quoro
- D-0007 (own engine on CH + KVM): license risk is low. CH is
Apache-2.0 AND BSD-3-Clause(permissive), so an AGPL-3.0 Quoro can spawn it as a separate process and even link its crates. Only the legal reading is unconfirmed; keep Apache-2.0 and BSD-3-Clause notice files in the distribution. - D-0007: the API surface is sufficient for a real engine: create/boot/pause/resume/shutdown, CPU+memory+disk+net hotplug, counters, balloon, snapshot/restore, migration, event monitor. It is HTTP/JSON over a UNIX socket, so the Rust agent can use any HTTP client over
UnixStream— no need for a CH-specific SDK. There is no versioned v2 to design against: pin one CH release per node-agent build. - D-0008 (one microVM per app, fast boot): the <100 ms figure is a marketing claim without methodology; no primary benchmark was found. Boot time will be dominated by our image, kernel, init and disk, not by CH. The direct-kernel-boot path (PVH
vmlinux/bzImage, no firmware) is the one to use for the warm path; firmware/edk2 adds a bootloader stage. - D-0028 (consoles/metrics: xterm.js, noVNC): CH has no VNC, no virtual GPU and no framebuffer device. noVNC has nothing to attach to on the CH side. The console plan must be text-only: xterm.js over the serial or virtio-console UNIX socket (
--serial socket=/--console socket=, one client at a time), relayed by the node agent, or vsock into an in-guest agent. If a graphical console is ever required, it needs a guest-side VNC/desktop server or GPU passthrough, both outside CH’s device model. This is a factual gap in D-0028’s stated tool list, not a preference. - Q-0004 (snapshot primitive): evidence, not a decision. CH snapshot = pause + full
memory-rangesdump +config.json+state.json, and it excludes disk contents, so any snapshot primitive must still solve disk consistency separately (that is exactly the open part of Q-0004). CH exposes no dirty bitmap and no incremental/diff snapshot API, so a chunk engine would re-read a full RAM image each cycle and rely on content-addressed dedup — workable but O(RAM) I/O per snapshot. Partial mitigations CH does offer:copyonwriterestore (page-cache sharing, and reflink-based delta when re-snapshotting a COW-restored VM on XFS/btrfs) andondemandrestore. - D-0023 (own chunk engine): CH’s offload-daemon mechanism (
send-migrationto aunix:peer withmemory_mode=memfds, plus a documented wire protocol invm-migration/src/protocol.rs) is a possible streaming feed for the chunk engine’s snapshot source, instead of writingmemory-rangesto local disk first. That is evidence for feasibility, not a recommendation. - D-0009 (KVM-capable host, nested virt accepted): CH requires KVM (or MSHV) and host kernel ≥6.8. No official document covers CH running inside an L2 guest; nested capability depends on the VPS exposing VMX/SVM. Also since v54, CH disables nested virt to its own guests by default (
--cpus nested=onneeded), which does not affect us unless an app needs KVM inside its microVM. - Snapshot portability: file-based snapshots can in principle be moved between nodes, but
config.jsonstores absolute paths, versions must match, and CPUID is checked. Cross-node snapshot/restore is a risk to validate on the lab, not an assumed capability.
Open risks and unknowns
- No primary benchmark for boot time. The <100 ms claim is unverified for our kernels/images. Needs a lab measurement per image class before D-0008’s “warm ≈ hot” reasoning is relied on.
- AGPL-3.0 compatibility is a legal reading, not a sourced statement. Apache-2.0 is GPLv3-compatible per the FSF; the separate-process argument is our inference. Legal review needed before shipping.
- Snapshot/restore across versions is ambiguous in the docs: README says not supported across versions;
docs/live_migration.mdpromises n-2 migration since v54;docs/releases.mdsays not compatible across MAJOR. Only migration has a clear n-2 promise; snapshot restore has none. Pin versions per node. - Cross-host snapshot restore (path layout, CPU model, disk lock/consistency) is undocumented; treat as needing validation.
- Incremental snapshot / dirty bitmap: no API found. If Q-0004 needs block-level deltas from the hypervisor, CH will not provide them; the diff must be computed above CH (or via the storage layer).
- virtio-fs requires an extra process (
virtiofsd) per share,shared=onmemory (which interacts badly withcopyonwriteand with hugepages), and no DAX. Operationally this is a second daemon to supervise and secure (cap_sys_admin). - rust-hypervisor-firmware 0.5.0 is from 2024-11-30, i.e. ~2 years old at access date; treat firmware path as less actively maintained than CH itself.
- Nested-virt hosts (D-0009) have no CH documentation; performance and feature availability (e.g. MSHV, SEV-SNP, IOMMU) inside a nested guest are unknown.
- The CH repo has no top-level
LICENSEfile and GitHub reportslicense: null; automated license scanners may flag it. Use theCargo.tomlSPDX expression andLICENSES/texts.
Sources
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/README.md (accessed 2026-11-02)
- https://api.github.com/repos/cloud-hypervisor/cloud-hypervisor (accessed 2026-11-02)
- https://api.github.com/repos/cloud-hypervisor/cloud-hypervisor/contents/ (accessed 2026-11-02)
- https://api.github.com/repos/cloud-hypervisor/cloud-hypervisor/contents/LICENSES (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/.reuse/dep5 (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/cloud-hypervisor/Cargo.toml (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/api_client/Cargo.toml (accessed 2026-11-02)
- https://api.github.com/repos/cloud-hypervisor/cloud-hypervisor/releases (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/releases.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/MAINTAINERS.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/api.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/vmm/src/api/openapi/cloud-hypervisor.yaml (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/snapshot_restore.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/live_migration.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/fs.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/device_model.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/threat-model.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/cpu.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/seccomp.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/landlock.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/io_throttling.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/macvtap-bridge.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/disk_locking.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/docs/performance_metrics.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/release-notes.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/main/vmm/src/config.rs (accessed 2026-11-02)
- https://www.cloudhypervisor.org/ (accessed 2026-11-02)
- https://gitlab.com/virtio-fs/virtiofsd/-/raw/main/Cargo.toml (accessed 2026-11-02)
- https://gitlab.com/api/v4/projects/virtio-fs%2Fvirtiofsd/repository/tags (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/rust-hypervisor-firmware/main/LICENSE (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloud-hypervisor/rust-hypervisor-firmware/main/Cargo.toml (accessed 2026-11-02)
- https://api.github.com/repos/cloud-hypervisor/rust-hypervisor-firmware/releases (accessed 2026-11-02)
- https://api.github.com/repos/cloud-hypervisor/edk2/releases (accessed 2026-11-02)
- https://www.gnu.org/licenses/license-list.html#apache2 (accessed 2026-11-02)
- https://raw.githubusercontent.com/firecracker-microvm/firecracker/main/README.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/firecracker-microvm/firecracker/main/docs/design.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/firecracker-microvm/firecracker/main/docs/snapshotting/snapshot-support.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/firecracker-microvm/firecracker/main/src/firecracker/swagger/firecracker.yaml (accessed 2026-11-02)
- https://raw.githubusercontent.com/firecracker-microvm/firecracker/main/src/firecracker/Cargo.toml (accessed 2026-11-02)
- https://api.github.com/repos/firecracker-microvm/firecracker/releases (accessed 2026-11-02)
Cloudflare Load Balancing pricing/health checks and cloudflared replica behaviour
- Question: Q-0013 VERIFY: Cloudflare Load Balancing pricing/health checks and cloudflared multi-replica failover behavior
- Researched: 2026-11-02 by agent:ds-research-3
- Status: answered (with two documented contradictions flagged)
Short answer
Load Balancing is a paid add-on available on the Free, Pro and Business plans: $5/month covers up to 2 origin servers, +$5/month per origin up to 20, plus usage at $0.50 per 500k DNS requests after the first 500k (per month). Geo routing is +$10/month. Health checks start at 60s intervals from 1 location; 30s and 15s intervals cost +$10 and +$15/month, and 3 or 5 check locations cost +$10 and +$15/month. Monitors speak HTTP/HTTPS, TCP, ICMP, UDP-ICMP and SMTP, with configurable timeout/retries, so failover is on the order of tens of seconds to a couple of minutes, not seconds.
A Load Balancer can use a tunnel as an origin, but only as <UUID>.cfargotunnel.com with the app hostname in the Host header — not as app.example.com — and a single pool cannot reference the same tunnel UUID twice. An HTTPS monitor with Host set to the app hostname goes through cloudflared to the app, so it does test the app. TCP monitors are not supported for tunnel endpoints, though. The documented workaround (an HTTP_STATUS route on cloudflared) only proves that cloudflared is reachable, not that the app is up. A Load Balancer also cannot tell replicas of one tunnel UUID apart: they count as one endpoint.
cloudflared replicas (max 25 per tunnel, 4 connections each) give host-level failover only: the nearest replica is chosen, there is no steering guarantee, and each replica forwards to its locally configured service. If a replica’s local app is down, that replica still receives traffic and returns the app’s error (typically 502). Replicas are documented as identical copies of one tunnel (“all replicas serve the same routes and use the same configuration parameters”), not as N hosts each serving a different app; for that, the documented pattern is one tunnel per site plus a Load Balancer.
Findings
1. Load Balancing pricing and tier availability
All figures below are quoted verbatim from Cloudflare’s own plans comparison table on the Analytics product page (the “Load Balancing”, “Global Load Balancing (Geo-based Routing)” and “Load Balancing Health Monitors” rows). Confirmed. Source: https://www.cloudflare.com/en-gb/application-services/products/analytics/ (accessed 2026-11-02)
Note on sourcing: the previously cited /en-in/ URL returns a bot challenge to a plain fetch and is not the Load Balancing page, so it has been replaced. The developers.cloudflare.com/load-balancing/ docs no longer publish prices at all — the product index page now sends readers to the dashboard (“Explore pricing options for Load Balancing in the dashboard”, https://developers.cloudflare.com/load-balancing/index.md, accessed 2026-11-02). The marketing page above is therefore the only public, fetchable, itemised source.
| Item | Price / limit | Vendor wording (verbatim) | Label |
|---|---|---|---|
| Base fee | $5/month for up to 2 origin servers | “Up to 2 origin servers: $5 per month” | Confirmed |
| Extra origins | $5/month per origin, up to 20 | “Additional origins, up to 20: $5 per month per origin” | Confirmed |
| Usage (DNS queries) | First 500k DNS requests/month free; $0.50 per 500k after | “First 500k DNS requests are free” / “$0.50 per every 500k DNS requests after” | Confirmed |
| Geo routing (Global LB) | +$10/month | “Enable for $10 per month” | Confirmed |
| Health check interval | 60s included; 30s +$10/month; 15s +$15/month | “Every 60 seconds: included” / “Every 30 seconds: additional $10 per month” / “Every 15 seconds: additional $15 per month” | Confirmed |
| Health check locations | 1 included; up to 3 +$10/month; up to 5 +$15/month | “1 location included” / “Up to 3 locations: additional $10 per month” / “Up to 5 locations: additional $15 per month” | Confirmed |
| Availability by plan | Free, Pro, Business (self-serve add-on); Enterprise custom | The table’s four columns are Free / Pro / Business / Contract, and Load Balancing is marked “Paid Add-on” under Free as well as Pro and Business | Confirmed |
| Enterprise | Custom pricing; checks every 5s from all data centres | “Custom Pricing” / “Includes health checks every 5 seconds.” / “Includes health checks from every Cloudflare data center.” | Confirmed |
- The plans pages themselves only repeat “Starting at $5 per month” for Load Balancing and do not itemise anything. Confirmed. Sources: https://www.cloudflare.com/plans/application-services/ and https://www.cloudflare.com/plans/network-cdn.md (both accessed 2026-11-02).
- The same base-fee and usage text also appears in English on the Web Optimization page, which is an independent fetchable copy. Confirmed. Source: https://www.cloudflare.com/application-services/products/website-optimization/ (accessed 2026-11-02) — “Up to 2 origin servers: $5 per month”, “Additional origins, up to 20: $5 per month per origin”, “First 500k DNS requests are free”, “$0.50 per every 500k DNS requests after”, “Enable for $10 per month”.
- “First 500K included” is corroborated by the billing docs, which also confirm the metered dimension is DNS queries to load-balanced hostnames, not bytes. Confirmed. Source: https://developers.cloudflare.com/billing/understand/how-charges-accrue/index.md (accessed 2026-11-02; last updated 2026-04-24): “If you use Load Balancing, DNS queries to load-balanced hostnames are metered (first 500K included).” Also https://developers.cloudflare.com/billing/understand/usage-based-billing/index.md (accessed 2026-11-02): Load Balancing billable metric “DNS queries”, free tier “First 500K queries”.
- Account quotas (non-Enterprise): 20 load balancers, 20 pools, 20 endpoints, monitors limited to 1.5x pools, monitor interval 15s–3600s. Confirmed. Source: https://developers.cloudflare.com/load-balancing/reference/limitations/ (accessed 2026-11-02; page last updated 2026-04-16)
- Contradiction (unresolved): the limitations page says the minimum interval is 15s for non-Enterprise and 10s for Enterprise, while the pricing table says Enterprise includes 5s checks. Both statements are now confirmed quotes from Cloudflare pages, so the disagreement is real and not a fetch artefact. Treat 15s as the safe non-Enterprise floor and do not design an Enterprise tier against either number. Hypothesis (which is current).
2. Health checks (monitors)
- Supported monitor types: HTTP/HTTPS, TCP, ICMP Ping, UDP-ICMP, SMTP. HTTP/HTTPS and TCP work for public and private endpoints; ICMP and UDP-ICMP also work for Tunnel; SMTP is public only. Confirmed. Source: https://developers.cloudflare.com/load-balancing/monitors/ (accessed 2026-11-02; last updated 2026-07-23)
- Each selected region probes from three data centres; a region is healthy if the majority of its data centres pass, and the endpoint is healthy if the majority of regions pass. Confirmed. Same URL.
- Configurable per monitor: interval, timeout, retries, expected codes/body, method, path, headers,
consecutive_up/consecutive_down. Confirmed. Sources: https://developers.cloudflare.com/load-balancing/monitors/ and https://developers.cloudflare.com/load-balancing/understand-basics/health-details/ (accessed 2026-11-02) - A pool becomes Critical when healthy endpoints fall below its Health Threshold, and traffic then follows the steering policy; the fallback pool is used as last resort and its health is ignored. Confirmed. Source: https://developers.cloudflare.com/load-balancing/understand-basics/health-details/ (accessed 2026-11-02; last updated 2026-04-16)
- With no monitor attached, pool health is not considered during steering. Confirmed. Same URL.
- Failover latency is roughly
interval x (retries + 1), plus region majority voting, plus DNS/proxy caching (Cloudflare caches resolved IPs for up to 5s). At the cheapest tier (60s, 1 region, 3 data centres) that is ~2–3 minutes worst case. Hypothesis (arithmetic from documented interval/retries; Cloudflare does not publish a failover-time figure). Sources: monitors page and https://developers.cloudflare.com/load-balancing/troubleshooting/load-balancing-faq/ (accessed 2026-11-02) - Steering policies: Standard (failover/random), Geo, Dynamic (latency), Proximity, Least Outstanding Requests; origin-level: random, hash, least outstanding. Confirmed. Source: https://developers.cloudflare.com/load-balancing/understand-basics/traffic-steering/steering-policies/ (accessed 2026-11-02; last updated 2026-09-10)
- Session affinity: cookie (
__cflb, default 23h TTL), cookie + client-IP fallback, or HTTP header; public load balancers only, not in DNS-only (grey cloud) mode. Confirmed. Source: https://developers.cloudflare.com/load-balancing/understand-basics/session-affinity/ (accessed 2026-11-02; last updated 2026-04-17) - Zero-Downtime Failover (none/temporary/sticky) covers failures between health checks. Confirmed. Same URL.
3. Cloudflare Tunnel endpoints as Load Balancer origins
- A published application route generates
<UUID>.cfargotunnel.com. That hostname plus the app hostname in the endpoint’s Host header is how you add a tunnel to a pool. Confirmed. Source: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/routing-to-tunnel/public-load-balancers (accessed 2026-11-02; last updated 2026-06-23) - “Load Balancer does not support directly adding
app.example.comas an endpoint if the service is behind Cloudflare Tunnel.” Confirmed. Same URL. - “A single origin pool cannot reference the same tunnel UUID twice.” Confirmed. Same URL.
- Recommended monitor for tunnel origins: HTTPS monitor with
Host= published app hostname. Confirmed. Same URL. - “TCP monitors are not supported for tunnel endpoints. Instead, create a health check endpoint on the
cloudflaredhost and use an HTTPS monitor”, e.g. a route with service typeHTTP_STATUS200. Confirmed. Same URL. - “This monitor verifies that
cloudflaredis reachable. It does not check whether the upstream service is accepting requests.” Confirmed. Same URL. Scope (reviewer correction, agent:so-research): this sentence in the docs refers only to theHTTP_STATUShealth-check route offered as a workaround for the missing TCP monitors. The recommended HTTPS monitor withHost= app hostname (above) is proxied throughcloudflaredto the app and returns the app’s status, so it is an app-level check. Hypothesis for the app-level reading: the docs recommend that monitor but do not spell out what it covers. With replicas of one UUID, though, the monitor reaches whichever replica is nearest, so it can pass while another replica’s local app is down. - “The load balancer does not distinguish between replicas of the same tunnel. If you run the same tunnel UUID on two separate hosts, the load balancer treats both hosts as a single endpoint.” Session affinity to a specific host therefore requires a different tunnel UUID per host. Confirmed. Same URL.
- Private hostname routes are not compatible with Load Balancing; use replicas for HA there. Confirmed. Source: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/tunnel-availability/ (accessed 2026-11-02; last updated 2026-04-17)
- Documented topology for real per-site failover: one dedicated tunnel per data centre, one pool per tunnel, one endpoint per pool, plus ≥2 replicas per tunnel inside each site; active-active requires multiple tunnel endpoints per pool (different UUIDs). Confirmed. Source: public-load-balancers page above.
- Caveat on distribution even with LB:
cloudflaredprefers connections in the same Cloudflare data centre, which can skew traffic across endpoints; Cloudflare suggests separate tunnels for finer steering. Confirmed. Same URL.
4. cloudflared replicas
- Every
cloudflaredinstance opens four outbound connections to four different Cloudflare servers across at least two data centres. Confirmed. Source: tunnel-availability page (accessed 2026-11-02) - Replicas: “You can run the same tunnel across various
cloudflaredprocesses for up to 100 connections (25 replicas) per tunnel.” Confirmed. Source: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/tunnel-availability/deploy-replicas/ (accessed 2026-11-02; last updated 2026-04-17) - Account limit agrees: “Active
cloudflaredreplicas per tunnel: 25”. Confirmed. Source: https://developers.cloudflare.com/cloudflare-one/account-limits/ (accessed 2026-11-02; last updated 2026-10-16) - “If your tunnel runs as a service, only one
cloudflaredinstance is allowed per host.” Confirmed. Same deploy-replicas URL. - Traffic distribution: “Replicas do not support traffic steering (such as round-robin or hash-based routing). When a request arrives at Cloudflare, it is forwarded to the geographically closest replica. If that connection fails, Cloudflare retries with other replicas, but there is no guarantee about which one is chosen.” Confirmed. Source: tunnel-availability page.
- On host death: remaining replicas keep serving; Cloudflare retries other replicas when a connection fails. Confirmed that this is the design; the time to detect and reroute is not published. Hypothesis that it is sub-second to a few seconds (connection-level failure, not health-check driven).
- Does a replica check the health of its local origin service? No documented health checking of the upstream service is performed by
cloudflared. Hypothesis — no Cloudflare page states “cloudflared health-checks the local service”; the external LB monitor is the only health check documented, and theHTTP_STATUSvariant of it explicitly “does not check whether the upstream service is accepting requests”. Consequence: a replica whose local app is down keeps receiving traffic and returns the local connection error (502/1033-style). Hypothesis. - Are replicas meant to be identical? The docs describe replicas as the same tunnel run on multiple hosts: “All replicas serve the same routes and use the same configuration parameters.” The HA purpose is “if a single host running
cloudflaredgoes down, the remaining replicas continue to serve traffic”, and the diagram shows both replicas pointing at the sameApplication. Confirmed (wording). Therefore “replicas on different hosts each serving a different local app” is not the documented pattern; the documented equivalent is one tunnel per site/pool. Using one UUID across hosts with divergent local services would put requests for any route on any host, i.e. random 502s. Hypothesis (behavioural consequence, not an explicit prohibition).
5. cloudflared version and licence
- Latest release:
2026.10.7, published 2026-10-30. Confirmed. Source: https://github.com/cloudflare/cloudflared/releases (accessed 2026-11-02) - Licence: Apache License 2.0. Confirmed. Source: https://raw.githubusercontent.com/cloudflare/cloudflared/master/LICENSE (accessed 2026-11-02)
6. Tunnel limits relevant to a free/hobby deployment
| Limit | Value | Label |
|---|---|---|
cloudflared tunnels per account | 1,000 | Confirmed |
| Active replicas per tunnel | 25 | Confirmed |
| Routes (CIDR + hostname) per account | 50,000 (shared with Cloudflare Mesh) | Confirmed |
| Published applications per tunnel | limited by 1 MB configuration size (~5,000–10,000 per changelog) | Confirmed |
Sources: https://developers.cloudflare.com/cloudflare-one/account-limits/ and https://developers.cloudflare.com/cloudflare-one/changelog/tunnel/ (accessed 2026-11-02).
- Tunnel itself is not priced per tunnel at these volumes. Beyond the 1,000 tunnels included in every account, “Tunnels for Platforms” sells extra capacity at $4.50 per additional tunnel per month. Confirmed (two Cloudflare pages, exact wording below).
- Changelog entry dated 2026-10-16: “Every account includes 1,000 tunnels. With Tunnels for Platforms, you can now run more than 1,000 tunnels in a single account… Additional tunnels are billed at $4.50 per tunnel per month. Enterprise customers can contact their account team for committed or usage-based pricing.” Source: https://developers.cloudflare.com/cloudflare-one/changelog/tunnel/ (accessed 2026-11-02).
- Product page: “Tunnels for Platforms costs $4.50 per additional tunnel per month. The first 1,000 tunnels in every account remain included at no cost.” Source: https://developers.cloudflare.com/tunnel/features/tunnels-for-platforms/ (accessed 2026-11-02; last updated 2026-10-16). Same page: every tunnel counts toward the limit, including inactive ones with no connected replicas.
Versions checked
| Component | Version | Release date | Source |
|---|---|---|---|
| cloudflared | 2026.10.7 | 2026-10-30 | https://github.com/cloudflare/cloudflared/releases |
| cloudflared licence | Apache-2.0 | n/a | https://raw.githubusercontent.com/cloudflare/cloudflared/master/LICENSE |
| LB docs — Monitors | page last updated 2026-07-23 | — | https://developers.cloudflare.com/load-balancing/monitors/ |
| LB docs — Limitations | page last updated 2026-04-16 | — | https://developers.cloudflare.com/load-balancing/reference/limitations/ |
| Tunnel docs — availability/failover | page last updated 2026-04-17 | — | https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/tunnel-availability/ |
| Tunnel docs — public load balancers | page last updated 2026-06-23 | — | https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/routing-to-tunnel/public-load-balancers |
| Zero Trust account limits | page last updated 2026-10-16 | — | https://developers.cloudflare.com/cloudflare-one/account-limits/ |
Implications for Quoro
- D-0024 needs a correction, not a reversal. It says “Multiple cloudflared replicas on the same tunnel give basic failover; Cloudflare paid LB adds real health checks.” The first half is right (host-level failover). The second half is only half right: over a tunnel, an LB monitor checks that
cloudflaredanswers, not that the app answers (unless the app exposes a distinctive health route on a published hostname). So Cloudflare LB does not give Quoro app-level health for tunnelled origins out of the box; Quoro’s own health probe (the witness/edge of D-0018), or an app-level/healthroute published through the tunnel and monitored by content/code, is still required. - Replicas are not a poor-man’s multi-node LB across different apps. One tunnel UUID = one logical origin; replicas are identical copies. If Quoro puts one
cloudflaredper node and each node serves that node’s apps, the documented pattern is one tunnel per node + an LB pool with one endpoint per tunnel UUID (active-active), not one shared UUID. Sharing a UUID across nodes would route requests to a node that may not host the requested app. - Failover is slow relative to a supervisor. Cheapest tier: 60s interval, 1 region, 3 data centres, plus ~5s DNS caching → tens of seconds to minutes. Quoro should not present Cloudflare failover as a substitute for in-cluster failover; it is a last-resort layer.
- D-0018 (edge as witness) still holds, but Cloudflare cannot be the witness. The LB’s health view over a tunnel is connector-level, so a Cloudflare-only edge cannot testify about app health. A small VPS edge that probes apps directly remains the only way to get the “sees both nodes, decides where traffic goes” property described in D-0018.
- D-0025 (provider abstraction) is cheap to honour. Everything used here (pools, monitors, tunnel UUID endpoints, DNS) is API-addressable, so a
LbProviderinterface with a Cloudflare implementation does not require modelling proprietary concepts beyond pool/endpoint/monitor. - Cost of a load balancer for 2 origins with 15 s checks from 3 regions: $5 base + $15 for 15 s checks + $10 for 3 regions ≈ $30/month before DNS-query usage; a single-region 30 s setup is about $15/month.
Open risks and unknowns
- Enterprise minimum interval is contradictory (5s in the pricing table vs 10s in the limitations table). Do not design against either without a live check.
- No official failover-time figure. The interval x retries estimate ignores region majority voting and Cloudflare’s internal propagation; real numbers must be measured.
- 502 behaviour from a dead local upstream is inferred, not documented. No Cloudflare page states what status
cloudflaredreturns when its configured service refuses connections; it must be measured (it also differs per protocol: HTTP vs TCP/SSH). - No documented statement that a replica never checks its local service. Absence of documentation is not proof; the claim is labelled Hypothesis on purpose.
- Pricing is only published in marketing/comparison tables, not in the docs or an API. The
/plans/*.mdpages dropped the itemisation, and the Load Balancing docs index now says only “Explore pricing options for Load Balancing in the dashboard”. Prices can change without a docs changelog, so re-check before quoting a cost to a user. - Same-UUID replicas and LB: docs say LB treats them as one endpoint, but whether per-request retry causes a different replica to serve a retried request is not documented.
- Not verified: whether Cloudflare Zero Trust “free” tier imposes any additional tunnel limits beyond the account limits page (no separate free-tier table was found).
Sources
- https://developers.cloudflare.com/load-balancing/ (accessed 2026-11-02)
- https://www.cloudflare.com/en-gb/application-services/products/analytics/ (accessed 2026-11-02) — plans comparison table with the itemised Load Balancing, Global LB and health-monitor pricing rows
- https://www.cloudflare.com/application-services/products/website-optimization/ (accessed 2026-11-02) — independent English copy of the base fee / usage / geo-routing text
- https://www.cloudflare.com/plans/application-services/ (accessed 2026-11-02)
- https://www.cloudflare.com/plans/network-cdn.md (accessed 2026-11-02)
- https://developers.cloudflare.com/billing/understand/how-charges-accrue/index.md (accessed 2026-11-02)
- https://developers.cloudflare.com/billing/understand/usage-based-billing/index.md (accessed 2026-11-02)
- https://developers.cloudflare.com/load-balancing/monitors/ (accessed 2026-11-02)
- https://developers.cloudflare.com/load-balancing/understand-basics/health-details/ (accessed 2026-11-02)
- https://developers.cloudflare.com/load-balancing/reference/limitations/ (accessed 2026-11-02)
- https://developers.cloudflare.com/load-balancing/understand-basics/traffic-steering/steering-policies/ (accessed 2026-11-02)
- https://developers.cloudflare.com/load-balancing/understand-basics/session-affinity/ (accessed 2026-11-02)
- https://developers.cloudflare.com/load-balancing/troubleshooting/load-balancing-faq/ (accessed 2026-11-02)
- https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/routing-to-tunnel/public-load-balancers (accessed 2026-11-02)
- https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/tunnel-availability/ (accessed 2026-11-02)
- https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/tunnel-availability/deploy-replicas/ (accessed 2026-11-02)
- https://developers.cloudflare.com/cloudflare-one/account-limits/ (accessed 2026-11-02)
- https://developers.cloudflare.com/cloudflare-one/changelog/tunnel/ (accessed 2026-11-02) — 2026-10-16 entry with the $4.50/tunnel wording
- https://developers.cloudflare.com/tunnel/features/tunnels-for-platforms/ (accessed 2026-11-02) — product page repeating $4.50 per additional tunnel per month
- https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/ (accessed 2026-11-02)
- https://github.com/cloudflare/cloudflared/releases (accessed 2026-11-02)
- https://raw.githubusercontent.com/cloudflare/cloudflared/master/LICENSE (accessed 2026-11-02)
Cloudflare Durable Objects as an external lease arbiter
- Question: Q-0012 Cloudflare Durable Objects as an external lease arbiter (consistency guarantees, cost)
- Researched: 2026-11-02 by agent:ds-research-2
- Status: answered
Short answer
A Durable Object is a good fit for the shape of a lease arbiter: one globally-unique, single-threaded instance per id, with private transactional storage, input gates (no concurrent event delivery while a storage op is in flight) and output gates (a reply is not emitted until the write is durable, and is replaced by an error if the write fails). That is a stronger primitive than an S3 conditional PUT, because the arbitration logic can be a single synchronous critical section instead of a client-side CAS loop.
It costs almost nothing: 2 nodes renewing every 5 s is ~1.04M requests/month, which fits the Workers Free plan limits (100k requests/day, 13,000 GB-s/day), and on the Paid plan is ~$0.15 of overage on top of the $5/month minimum.
The costs are structural, not financial. It requires a Cloudflare account and a deployed Worker (a DO has no public hostname — it is reached through a Worker binding), the arbiter logic must be written in JavaScript/TypeScript on Cloudflare’s runtime, and it adds a third-party dependency in the exact path that decides who is primary. Also note the platform’s own escape hatch: objects can be shut down and restarted at any time, and in-flight requests that touch storage are terminated with an error — so the lease protocol has to be correct across an abrupt restart, not just across a network partition.
Findings
Consistency guarantees
- Each Durable Object has a globally-unique id/name and “can be used to coordinate between multiple clients who need to work together”; its storage “lives together with the object, [is] strongly consistent yet fast to access”. Confirmed. Source: https://developers.cloudflare.com/durable-objects/what-are-durable-objects/ (accessed 2026-11-02)
- Storage is “durable, transactional, and strongly consistent”, “persisted across requests, and accessible only within that object”. Confirmed. Source: https://developers.cloudflare.com/durable-objects/what-are-durable-objects/ (accessed 2026-11-02)
- Single-threaded: “Durable Objects are single-threaded and cooperatively multi-tasked, just like code running in a web browser.” Objects are “single-threaded and cooperatively multi-tasked”. Confirmed. Source: https://developers.cloudflare.com/durable-objects/what-are-durable-objects/ (accessed 2026-11-02)
- The actor framing is documented explicitly: “Each Durable Object instance can be seen as an Actor instance, receiving messages … executing some logic in its own single-threaded context”. Confirmed. Source: https://developers.cloudflare.com/durable-objects/what-are-durable-objects/ (accessed 2026-11-02)
- Uniqueness is enforced at creation, not just assumed: for a name-derived id, “the system must check that the Durable Object has not been created anywhere else … this round-the-world check can take up to a few hundred milliseconds”, and the stated purpose is “to guarantee that only one instance of the Durable Object is created”. Confirmed. Source: https://developers.cloudflare.com/durable-objects/api/namespace/ (accessed 2026-11-02)
- Input gates: “While a storage operation is executing, no events shall be delivered to the object except for storage completion events. Any other events will be deferred until such a time as the object is no longer executing JavaScript code and is no longer waiting for any storage operations.” Confirmed. Source: https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/ (accessed 2026-11-02; post published 2021-08-03, page
dateModified2026-07-15) - Output gates: “When a storage write operation is in progress, any new outgoing network messages will be held back until the write has completed. … If the write ultimately fails, the outgoing network messages will be discarded and replaced with errors, while the Durable Object will be shut down and restarted from scratch.” This is the property that makes “reply OK = write is durable” true. Confirmed. Source: https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/ (accessed 2026-11-02)
- Writes are automatically coalesced and ordered: multiple non-awaited
put()/delete()calls are stored atomically together, and operations are performed “in exactly the order in which they were initiated”. Confirmed. Source: https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/ (accessed 2026-11-02) - During shutdown/migration: “When a Durable Object is shut down, the object instance is automatically restarted and new requests are routed to the new instance.” In-flight HTTP & RPC requests are “allowed to finish if they do not access a Durable Object’s storage. If a request attempts to access a Durable Object’s storage, it will be stopped immediately and return an error to maintain Durable Objects global uniqueness property.” WebSockets are terminated. Confirmed. Source: https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/ (accessed 2026-11-02)
- Shutdown causes are routine, not exceptional: code updates, lack of requests, “Cloudflare updates to the Workers runtime system”, and “Workers runtime decisions on where to host objects”. No shutdown hooks are provided; Cloudflare says to write state incrementally instead. Confirmed. Source: https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/ (accessed 2026-11-02)
- Can two instances ever run concurrently? The docs state the invariant (“Each Durable Object runs in exactly one location, in one single thread, at a time” — blog) and that storage-touching requests are killed at shutdown specifically to preserve uniqueness. No page fetched documents a window in which two instances serve the same object. Confirmed for the documented statements; the absence of a documented counterexample is an absence in the docs, not a proof. Source: https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/ , https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/ (accessed 2026-11-02)
- Practical consequence for a lease: an arbiter call can fail with a generic error mid-write and the object can come back with the pre-write state. A client must treat “no definitive response” as “lease not held”, exactly as with the R2 negative-read caveat. Hypothesis (composition of the Confirmed shutdown behaviour with the Confirmed output-gate rule). Source: https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/ (accessed 2026-11-02)
Availability
- “Durable Objects migrate among healthy servers, and therefore applications never have to worry about managing them.” Confirmed. Source: https://developers.cloudflare.com/durable-objects/what-are-durable-objects/ (accessed 2026-11-02)
- “Durable Objects are not yet available in every Cloudflare data center.” Confirmed. Source: https://developers.cloudflare.com/durable-objects/what-are-durable-objects/ (accessed 2026-11-02)
- Location hints are best effort:
get(id, {locationHint})“Only the first call toget()for a particular Object will respect the hint”, and “Hints are a best effort and not a guarantee. Unlike with jurisdictions, Durable Objects will not necessarily be instantiated in the hinted location”. Confirmed. Source: https://developers.cloudflare.com/durable-objects/reference/data-location/ (accessed 2026-11-02) - Objects do not move: “Durable Objects do not currently change locations after they are created”; dynamic relocation is “planned for the future”. South America, Africa and Middle East hints spawn the object in a nearby supported location instead. Confirmed. Source: https://developers.cloudflare.com/durable-objects/reference/data-location/ (accessed 2026-11-02)
- Jurisdictions (
eu,us,fedramp) constrain where the DO runs and stores data, and are a hard constraint (unlike hints): “You will run into an error if the jurisdiction on yourDurableObjectNamespaceand the jurisdiction onDurableObjectIdare different.” Confirmed. Source: https://developers.cloudflare.com/durable-objects/reference/data-location/ (accessed 2026-11-02) - Latency, sourced numbers only: every request to a DO must be forwarded to the data center where it lives, which “will often add response latency”. The only published figure found is for first access to a name-derived id: the uniqueness check “can take up to a few hundred milliseconds”. No steady-state cross-region latency figure was found. Confirmed (statements) / not found (steady-state numbers). Source: https://developers.cloudflare.com/durable-objects/reference/data-location/ , https://developers.cloudflare.com/durable-objects/api/namespace/ (accessed 2026-11-02)
- Evidence from a real Cloudflare incident: during the 2025-02-06 R2 outage, Durable Objects “were impacted as the stampeding horde of clients re-connecting to R2 drove an increase in load”, with “a 0.09% actual increase in error rates in calls to Durable Objects running in North America” between 09:13 and 09:36 UTC. The failing component was the R2 gateway, not DO. Confirmed. Source: https://blog.cloudflare.com/cloudflare-incident-on-february-6-2025 (accessed 2026-11-02)
- What happens if the DO’s own location is down (colo failure) is not documented on the pages fetched; the lifecycle page only says the runtime decides to “move the object to a different host”. Hypothesis / not found. Source: https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/ (accessed 2026-11-02)
Programming model for a lease
- Storage for the fencing counter: KV API (
get/put) or SQL API, both available on SQLite-backed objects (KV only on the legacy backend). Aput()of{owner, expiry, token}inside one input-gated critical section is the acquire operation. Confirmed (API surface); the lease recipe is Hypothesis. Source: https://developers.cloudflare.com/durable-objects/what-are-durable-objects/ (accessed 2026-11-02) - Alarms for TTL: one alarm per object at a time (
setAlarmoverrides),alarm()is called at the scheduled time, execution is at-least-once, retried on throw with exponential backoff “starting at a 2 second delay … with up to 6 retries allowed”. “Only one instance ofalarm()will ever run at a given time per Durable Object instance.” Alarm handlers have a 15 minute wall-time limit. Confirmed. Source: https://developers.cloudflare.com/durable-objects/api/alarms/ , https://developers.cloudflare.com/durable-objects/platform/limits/ (accessed 2026-11-02) - Alarm caveat for correctness: at-least-once means an alarm handler can run twice, so “lease expired” transitions must be idempotent (compare the token before revoking). Hypothesis. Source: https://developers.cloudflare.com/durable-objects/api/alarms/ (accessed 2026-11-02)
- Reachability: a DO is addressed through a
DurableObjectNamespacebinding available “from the fetch handler on a Cloudflare Worker”, viagetByName/get(id)returning a stub. There is no documented public hostname for a DO; reaching it from a plain HTTPS client requires a Worker route in front that forwards the request to the stub. Confirmed for the binding-only access path; the “no public endpoint” conclusion is Hypothesis (stated as the access model, never as an explicit prohibition). Source: https://developers.cloudflare.com/durable-objects/api/namespace/ (accessed 2026-11-02) - Lifecycle cost/behaviour relevant to a 5 s lease: an object is “active, in-memory” while handling, then idle (hibernateable or not), then hibernated after “10 seconds of inactivity”, then evicted entirely after “70-140 seconds of inactivity”. In-memory state is lost on hibernation; storage survives. Confirmed. Source: https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/ (accessed 2026-11-02)
- A lease renewed every 5 s from 2 nodes means the object is hit every ~2.5 s, so it will effectively never hibernate. That is fine for correctness (state is in storage) but it means the code cannot rely on in-memory state across quiet periods. Hypothesis (arithmetic on the Confirmed lifecycle timings).
Cost
Published DO prices:
| Workers Free | Workers Paid | |
|---|---|---|
| Requests | 100,000 / day | 1M / month included, + $0.15 / million |
| Duration | 13,000 GB-s / day | 400,000 GB-s / month included, + $12.50 / million GB-s |
| Rows read (SQLite) | 5M / day | first 25B / month included, + $0.001 / million |
| Rows written (SQLite) | 100,000 / day | first 50M / month included, + $1.00 / million |
| SQL stored data | 5 GB total | 5 GB-month, + $0.20 / GB-month |
| Storage backend | SQLite only | SQLite (KV only for pre-existing KV namespaces) |
- Confirmed. Source: https://developers.cloudflare.com/durable-objects/platform/pricing/ (accessed 2026-11-02)
- Billing nuances: duration is billed in wall-clock time “as long as the Object is active and not eligible for hibernation”, shared across concurrent requests; an idle object that qualifies for hibernation is not billed even before the runtime hibernates it.
accept()on a standard-API WebSocket bills duration for the whole connection, hence the Hibernation API recommendation. Duration is charged for the full 128 MB allocation regardless of actual use. Confirmed. Source: https://developers.cloudflare.com/durable-objects/platform/pricing/ (accessed 2026-11-02) - Each
setAlarm()is billed as one row written; KV-styleget/put/deleteare billed as rows read/written. Confirmed. Source: https://developers.cloudflare.com/durable-objects/platform/pricing/ (accessed 2026-11-02) - Paid-plan examples include a “minimum $5/mo usage” line, i.e. the Workers Paid plan floor. Confirmed. Source: https://developers.cloudflare.com/durable-objects/platform/pricing/ (accessed 2026-11-02)
- The pricing page still carries the note that SQLite storage billing starts 2026-01-07; given today’s date that billing is in effect. Confirmed (note text); interpretation is Hypothesis. Source: https://developers.cloudflare.com/durable-objects/platform/pricing/ (accessed 2026-11-02)
Arithmetic, 2 nodes renewing every 5 s (30-day month):
- Requests: 86,400 / 5 = 17,280 per node per day → 34,560/day → 1,036,800/month.
- Free plan: 34,560/day vs 100,000/day limit → fits. $0.
- Paid plan: 36,800 over the included 1M, rounded up to 1,000,000 billable → 1 × $0.15 = $0.15, plus the $5/month plan minimum → $5.15/month.
- Duration (assume ~10 ms wall time per renewal): 1,036,800 × 0.010 s = 10,368 s → × 128 MB/1 GB = 1,327 GB-s/month. Paid included 400,000 → $0. Free daily limit is 13,000 GB-s (~390,000/month) → $0.
- Rows written (one
putper renewal, plus onesetAlarmwhen the expiry moves): ~1.04M–2.07M/month. Free limit 100,000/day (~3M/month) → fits. Paid included 50M → $0. - Storage: one small record, far under 5 GB → $0.
- Hypothesis overall (arithmetic over Confirmed prices and the Confirmed request/duration model). The one guessed input is the 10 ms per-request wall time. Even a 100 ms handler lands at 103,680 s → 13,270 GB-s/month, i.e. ~442 GB-s/day against the Free plan’s 13,000 GB-s/day — still a large margin. The free plan is viable either way.
Operational burden for a self-hosted Quoro user
- Needs a Cloudflare account, an API token with Workers Scripts write (or
wrangler login), a Worker script exporting the DO class, and a DO namespace declared in the Wrangler configuration; the namespace must be migrated/created on first deploy. Confirmed for the config/binding model. Source: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ (binding model), https://developers.cloudflare.com/durable-objects/api/namespace/ (accessed 2026-11-02) - Deployment from code is possible without wrangler:
PUT /accounts/{account_id}/workers/scripts/{script_name}(“Upload Worker Module”) accepts multipart form data with a JSONmetadatapart carryingbindings, including adurable_object_namespacebinding withclass_name/namespace_id/script_name. Token permissions:Workers Scripts Write,Workers Admin,Workers Editor. Confirmed (described only; not used). Source: https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/update/ (accessed 2026-11-02) - This means Quoro could ship “create the arbiter” as a one-click/one-command flow that uploads a small Worker — but it still requires the user to hold a Cloudflare account and an API token, and it puts the arbiter’s source code inside the user’s Cloudflare account, where Quoro cannot upgrade it silently without another deploy. Hypothesis (operational inference).
Comparison with R2 and with a self-hosted witness
| R2 conditional writes | Durable Object | Self-hosted witness daemon | |
|---|---|---|---|
| Primitive | CAS on one object (If-Match / If-None-Match), 412 on loss | Single-threaded actor with transactional storage; arbitration is plain code | Whatever the daemon implements (vote / tie-break) |
| Server-side TTL | None — expiry is a client-written timestamp | Possible (alarm fires in the object, on Cloudflare’s clock) | Possible (daemon’s own clock) |
| Access from Rust nodes | Any S3 client (aws-sdk-s3, rust-s3) | Needs a Worker route in front; HTTPS + JSON/RPC | Plain HTTP to the user’s own host |
| Portability | Depends on the provider’s S3 conditional support (AWS yes, B2 apparently no) | Cloudflare-only, JS/TS worker code | Fully self-hosted, any machine |
| Failure mode | Provider API outage (R2: 59 min at 100% errors on 2025-02-06) | Provider outage; object shutdown/restart kills in-flight storage requests | The user’s own third box; no third-party dependency |
| Cost at 5 s renewal, 2 nodes | $0 leader-only; $4.50 if both contend | $0 free plan; $5.15/month paid | Free (user’s hardware) |
| Fits D-0017 “witness in a different failure domain” | Yes (Cloudflare’s domain) | Yes (Cloudflare’s domain) | Yes, if the user actually places it elsewhere |
- Every cell above traces to the Findings sections of this file and
docs/research/r2-conditional-writes.md; the comparison itself is Hypothesis (synthesis). - D-0017 lists the self-hosted witness as “free and first-class” and the external arbiter as optional. Nothing in this research contradicts that ordering: the DO is the cheapest managed option but it is the only option that requires a Cloudflare account and JavaScript code in the critical path, whereas the R2 option requires an S3 bucket the user may already have.
Versions checked
| Component | Version | Release date / last updated | Source |
|---|---|---|---|
| Durable Objects — What are Durable Objects | n/a | last updated 2026-07-15 | https://developers.cloudflare.com/durable-objects/what-are-durable-objects/ |
| Durable Objects — Lifecycle | n/a | last updated 2026-10-30 | https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/ |
| Durable Objects — Limits | n/a | last updated 2026-10-29 | https://developers.cloudflare.com/durable-objects/platform/limits/ |
| Durable Objects — Pricing | n/a | last updated 2026-10-18 | https://developers.cloudflare.com/durable-objects/platform/pricing/ |
| Durable Objects — Data location | n/a | last updated 2026-06-26 | https://developers.cloudflare.com/durable-objects/reference/data-location/ |
| Durable Objects — Alarms API | n/a | last updated 2026-04-21 | https://developers.cloudflare.com/durable-objects/api/alarms/ |
| Durable Objects — Namespace API | n/a | last updated 2026-04-21 | https://developers.cloudflare.com/durable-objects/api/namespace/ |
| “Easy, Fast, Correct” gates post | n/a | published 2021-08-03 (>12 months old), page modified 2026-07-15 | https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/ |
| SQLite storage billing start | n/a | target 2026-01-07 | https://developers.cloudflare.com/durable-objects/platform/pricing/ |
Implications for Quoro
- D-0017’s “Cloudflare Durable Object (strong consistency)” premise is accurate. The DO gives a real single-writer critical section, and input/output gates mean a successful reply implies the write is durable — the property a lease arbiter needs and the one an S3 bucket cannot give without client-side care.
- A DO is the only option in D-0017 that can own the lease’s expiry. R2 has no TTL, so with R2 the “lease expired” event is something the two nodes infer from their own clocks. With a DO, an alarm fires inside the object and the arbiter itself can revoke the lease and bump the fencing token. That is a materially different safety story for D-0019, though the fencing token is still mandatory on the node side.
- But the DO does not remove the need for fencing. Objects are shut down for routine reasons, in-flight storage-touching requests are killed with an error, and the object can restart with pre-write state. A client that retries an acquire after an error can therefore hold a token the arbiter no longer considers current. D-0019’s “node self-fences” requirement stays exactly as written; the DO only makes it easier to issue monotonic tokens.
- Self-hosting tension with D-0012. D-0012 says “the stateful API stays self-hosted”. A DO arbiter does not move the control plane, but it does put a piece of decision logic (who is primary) on Cloudflare, in JavaScript, in a repo the user cannot
git pull. If Quoro ships this, the arbiter Worker should be treated as a thin, versioned, published artifact (source in the Quoro repo, deployed by the user) rather than as an opaque managed service — otherwise the “self-hosted” claim weakens in exactly the failure path users care about. - Free-plan viability makes the DO the cheapest managed arbiter. 2 nodes at 5 s is 34,560 requests/day against a 100,000/day limit; there is ~3× headroom, which is enough for retries but not for a 1 s renewal loop (which would be 172,800/day and blow the limit). This is a hard constraint on any future “faster failover” tuning.
- Operational burden is a Cloudflare account + an API token + a deploy. Not zero, and it is a step a self-hoster may refuse. The R2 option reuses an S3 credential the user may already have; the self-hosted witness needs a third box. These are three genuinely different user populations, which supports D-0017 keeping all three.
- The Feb 2025 incident is the number to design against: R2 was 100% down for 59 minutes while Durable Objects degraded by 0.09%. If the arbiter is a DO, a Cloudflare-wide event may still be survivable; if it is R2 conditional writes, the same event is a total arbiter outage. Both still need a documented “arbiter unreachable” policy separate from “lease lost” (D-0019 currently only defines the latter).
Open risks and unknowns
- No documented behaviour for “the DO’s data center is unavailable” — only “migrate among healthy servers” and a runtime decision to host elsewhere. The worst-case unavailability window for a single object is not published. Not found.
- No published steady-state latency for a client far from the DO’s location; only “up to a few hundred milliseconds” for the first name lookup. This matters for choosing the lease TTL relative to renewal interval.
- Whether Cloudflare ever runs two instances of one object concurrently (e.g. during a runtime upgrade or a colo evacuation) is stated as never, but no formal or documented edge-case analysis was found. The safety of a lease protocol resting on that invariant should be treated as a platform claim, not a proof.
- Durable Objects are not available in every Cloudflare data center; users in unsupported regions get an object in a nearby location, so latency and failure-domain assumptions vary by user geography.
- The 2021 gates blog is the only primary description of input/output gates; it is older than 12 months (page modified 2026-07-15). No current doc page restates the invariants in the same detail, so a behaviour change would be hard to detect from docs alone.
- Free-plan headroom (100k requests/day) is shared with any other use of the same Cloudflare account; if a user already runs Workers there, the arbiter’s budget is not guaranteed.
- SQLite storage billing started 2026-01-07; the exact storage cost for a lease object is negligible but the billing model changed recently and the page still describes it as a future change.
Sources
- https://developers.cloudflare.com/durable-objects/what-are-durable-objects/ — accessed 2026-11-02
- https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/ — accessed 2026-11-02
- https://developers.cloudflare.com/durable-objects/api/namespace/ — accessed 2026-11-02
- https://developers.cloudflare.com/durable-objects/api/alarms/ — accessed 2026-11-02
- https://developers.cloudflare.com/durable-objects/reference/data-location/ — accessed 2026-11-02
- https://developers.cloudflare.com/durable-objects/platform/limits/ — accessed 2026-11-02
- https://developers.cloudflare.com/durable-objects/platform/pricing/ — accessed 2026-11-02
- https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/ — accessed 2026-11-02
- https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/update/ — accessed 2026-11-02
- https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ — accessed 2026-11-02
- https://blog.cloudflare.com/cloudflare-incident-on-february-6-2025 — accessed 2026-11-02
R2 conditional writes as a lease arbiter
- Question: Q-0011 does Cloudflare R2 support reliable conditional writes (If-Match / If-None-Match) usable as a lease arbiter?
- Researched: 2026-11-02 by agent:ds-research-2
- Status: answered
Short answer
Yes, on the R2 side the primitive is real: PutObject supports If-None-Match: * (create-once) and If-Match: <etag> (compare-and-swap), evaluated atomically at commit time, with 412 PreconditionFailed for the losers and at most one winner. R2 is strongly consistent for reads, writes, deletes and list, and competing conditional writes to one key are serialized. Two caveats matter for a lease: R2 rate-limits concurrent writes to the same key to 1 per second (beyond that: HTTP 429), and a failed conditional write is still billed as a Class A operation.
The bigger caveat is D-0025: the same primitive is not uniformly available on other S3-compatible stores. AWS S3 has had If-None-Match since 2024-08 and If-Match on PutObject/CompleteMultipartUpload since 2024-11; MinIO AIStor documents both; Backblaze B2 does not document conditional PUT support and third-party compatibility matrices say it lacks it. So “R2/S3 conditional writes” is a Cloudflare-and-AWS-class capability, not a portable S3 one.
Object storage also gives no server-side TTL: expiry is computed by clients from their own clocks, so the object must carry an owner id, an expiry timestamp and a monotonically increasing fencing token, and consumers must reject stale tokens (Kleppmann). Without that, a paused node can still act after its lease expired.
Findings
R2 S3-compatible conditional writes
PutObjectsupports the standard S3 conditional headersIf-Match,If-None-Match,If-Modified-Since,If-Unmodified-Since;If-None-Match: *is documented as the “create-once” form. Confirmed. Source: https://developers.cloudflare.com/r2/api/s3/extensions/ (accessed 2026-11-02)- Conditions “are evaluated atomically, as part of the same write that commits the object”; when several clients concurrently
PutObjectwithIf-None-Match: *on an absent key, “at most one request succeeds and every other request fails with a412 PreconditionFailederror code. Competing conditional writes can never both observe success.” Confirmed. Source: https://developers.cloudflare.com/r2/api/s3/extensions/ (accessed 2026-11-02) - The S3 compatibility table lists
PutObjectconditional operations as ✅ If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, and repeats the atomicity statement in a “Conditional operations” note under “Behaviors & Limitations”. Confirmed. Source: https://developers.cloudflare.com/r2/api/s3/api/ (accessed 2026-11-02) - Failure code is documented as
412 PreconditionFailed. No409 Conflictappears anywhere in R2’s conditional-write documentation, unlike AWS S3 (see below). Confirmed for “412 is documented”; the absence of 409 is an absence in the docs, not a guarantee it can never occur (see open risks). Source: https://developers.cloudflare.com/r2/api/s3/extensions/ (accessed 2026-11-02) CopyObjectsupports conditionals on the destination via R2-specific headerscf-copy-destination-if-match/cf-copy-destination-if-none-match(plus-if-modified-since/-if-unmodified-since), returning412 PreconditionFailed. This feature is labelled beta. Confirmed. Source: https://developers.cloudflare.com/r2/api/s3/extensions/ (accessed 2026-11-02)- Those destination conditions are not atomic with respect to the
x-amz-copy-source-if-*source conditions: the source is selected at one moment and the destination committed at another. Confirmed. Source: https://developers.cloudflare.com/r2/api/s3/extensions/ (accessed 2026-11-02) CompleteMultipartUploadandDeleteObject/DeleteObjectslist no conditional-operation features in R2’s compatibility table (only unimplemented MFA / object-lock / request-payer / bucket-owner rows). So conditional complete-MPU and conditional delete are not available on R2. Confirmed (as documented feature lists). Source: https://developers.cloudflare.com/r2/api/s3/api/ (accessed 2026-11-02)CopyObjectalso supports source-sidex-amz-copy-source-if-match/-if-none-match;UploadPartCopydoes not (all four copy-source conditionals marked ❌). Confirmed. Source: https://developers.cloudflare.com/r2/api/s3/api/ (accessed 2026-11-02)
Workers binding onlyIf
R2PutOptions.onlyIfaccepts anR2Conditionalor aHeadersobject: “Specifies that the object should only be stored given satisfaction of certain conditions”. Confirmed. Source: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ (accessed 2026-11-02)- On a failed precondition,
put()“returnsnull, and the object will not be stored”. On success it returns anR2Objectwhoseetagcan be used as the nextIf-Matchvalue. Confirmed. Source: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ (accessed 2026-11-02) R2Conditionalfields:etagMatches,etagDoesNotMatch,uploadedBefore,uploadedAfter. Alternatively aHeadersobject with conditional headers is accepted (“All conditional headers aside fromIf-Rangeare supported”). Confirmed. Source: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ (accessed 2026-11-02)- Binding writes are strongly consistent: “Once the Promise resolves, all subsequent read operations will see this key value pair globally.” Deletes and lists are likewise documented as strongly consistent. Confirmed. Source: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ (accessed 2026-11-02)
Consistency and concurrency
- R2 is strongly consistent for read-after-write, metadata update, delete (“reads … immediately return a ‘does not exist’ error”) and list (“will list all objects at that point in time”). IAM/permission changes are the exception (eventually consistent, up to a minute). Confirmed. Source: https://developers.cloudflare.com/r2/reference/consistency/ (accessed 2026-11-02)
- Without a conditional header, “in the event two clients are writing (
PUTorDELETE) to the same key, the last writer to complete ‘wins’”. Confirmed. Source: https://developers.cloudflare.com/r2/reference/consistency/ (accessed 2026-11-02) - With a conditional header the semantics change to single-winner: conditions are “always evaluated against the committed state of the bucket at the moment the write commits, never against an older snapshot”, and “competing conditional writes to the same key are serialized”. Confirmed. Source: https://developers.cloudflare.com/r2/reference/consistency/ (accessed 2026-11-02)
- Negative-read trap: a
HeadObject/GetObjectreturning 404 “does not exist at the moment the read was processed” but “is not final with respect to an earlierPutObjectwhose outcome is still unknown” — an in-flight write can still commit afterwards. The documented remedy is to retry withIf-None-Match: *, which fails with 412 if the ambiguous write did commit. Confirmed. Source: https://developers.cloudflare.com/r2/reference/consistency/ (accessed 2026-11-02) - Per-key write rate limit: “Maximum concurrent writes to the same object name (key): 1 per second”, and “Concurrent writes to the same object name (key) at a higher rate return HTTP 429 (rate limited) responses.” The 50/s bucket-management limit explicitly does not apply to object reads/writes. Confirmed. Source: https://developers.cloudflare.com/r2/platform/limits/ (accessed 2026-11-02)
- Bucket management operations are limited to 50/s per bucket; the Cloudflare REST API to 1,200 requests per five minutes (use S3 or Workers API for object operations). Confirmed. Source: https://developers.cloudflare.com/r2/platform/limits/ (accessed 2026-11-02)
Other S3-compatible providers
| Provider | If-None-Match: * on PutObject | If-Match: <etag> on PutObject | Orientation |
|---|---|---|---|
| Cloudflare R2 | Yes (S3 API and Workers onlyIf) | Yes | Confirmed — https://developers.cloudflare.com/r2/api/s3/extensions/ |
| AWS S3 | Yes, since 2024-08-20 | Yes, since 2024-11-25 | Confirmed — https://aws.amazon.com/about-aws/whats-new/2024/08/amazon-s3-conditional-writes/ , https://aws.amazon.com/about-aws/whats-new/2024/11/amazon-s3-functionality-conditional-writes/ |
| MinIO AIStor | Yes | Yes | Confirmed for AIStor — https://docs.min.io/aistor/developers/s3-api-compatibility/ |
| MinIO (community server) | Disputed | Yes per AIStor docs, not verified for community build | Hypothesis — a 2024 discussion reports IfNoneMatch=* not preventing a second write on community MinIO — https://github.com/minio/minio/discussions/20318 |
| Backblaze B2 | Not documented / not supported | Not documented / not supported | Hypothesis — B2’s own S3 Put Object header reference lists no conditional headers (https://www.backblaze.com/apidocs/s3-put-object, page dated 2023-06-01), and a third-party provider compatibility page states B2 “don’t support the If-Match and If-None-Match HTTP headers” (https://docs.nebius.com/object-storage/transfer/requirements) |
- AWS documents conditional writes for
PutObject,CompleteMultipartUploadandCopyObject(both header types), and conditional deletes forDeleteObject/DeleteObjects. Confirmed. Source: https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-requests.html (accessed 2026-11-02) - AWS failure semantics are richer than R2’s:
If-None-Matchmismatch →412 Precondition Failed;If-Matchmismatch →412; but a concurrent delete can also produce409 Conflict(If-None-Match) or404 Not Found(If-Match); “the first write operation to finish succeeds. Amazon S3 then fails subsequent writes with a412”. Confirmed. Source: https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html (accessed 2026-11-02) - AWS conditional writes require Signature Version 4. Confirmed. Source: https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html (accessed 2026-11-02)
- AWS conditional writes cost nothing extra, and failed conditional requests are still charged at normal request rates. Confirmed. Source: https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-requests.html (accessed 2026-11-02)
Lease semantics on object storage
- Object storage has no server-side lease/TTL: nothing expires a key or revokes an owner. Expiry is a timestamp the client writes and other clients interpret, so safety depends on the clients’ clocks and on their willingness to stop when the timestamp passes. Hypothesis (inference from the documented API surface: R2 documents no TTL, expiry or lock primitive anywhere in its S3 extensions, consistency or Workers API references). Source: https://developers.cloudflare.com/r2/api/s3/extensions/ (accessed 2026-11-02)
- A lease alone is not safe for correctness: a client can be paused (GC, page fault,
SIGSTOP, scheduler) or its packet delayed past expiry, and then act while another client holds the lease. The fix is a fencing token — a number the lock service increments on every acquisition — which the protected resource checks and rejects if it has already seen a higher value. Confirmed (as published argument). Source: https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html (accessed 2026-11-02) - Kleppmann’s argument is explicitly about locks whose failure corrupts state (the Quoro case per D-0019), and he notes that a lock acquisition is essentially compare-and-set and that safety must not rest on timing assumptions. Confirmed (as published argument). Source: https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html (accessed 2026-11-02)
- Mapping that onto R2: the lease object must hold
{owner, expiry, token}; acquisition is onePutObjectwithIf-Matchon the current etag (orIf-None-Match: *to create), the token is incremented inside that same write, and every stateful action on a node carries the token so the node-agent can refuse to start apps under a stale token (D-0019 self-fencing). Hypothesis (design inference; the pieces are each Confirmed, the composition is not documented as a lease recipe by Cloudflare). Source: https://developers.cloudflare.com/r2/reference/consistency/ (accessed 2026-11-02)
Cost
Published prices (Standard storage, Workers plan independent):
| Item | Price | Free tier |
|---|---|---|
| Storage | $0.015 / GB-month | 10 GB-month/month |
| Class A (PutObject, CopyObject, ListObjects, CreateMultipartUpload, …) | $4.50 / million requests | 1 million requests/month |
| Class B (HeadObject, GetObject, …) | $0.36 / million requests | 10 million requests/month |
| DeleteObject / DeleteBucket / AbortMultipartUpload | free | — |
| Egress | free | — |
- Confirmed. Source: https://developers.cloudflare.com/r2/pricing/ (accessed 2026-11-02)
- A
PutObjectthat returns 412 because of a conditional header “counts as a Class A operation”; auth failures (401/403 auth) are not charged. Confirmed. Source: https://developers.cloudflare.com/r2/pricing/ (accessed 2026-11-02) - Billable usage is rounded up to the next billing unit (1,000,001 operations are billed as 2 million). Confirmed. Source: https://developers.cloudflare.com/r2/pricing/ (accessed 2026-11-02)
Arithmetic for a lease renewed every 5 s (30-day month):
- One renewing node: 86,400 / 5 = 17,280 writes/day → × 30 = 518,400 Class A/month. Under the 1M free tier → $0.00. Reads (
HeadObject, Class B) at the same cadence: 518,400 → within 10M free → $0.00. Storage: one object well under 1 GB → within 10 GB free. - Two nodes both attempting renewals (contending conditional PUTs, losers paying 412): 17,280 × 2 = 34,560/day → 1,036,800 Class A/month. That is 36,800 over the free million; with round-up that is 1,000,000 billable → 1 × $4.50 = $4.50/month. Hypothesis (arithmetic on Confirmed prices + Confirmed 412-billing rule).
- Add retries and 429 back-off and the two-node figure grows; a 1 s retry loop would blow past the free tier and approach the 1-write/s-per-key limit.
- No official R2 latency figures were found. Confirmed as “not found” (no latency number appears on the pricing, limits, consistency or S3 API pages fetched). Open risk.
Availability incidents
- 2025-02-06: R2 was unavailable for 59 minutes (08:14–09:13 UTC) with a 100% error rate on all operations against R2 buckets and objects, caused by human error in abuse remediation disabling the R2 Gateway service. No data loss; the gateway is the front door for both the S3 and REST APIs, and in-flight requests were terminated. Confirmed. Source: https://blog.cloudflare.com/cloudflare-incident-on-february-6-2025 (accessed 2026-11-02)
- In the same incident, Durable Objects (used by R2’s own metadata layer) saw only a 0.09% error increase in North America during recovery — i.e. the R2 API frontend failed while the DO-based metadata layer did not. Confirmed. Source: https://blog.cloudflare.com/cloudflare-incident-on-february-6-2025 (accessed 2026-11-02)
- Any single external arbiter (R2 or a DO) is a new dependency: a 59-minute arbiter outage means no leader can be renewed for 59 minutes. With D-0019 self-fencing, that converts into “stateful apps stop” unless the node is allowed to keep running on an unexpired lease. Hypothesis (consequence of the Confirmed incident plus D-0019’s self-fencing rule).
Versions checked
| Component | Version | Release date / last updated | Source |
|---|---|---|---|
| R2 S3 API compatibility page | n/a | last updated 2026-10-14 | https://developers.cloudflare.com/r2/api/s3/api/ |
| R2 S3 extensions (conditional ops) | n/a | last updated 2026-10-14 | https://developers.cloudflare.com/r2/api/s3/extensions/ |
| R2 consistency model | n/a | last updated 2026-10-14 | https://developers.cloudflare.com/r2/reference/consistency/ |
| R2 limits | n/a | last updated 2026-10-30 | https://developers.cloudflare.com/r2/platform/limits/ |
| R2 pricing | n/a | last updated 2026-10-30 | https://developers.cloudflare.com/r2/pricing/ |
| R2 Workers API reference | n/a | last updated 2026-07-31 | https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ |
| AWS S3 conditional writes (If-None-Match) | n/a | announced 2024-08-20 | https://aws.amazon.com/about-aws/whats-new/2024/08/amazon-s3-conditional-writes/ |
| AWS S3 conditional writes (If-Match) | n/a | announced 2024-11-25 | https://aws.amazon.com/about-aws/whats-new/2024/11/amazon-s3-functionality-conditional-writes/ |
| MinIO AIStor S3 compatibility | AIStor docs, changed in RELEASE.2026-08-07T18-34-35Z | 2026 | https://docs.min.io/aistor/developers/s3-api-compatibility/ |
Backblaze B2 S3 Put Object reference | n/a | page dated 2023-06-01 (>12 months old) | https://www.backblaze.com/apidocs/s3-put-object |
Implications for Quoro
- D-0017 (external lease arbiter via S3/R2 conditional writes): the premise holds for R2 specifically. A single
PutObjectwithIf-Match/If-None-Matchis a genuine CAS with one winner and 412 for the rest, on strongly consistent storage. This is enough to arbitrate a lease between two nodes. - Portability (D-0025) is the weak point. The arbiter is described as “S3/R2 conditional writes”, but the conditional-PUT capability is not part of the portable S3 compatibility surface Quoro promises. AWS S3 and MinIO AIStor support it; B2 appears not to; community MinIO is disputed. If the arbiter is offered as “bring your own S3 bucket”, Quoro must probe the capability at setup (one
If-None-Match: *against a scratch key, then a second with the same header expecting 412) and refuse to enable the arbiter if the probe fails. Without that probe, a provider that silently ignoresIf-None-Match(the failure mode reported for some stores) would give Quoro a lease arbiter that can hand the same lease to both nodes — a split-brain path, which is exactly what D-0016/D-0017 exist to prevent. - R2’s 1-write-per-second-per-key limit constrains the lease design. Renewals every 5 s by the leader are 0.2 writes/s — fine. A design where both nodes poll the key aggressively, or where a node retries in a tight loop, will hit 429s; a 429 must be treated as “lease not renewed” (fail closed) and not silently retried, otherwise the node’s self-fencing timer and the arbiter disagree.
- No TTL means the “expiry” is Quoro’s own number. The arbiter cannot revoke a lease. So D-0019’s fencing must be end-to-end: the lease object carries a monotonic token, and the token is what the node-agent checks before starting a stateful app, and what any downstream resource (volume, VM, LB target) can reject. A lease timestamp alone does not satisfy D-0019.
- Release must not be a delete.
DeleteObjecthas no conditional support on R2, so “release the lease” has to be a conditional put that marks the object free (e.g.owner: null, token incremented), or the delete/create race becomes a second split-brain path. - Cost is negligible, but the free-tier cliff is sharp. Leader-only renewal (518k writes/month) is $0; two contending nodes (1.04M writes/month) is $4.50/month because of the round-up rule. Neither is a design constraint; the retry policy is.
- A 59-minute R2 outage stops all renewals. Because D-0019 has nodes self-fence, an arbiter outage becomes an availability event for stateful apps on Quoro, not just a control-plane event. This is evidence that the arbiter needs a documented “arbiter unreachable” behaviour distinct from “lease lost” (the current decisions do not distinguish them).
Open risks and unknowns
- Whether R2 can ever return
409 Conflict(as AWS does for delete-vs-conditional-write races) is not documented; a client that only handles 412 may mis-handle a 409. Not found. - No official R2 latency figures (p50/p99 for PutObject/HeadObject from a given region) were found. The lease timeout must therefore be chosen without a published latency budget — this affects how long a node can safely believe it holds a lease.
- Whether community MinIO (as opposed to paid AIStor) implements
If-Match/If-None-Matchcorrectly is unresolved: the AIStor docs say yes, a 2024 discussion saysIfNoneMatch=*did not prevent a second write. Needs a hands-on probe, not more reading. - Backblaze B2’s conditional-PUT support is asserted only by absence in its own (2023-dated) S3 reference plus a third-party matrix. B2 may have added it since; the doc is older than 12 months. Needs a probe or a current B2 changelog.
- The precise semantics of
If-Matchwhen the target key does not exist on R2 (404 vs 412) are not stated in the pages fetched; AWS documents 404 for that case. Not found for R2. - Whether R2’s 1-write/s-per-key limit is enforced per bucket or per account, and how it interacts with conditional writes specifically (as opposed to unconditional writers), is not stated.
Sources
- https://developers.cloudflare.com/r2/api/s3/api/ — accessed 2026-11-02
- https://developers.cloudflare.com/r2/api/s3/extensions/ — accessed 2026-11-02
- https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ — accessed 2026-11-02
- https://developers.cloudflare.com/r2/reference/consistency/ — accessed 2026-11-02
- https://developers.cloudflare.com/r2/platform/limits/ — accessed 2026-11-02
- https://developers.cloudflare.com/r2/pricing/ — accessed 2026-11-02
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-requests.html — accessed 2026-11-02
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html — accessed 2026-11-02
- https://aws.amazon.com/about-aws/whats-new/2024/08/amazon-s3-conditional-writes/ — accessed 2026-11-02
- https://aws.amazon.com/about-aws/whats-new/2024/11/amazon-s3-functionality-conditional-writes/ — accessed 2026-11-02
- https://docs.min.io/aistor/developers/s3-api-compatibility/ — accessed 2026-11-02
- https://github.com/minio/minio/discussions/20318 — accessed 2026-11-02
- https://www.backblaze.com/apidocs/s3-put-object — accessed 2026-11-02
- https://www.backblaze.com/docs/cloud-storage-s3-compatible-api — accessed 2026-11-02
- https://docs.nebius.com/object-storage/transfer/requirements — accessed 2026-11-02
- https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html — accessed 2026-11-02
- https://blog.cloudflare.com/cloudflare-incident-on-february-6-2025 — accessed 2026-11-02
rmcp maturity, Litestream replication to R2, and a utoipa -> TypeScript client pipeline
- Question: Q-0014 VERIFY: rmcp maturity; Litestream replication to R2; utoipa + orval/openapi-ts pipeline
- Researched: 2026-11-02 by agent:ds-research-4
- Status: answered
Short answer
rmcp is the official SDK and is past 1.0 (3.8.0, 2026-10-26), but it has shipped three semver-major versions in five months (1.0 in March, 2.0 in June, 3.0 in July 2026), so pinning an exact minor version and budgeting for upgrades is mandatory. It implements MCP 2026-07-28 by default and still understands 2025-03-26 through 2025-11-25; it ships stdio and Streamable HTTP (server as a Tower service mountable on Axum), and deliberately does not ship the legacy 2024-11-05 HTTP+SSE transport. OAuth 2.1/PKCE support is documented for the client side; no server-side auth middleware is documented.
Litestream is still 0.5.x/beta (0.5.17, 2026-08-31), actively maintained, Apache-2.0, and has an official Cloudflare R2 section in its S3-compatible guide. Replication is asynchronous with a 1s default sync (RPO up to ~1s), point-in-time restore via TXID or timestamp, and one Litestream process per database. The 0.5 LTX rewrite removed client-side (age) encryption; only S3 SSE-C/SSE-KMS remain — a direct conflict with Quoro’s encrypted-backup expectation that must be resolved.
utoipa 6.0.0 emits OpenAPI 3.1.0 by default (3.2 opt-in) and is Apache-2.0, but is effectively single-maintainer (646 of the top commits) with 256 open issues and only two releases in 12 months. orval 8.46.0 (MIT) and @hey-api/openapi-ts 0.99.0 (MIT) both consume 3.1 and both generate TanStack Query hooks, Zod schemas and fetch clients; openapi-ts is still pre-1.0 with documented breaking changes per minor. One confirmed utoipa->orval break exists today: utoipa 6 omits empty response description, which makes orval reject the 3.1.0 document (utoipa issue #1636, fix open in #1649).
Findings
rmcp (official Rust MCP SDK)
Version and cadence
- Latest published version is 3.8.0, released 2026-10-26; the crate has 71 published versions since 2025-03-16. Confirmed. Source: https://crates.io/api/v1/crates/rmcp (accessed 2026-11-02)
- rmcp is past 1.0: 1.0.0 was published 2026-03-03, 2.0.0 on 2026-06-29, 3.0.0 on 2026-07-28. Confirmed. Source: https://crates.io/api/v1/crates/rmcp (accessed 2026-11-02)
- 47 crate versions were published in the last 12 months (created_at after 2025-11-02), roughly four per month. Counting per-crate tags (rmcp + rmcp-macros) the GitHub releases feed shows 94 releases in the same window. Confirmed. Sources: https://crates.io/api/v1/crates/rmcp and https://api.github.com/repos/modelcontextprotocol/rust-sdk/releases (accessed 2026-11-02)
- Three semver-major bumps in five months. 2.0.0 is explicitly breaking (
[**breaking**] align model types with MCP 2025-11-25 spec,add Audio variant to PromptMessageContent) with migration guide discussion #926. 3.0.0 carries a long “Breaking changes” section (sessionless Streamable HTTP, removal ofinitialize, MRTR result enums,stateful_moderename, MSRV 1.88) and migration guide discussion #969. Confirmed. Sources: https://api.github.com/repos/modelcontextprotocol/rust-sdk/releases/tags/rmcp-v2.0.0, https://api.github.com/repos/modelcontextprotocol/rust-sdk/releases/tags/rmcp-v3.0.0, https://crates.io/api/v1/crates/rmcp (accessed 2026-11-02) - The in-repo changelog only goes back to 3.x, so the number of breaking changes before 3.0 cannot be counted from it; the 2.0/3.0 release bodies are the reliable record. Confirmed (about the changelog’s coverage). Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/crates/rmcp/CHANGELOG.md (accessed 2026-11-02)
Status, licence, spec revisions
- It is the official SDK: repository lives under the
modelcontextprotocolGitHub org and the README calls it “An official Rust Model Context Protocol SDK implementation with tokio async runtime”. Confirmed. Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/README.md (accessed 2026-11-02) - Licence: the LICENSE file documents a transition from MIT to Apache-2.0; contributions whose authors have not consented remain MIT. GitHub reports the licence as “Other”/NOASSERTION, so downstream users must read the LICENSE rather than trusting an SPDX tag. Confirmed. Sources: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/LICENSE and https://api.github.com/repos/modelcontextprotocol/rust-sdk (accessed 2026-11-02)
- Spec revisions understood: the
ProtocolVersionconstants incrates/rmcp/src/model.rsare2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28, withLATEST = V_2026_07_28andKNOWN_VERSIONSlisting all five. Confirmed. Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/crates/rmcp/src/model.rs (accessed 2026-11-02) - The README states it “implements the stable MCP
2026-07-28specification while remaining fully compatible with the2025-11-25release and earlier versions”. Confirmed. Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/README.md (accessed 2026-11-02)
Transports
- Supported transports, per the README table: stdio (
transport-io), client child process (transport-child-process), Streamable HTTP server (transport-streamable-http-server), Streamable HTTP client (transport-streamable-http-client-reqwest), and worker/in-process (transport-worker). Confirmed. Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/README.md (accessed 2026-11-02) - Streamable HTTP server is “a Tower service you can mount on any
axum/hyperrouter”;rmcp::transport::StreamableHttpServiceis the type. This is the direct Axum integration path for Quoro. Confirmed. Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/README.md (accessed 2026-11-02) - SSE as a standalone transport is not provided. SSE framing is an implementation detail of Streamable HTTP (
client-side-ssefeature). The legacy two-endpoint HTTP+SSE transport of revision2024-11-05is a “deliberate non-goal” and “rmcp will not add the legacy transport back”. Confirmed. Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/README.md (accessed 2026-11-02)
Auth and capability surface
- OAuth 2.1 support with PKCE (S256), RFC 8707 resource binding, RFC 9728 protected-resource metadata, RFC 8414 + OIDC AS metadata, RFC 7591 dynamic client registration, CIMD, scope upgrade on 403, and automatic token refresh. Enabled by the
authcargo feature. Confirmed. Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/docs/OAUTH_SUPPORT.md (accessed 2026-11-02) - The OAuth document describes the client side (obtaining and refreshing tokens, then calling an MCP server). It documents no server-side authorization middleware for a Rust MCP server. Whether a server can rely on rmcp for inbound token validation is therefore unclear. Hypothesis (absence of documentation is not proof of absence). Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/docs/OAUTH_SUPPORT.md (accessed 2026-11-02)
- Capabilities advertised in the README table of contents and examples: tools, resources (incl. subscriptions), prompts, sampling, elicitation, roots, logging, completions, notifications, tasks (long-running tool invocations), response caching. Confirmed. Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/README.md (accessed 2026-11-02)
- Tool macros exist:
#[tool],#[tool_router],#[tool_handler], plus#[tool_router(server_handler)]for tools-only servers; implemented in thermcp-macroscrate. JSON Schema is generated withschemars(draft 2020-12). Confirmed. Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/README.md (accessed 2026-11-02)
Maturity signals
| Signal | Value | Label | Source |
|---|---|---|---|
| GitHub stars | 4,085 | Confirmed | https://api.github.com/repos/modelcontextprotocol/rust-sdk (accessed 2026-11-02) |
| Open issues (GitHub count, includes PRs) | 70 | Confirmed | same |
| Forks | 694 | Confirmed | same |
| Contributors | 223 | Confirmed | https://api.github.com/repos/modelcontextprotocol/rust-sdk/contributors?per_page=1 (accessed 2026-11-02) |
| crates.io downloads (total) | 43,612,793 | Confirmed | https://crates.io/api/v1/crates/rmcp (accessed 2026-11-02) |
| crates.io downloads (recent 90d) | 24,877,320 | Confirmed | same |
| First release | 2025-03-16 | Confirmed | same |
| Last commit | 2026-11-01 | Confirmed | https://api.github.com/repos/modelcontextprotocol/rust-sdk (accessed 2026-11-02) |
- Notable users listed by the project: Block’s
goose,apollo-mcp-server(Apollo GraphQL),rustfs-mcp,containerd-mcp-server,nvim-mcp,stakpak-agent,hyper-mcp. Confirmed (as project self-reporting). Source: https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/README.md (accessed 2026-11-02) - Maturity judgement: downloads and named users indicate real production adoption, but the release history shows the API is still tracking a fast-moving spec with breaking releases every few months. Hypothesis (inference from download counts, release cadence and changelog, not a stated fact). Sources: as above (accessed 2026-11-02)
Litestream (SQLite replication)
Version, licence, maintenance
- Latest release is v0.5.17, published 2026-08-31. The project is still 0.x and its README carries a “status-beta” badge. Confirmed. Sources: https://api.github.com/repos/benbjohnson/litestream/releases and https://raw.githubusercontent.com/benbjohnson/litestream/main/README.md (accessed 2026-11-02)
- Licence is Apache-2.0. Confirmed. Source: https://api.github.com/repos/benbjohnson/litestream (accessed 2026-11-02)
- Maintained by Ben Johnson (repo owner); the README thanks Fly.io for support and testing credits. Recent commits: 2026-10-31 (
fix(s3): add opt-in PutObject checksums), 2026-10-30 (fix(s3): retry throttling with bounded backoff). 43 contributors, 14,564 stars, 149 open issues, not archived. Confirmed. Sources: https://api.github.com/repos/benbjohnson/litestream/commits and https://api.github.com/repos/benbjohnson/litestream (accessed 2026-11-02) - CONTRIBUTING says large external feature contributions are “generally not accepted” and major features are implemented internally. Confirmed. Source: https://raw.githubusercontent.com/benbjohnson/litestream/main/CONTRIBUTING.md (accessed 2026-11-02)
- Release cadence is irregular: multiple 0.5.x patches through 2026 (0.5.9 Feb, 0.5.10 Mar, 0.5.11 Apr, 0.5.12 Jun, 0.5.13–0.5.14 Jul, 0.5.15 Jul, 0.5.16 Aug, 0.5.17 Aug), then no release for ~2 months while commits continued. Confirmed. Source: https://api.github.com/repos/benbjohnson/litestream/releases?per_page=10 (accessed 2026-11-02)
Big recent changes (0.5.x)
- v0.5.0 (2025-09-30) replaced the storage layer with LTX (
Replace storage layer with LTX #645), removed generations (#636), removed multiple-replica support (#647), added multi-level compaction (#650) and multi-level restore (#652), added a VFS MVP (#721) and an MCP server integration (#655), and renamed thewalcommand toltx(#671). Confirmed. Source: https://api.github.com/repos/benbjohnson/litestream/releases/tags/v0.5.0 (accessed 2026-11-02) - Read replicas were not permanently removed: they return as an optional VFS extension, which is read-only, requires CGO, and ships as pre-built binaries or a
-tags vfsbuild. Confirmed. Sources: https://litestream.io/guides/vfs/ and https://litestream.io/how-it-works/ (accessed 2026-11-02) - Client-side (age) encryption was removed in the LTX rewrite. Since v0.5.1 a non-empty
age:block makes Litestream exit withage encryption is not currently supported; v0.5.0 silently ignored it. Server-side S3 encryption via SSE-C / SSE-KMS was added in PR #902 (merged 2025-12-30) and documented as v0.5.6. Confirmed. Sources: https://litestream.io/reference/config/ and https://api.github.com/repos/benbjohnson/litestream/pulls/902 (accessed 2026-11-02)
Cloudflare R2 support
- R2 has an official section in the “Replicating to S3-Compatible Services” guide. Minimal config:
type: s3,bucket,path,endpoint: ${CF_ACCOUNT_ID}.r2.cloudflarestorage.com,region: auto(orus-east-1for compatibility),access-key-id,secret-access-key. R2 supports both path-style and virtual-hosted addressing, soforce-path-styleis not required. Confirmed. Source: https://litestream.io/guides/s3-compatible/ (accessed 2026-11-02) - Since v0.5.0 the provider is auto-detected from the endpoint and provider defaults applied; for R2 the docs note (v0.5.8) that Litestream “automatically sets concurrency=2 for R2 endpoints to avoid R2’s strict concurrent upload limits”. A YAML-path detection gap for R2 was fixed in #1098 (merged 2026-02-11). Confirmed. Sources: https://litestream.io/guides/s3-compatible/ and https://api.github.com/repos/benbjohnson/litestream/issues/1098 (accessed 2026-11-02)
- Known R2-specific issue: HTTP 429
Reduce your request ratewas not retried by the S3 client; fixed byfix(s3): retry throttling with bounded backoff(#1609), closed 2026-10-30, i.e. not in the 0.5.17 release from 2026-08-31. Confirmed. Source: https://api.github.com/repos/benbjohnson/litestream/issues/1603 (accessed 2026-11-02) - Earlier R2 upload failures were caused by AWS SDK v2
aws-chunkedcontent encoding; fixed in v0.5.4 for all custom endpoints (InvalidContentEncoding,MalformedTrailerError). Confirmed. Sources: https://litestream.io/guides/s3-compatible/ and https://api.github.com/repos/benbjohnson/litestream/issues/948 (accessed 2026-11-02) - Open (not R2-specific but relevant to cost/rate): #1171 “High PUT volume despite zero SQLite writes and long sync-interval” (2026-02-27) and #1387 VFS page-fetch amplification. Confirmed (as open issues). Source: https://api.github.com/search/issues?q=repo:benbjohnson/litestream+R2 (accessed 2026-11-02)
Replication semantics
- Replication is asynchronous: “By default, Litestream will replicate new changes to an S3 replica every second”, and a crash can lose the writes in that window. Synchronous replication is on the roadmap but not implemented. Confirmed. Source: https://litestream.io/tips/ (accessed 2026-11-02)
- RPO is therefore the sync interval (default 1s, configurable via
sync-interval); graceful shutdown attempts a final sync of outstanding WAL changes. Confirmed. Sources: https://litestream.io/tips/ and https://litestream.io/reference/config/ (accessed 2026-11-02) - WAL mode is required; Litestream sets it automatically if absent, and recommends
PRAGMA busy_timeout = 5000, andPRAGMA wal_autocheckpoint = 0under high write load. Confirmed. Source: https://litestream.io/tips/ (accessed 2026-11-02) - Litestream takes over checkpointing by holding a long-running read transaction; it creates a
_litestream_locktable in the source database, which changes the schema and page count (restored DBs also contain it). Confirmed. Source: https://raw.githubusercontent.com/benbjohnson/litestream/main/README.md (accessed 2026-11-02) - Restore:
litestream restorefetches the newest snapshot not overshooting the target and replays LTX files in TXID order. Targets are-txid(inclusive) or-timestamp(exclusive), i.e. point-in-time restore. Restore granularity is bounded by LTX file boundaries (roughly one per sync while L0 is retained). Confirmed. Source: https://litestream.io/how-it-works/ (accessed 2026-11-02) - Single writer: “Ensure only one process writes to the database at a time” and “only one Litestream instance should replicate a given database”. Multiple replicators into the same bucket/path can make restore impossible. Confirmed. Source: https://litestream.io/guides/docker/ (accessed 2026-11-02)
- Process or library: production use is the CLI sidecar; the Go library exists but its API “is not considered stable”, requires
modernc.org/sqlite, and is explicitly not recommended where API stability matters. There is no Rust embedding path. Confirmed. Source: https://litestream.io/guides/go-library/ (accessed 2026-11-02)
Relevance to D-0023 / D-0015
- D-0023 already names Litestream as the SQLite exception in the catalog (“databases use their native replication orchestrated by Quoro … Litestream for SQLite”), and D-0015 notes Litestream as a candidate for backing up the control plane. Both are consistent with what Litestream actually does, with two caveats: it is a sidecar process per database, not an embedded library, and its 0.5.x backups are not client-side encrypted unless the target supports SSE-C/SSE-KMS. Confirmed for the mechanics; caveat relevance is a judgement. Sources: as above (accessed 2026-11-02)
OpenAPI pipeline: utoipa -> TypeScript
utoipa
- Latest is 6.0.0, released 2026-09-22; Apache-2.0; 59.4M downloads. Confirmed. Sources: https://crates.io/api/v1/crates/utoipa and https://api.github.com/repos/juhaku/utoipa (accessed 2026-11-02)
- Emits OpenAPI 3.1 (“Features: OpenAPI 3.1”). 6.0.0 added a
Version32type for OpenAPI 3.2, but the default document still reportsopenapi: 3.1.0(confirmed by open issue #1636). Confirmed. Sources: https://raw.githubusercontent.com/juhaku/utoipa/master/README.md, https://raw.githubusercontent.com/juhaku/utoipa/master/utoipa/CHANGELOG.md, https://api.github.com/repos/juhaku/utoipa/issues/1636 (accessed 2026-11-02) - Axum integration:
utoipa-axum0.3.0 (2026-09-22), which depends onaxum ^0.8.4andutoipa ^6.0.0; theaxum_extrasfeature parses path/query params and request/response bodies. Axum’s latest is 0.8.9, so versions line up. Confirmed. Sources: https://crates.io/api/v1/crates/utoipa-axum/0.3.0/dependencies and https://crates.io/api/v1/crates/axum (accessed 2026-11-02) - Maintenance: last commit 2026-10-20; two releases in the last 12 months (5.5.0 on 2026-05-04 and 6.0.0 on 2026-09-22); 4,141 stars, 256 open issues. Contributor concentration is extreme:
juhaku646 commits, next contributor 29. No “seeking maintainers”/“bus factor” issue was found. Confirmed for the numbers; Hypothesis that the project is a single-maintainer bus-factor risk. Sources: https://api.github.com/repos/juhaku/utoipa, https://api.github.com/repos/juhaku/utoipa/contributors?per_page=3, https://crates.io/api/v1/crates/utoipa (accessed 2026-11-02) - Alternatives, one line each: aide 0.15.1 stable (0.16.0-alpha.4 is the newest published, 2026-04-14), Apache/MIT, Axum-native OpenAPI generation. poem-openapi 5.1.16, last released 2025-07-28 (~15 months old), tied to the poem framework. Confirmed. Sources: https://crates.io/api/v1/crates/aide and https://crates.io/api/v1/crates/poem-openapi (accessed 2026-11-02)
orval
- Latest 8.46.0, published 2026-10-30, MIT, Node >= 22.18, 69 stable releases in the last 12 months, 3.62M downloads/week, 6,582 stars, 25 open issues. 8.0.0 was a major release on 2026-01-14. Confirmed. Sources: https://registry.npmjs.org/orval, https://api.npmjs.org/downloads/point/last-week/orval, https://api.github.com/repos/orval-labs/orval (accessed 2026-11-02)
- OpenAPI 3.1: Orval internally upgrades input to OpenAPI 3.1 (“before the internal OpenAPI 2.0 → 3.1 upgrade”), and its test fixtures were rewritten to OAS 3.1 in PRs #4149/#4153 (2026-09-18/19), with downstream OAS 3.0 handling removed (#4155). Confirmed. Sources: https://orval.dev/docs/reference/configuration/input and https://api.github.com/search/issues?q=repo:orval-labs/orval+%22OpenAPI+3.1%22 (accessed 2026-11-02)
- Generates React Query (TanStack Query) hooks, Zod schemas, native fetch clients, Axios, SWR, Vue/Svelte/Solid/Angular query, MSW mocks, and has an MCP-server target. Confirmed. Source: https://raw.githubusercontent.com/orval-labs/orval/master/README.md (accessed 2026-11-02)
@hey-api/openapi-ts
- Latest stable 0.99.0, published 2026-06-22 (dist-tag
latest);nextis a 2026-09-30 prerelease. MIT, Node >= 22.18, 50 stable releases in the last 12 months, 748 stable versions total, 6.88M downloads/week, 5,551 stars, 659 open issues. Confirmed. Sources: https://registry.npmjs.org/@hey-api%2fopenapi-ts, https://api.npmjs.org/downloads/point/last-week/@hey-api/openapi-ts, https://api.github.com/repos/hey-api/hey-api (accessed 2026-11-02) - OpenAPI 3.1: docs state “Hey API supports all valid OpenAPI versions and file formats”, and the config example accepts an inline
openapi: '3.1.1'document. Confirmed. Source: https://heyapi.dev/docs/openapi/typescript/configuration/input (accessed 2026-11-02) - Ships TanStack Query, Zod, Valibot, Fetch, Axios, Ky and other plugins. Confirmed. Source: https://heyapi.dev/docs/openapi/typescript/get-started (accessed 2026-11-02)
- Stability: pre-1.0 and explicit that “that pace sometimes comes with breaking changes”, documented per minor version (e.g. v0.99.0 plugin dedupe and
plugin.symbols->plugin.imports, v0.96.0 dropped Node 20, v0.91.0 dropped CJS). Confirmed. Source: https://heyapi.dev/docs/openapi/typescript/migrating (accessed 2026-11-02)
Known utoipa output vs generator incompatibilities
- Confirmed break, orval: utoipa 6.0.0 stopped serialising empty response
descriptionwhile still declaringopenapi: 3.1.0; OpenAPI 3.1 requiresdescriptionon a Response Object, and “strict clients such as orval reject it”. The fix is open as PR #1649 (“Always serialize the description of a response”). Until merged, the workaround is a doc comment/descriptionon every response or pinning utoipa 5.x. Confirmed. Sources: https://api.github.com/repos/juhaku/utoipa/issues/1636 and https://api.github.com/repos/juhaku/utoipa/issues/1649 (accessed 2026-11-02) - Nullable
Option<T>shape: utoipa emitsOption<T>asoneOf: [null, T](or atype: [T, "null"]array) rather than anullableflag; tracked in open issue #1260, which reports downstream generators choking on thenullbranch. utoipa-gen 6.x has partial mitigations (“emit title/default instead of oneOf when Option is non-nullable”, “avoid degenerate oneOf”). Confirmed for the emitted shape and issue state; Hypothesis that orval/openapi-ts mishandle it today (no direct repro found). Sources: https://api.github.com/repos/juhaku/utoipa/issues/1260 and https://raw.githubusercontent.com/juhaku/utoipa/master/utoipa-gen/CHANGELOG.md (accessed 2026-11-02) - orval 3.1 null-union bugs (independent of utoipa):
aliasCombinedTypes: trueproduced duplicate self-referencing types fortype: [object, 'null'](#4291, closed 2026-10-12), duplicate nullable component aliases (#4292, closed), and nullabledate-timequery params written asanyOfwere sent asDate.toString()(#4417, fixed 2026-11-01). Confirmed. Source: https://api.github.com/search/issues?q=repo:orval-labs/orval+%22OpenAPI+3.1%22 (accessed 2026-11-02) - openapi-ts +
allOfcomposition: open issue #4470 (2026-10-13) reports the TanStack Query plugin skipping infinite-query options when pagination arrives throughallOf— relevant because utoipa can emitallOffor flattened/composed schemas, but the issue is not utoipa-specific. Confirmed as an open issue; Hypothesis that it affects utoipa-generated specs. Source: https://api.github.com/search/issues?q=utoipa+repo:hey-api/hey-api (accessed 2026-11-02)
orval vs openapi-ts (sourced facts)
| Fact | orval | @hey-api/openapi-ts |
|---|---|---|
| Version / date | 8.46.0 / 2026-10-30 Confirmed [6] | 0.99.0 / 2026-06-22 Confirmed [7] |
| Licence | MIT Confirmed [6] | MIT Confirmed [8] |
| OpenAPI 3.1 | Yes, input upgraded to 3.1 internally Confirmed [9] | Yes, “all valid OpenAPI versions” Confirmed [10] |
| TanStack Query hooks | React/Vue/Svelte/Solid/Angular Query targets Confirmed [11] | TanStack Query plugin Confirmed [12] |
| Zod | @orval/zod client Confirmed [11] | Zod validator plugin Confirmed [12] |
| Fetch client | Native fetch client Confirmed [11] | Fetch API client Confirmed [12] |
| Node requirement | >= 22.18 Confirmed [6] | >= 22.18 Confirmed [8] |
| Stable releases, last 12 mo | 69 Confirmed [6] | 50 Confirmed [7] |
| npm downloads/week | 3.62M Confirmed [13] | 6.88M Confirmed [14] |
| Open GitHub issues | 25 Confirmed [15] | 659 Confirmed [16] |
| Semver status | 8.x, major bumps rare (8.0.0 2026-01-14) Confirmed [6] | pre-1.0, breaking changes per minor Confirmed [17] |
| Last release age | 3 days | ~4.5 months (stable channel) |
Versions checked
| Component | Version | Release date | Source |
|---|---|---|---|
| rmcp | 3.8.0 | 2026-10-26 | https://crates.io/api/v1/crates/rmcp |
| utoipa | 6.0.0 | 2026-09-22 | https://crates.io/api/v1/crates/utoipa |
| utoipa-axum | 0.3.0 | 2026-09-22 | https://crates.io/api/v1/crates/utoipa-axum |
| aide | 0.15.1 (0.16.0-alpha.4 latest) | 2026-04-14 (alpha) | https://crates.io/api/v1/crates/aide |
| poem-openapi | 5.1.16 | 2025-07-28 | https://crates.io/api/v1/crates/poem-openapi |
| axum | 0.8.9 | 2026-04-14 | https://crates.io/api/v1/crates/axum |
| Litestream | 0.5.17 | 2026-08-31 | https://api.github.com/repos/benbjohnson/litestream/releases |
| orval | 8.46.0 | 2026-10-30 | https://registry.npmjs.org/orval |
| @hey-api/openapi-ts | 0.99.0 | 2026-06-22 | https://registry.npmjs.org/@hey-api%2fopenapi-ts |
Implications for Quoro
- D-0028 (MCP: rmcp). The choice is sound on capability: official org, 1.0+, Axum-mountable Streamable HTTP, OAuth 2.1 client, all five spec revisions including
2025-11-25and2026-07-28. The cost is churn: three major versions in five months. Quoro should pinrmcpto an exact minor (not a caret range), keep the MCP surface behind a thin internal trait, and treat spec upgrades as scheduled work. Because rmcp deliberately omits the legacy 2024-11-05 HTTP+SSE transport, any agent client Quoro must support that only speaks that revision cannot connect directly — Streamable HTTP is the only HTTP option. - D-0028/Axum.
StreamableHttpServicebeing a Tower service means MCP can share the same Axum router, ports, tracing and middleware as the REST API. That is a real reduction in surface, consistent with “minimal dependencies”. - D-0028 (OpenAPI via utoipa). utoipa 6 is the only mature code-first option for Axum (
utoipa-axumtracks axum 0.8.x, which is current). The risk is maintenance concentration (one contributor, 256 open issues, two releases a year) and the confirmed orval break on missing responsedescription. Quoro can mitigate today by requiring adescriptionon every response in the API contract and by pinning utoipa exactly. - D-0028 (orval vs openapi-ts). Both satisfy the stated need (TanStack Query + Zod + fetch). orval is 8.x, low open-issue count, released days ago, and normalises to 3.1; openapi-ts has more downloads but is pre-1.0 with documented per-minor breaking changes and 659 open issues. Evidence favours orval on stability, openapi-ts on plugin breadth and download share. No decision is implied here.
- D-0023 / D-0015 (Litestream). The mechanics match the decisions: async WAL shipping, ~1s RPO, PITR by timestamp or TXID, one sidecar per database, official R2 target. But two premises need correcting: (a) Litestream is a separate process, not something Quoro can embed in a Rust binary, so “orchestrated by Quoro” means supervising a sidecar and its config; (b) in 0.5.x it provides no client-side encryption — age was removed and is hard-rejected. D-0023’s whole volume path is “content-addressed chunks (dedup, encrypted)”, so shipping SQLite backups in plaintext to R2 would be inconsistent with that decision. Options are SSE-C/SSE-KMS where the provider supports it, Quoro-side encryption of the SQLite dump/file, or using Quoro’s own chunk engine for SQLite too. That is a decision for the maintainer, not this research.
- D-0023 / D-0025 (R2). R2 is a first-class documented Litestream target with
region: autoand no force-path-style requirement, and Litestream already throttles concurrency to 2 for R2. The 429 retry fix landed after 0.5.17, so Quoro should not ship 0.5.17 as-is for R2 under load without testing, or should build from a commit including #1609. - D-0015 (control-plane backup). Litestream can back up the control-plane SQLite file, but it adds a
_litestream_locktable to the source schema and requires WAL mode plus a single writer. If Quoro’s state store is an append-only change log with its own chunk replication, Litestream may be redundant for the control plane; the evidence does not decide this.
Open risks and unknowns
- rmcp 3.x moves with the MCP spec;
2026-07-28removes protocol sessions andinitialize, so any Quoro design assuming stateful sessions orMcp-Session-Idwill not survive an rmcp upgrade. - No primary source found for server-side OAuth/token validation in rmcp; if Quoro’s MCP endpoint must authenticate inbound agents, that middleware may have to be written on the Axum side. Unverified.
- rmcp licence is in an MIT -> Apache-2.0 transition (GitHub reports “Other”). Legal review needed before distribution, especially with D-0004’s AGPL core.
- utoipa has a bus factor of roughly one and 256 open issues; no maintainer-succession statement found. A future stall would strand the API contract.
- The utoipa -> orval break (#1636/#1649) is unresolved as of 2026-11-02; whether the fix lands in 6.0.x or 6.1 is unknown.
- No direct repro was found for utoipa
Option<T>oneOf [null, T]failing in orval or openapi-ts; this needs a small fixture test before committing to the pipeline. - Litestream 0.5.x has no client-side encryption (age removed). R2 does list SSE-C (
x-amz-server-side-encryption-customer-*) as supported on PutObject, GetObject, HeadObject, CreateMultipartUpload and CopyObject, and lists SSE/SSE-KMS headers as unsupported. Confirmed. Source: https://developers.cloudflare.com/r2/api/s3/api/ (accessed 2026-11-02, reviewer agent:so-research). Whether Litestream’s SSE-C path works end to end against R2 is untested. SSE-C still means R2 holds plaintext transiently and the key travels with every request; it is not equivalent to client-side encryption. - Litestream is beta/0.x with no 1.0 date published; the 0.5 LTX format is incompatible with 0.3 (
generationsremoved), so restore tooling must match the version that wrote the replica. - The R2 429 backoff fix (#1609) is not in 0.5.17; the next release date is unknown.
- hey-api’s stable channel is ~4.5 months old while
nextprereleases continue; how quickly 1.0 lands is unknown. - Download counts and star counts are point-in-time and were read on 2026-11-02.
Sources
- https://crates.io/api/v1/crates/rmcp (accessed 2026-11-02)
- https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/README.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/LICENSE (accessed 2026-11-02)
- https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/crates/rmcp/src/model.rs (accessed 2026-11-02)
- https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/docs/OAUTH_SUPPORT.md (accessed 2026-11-02)
- https://registry.npmjs.org/orval (accessed 2026-11-02)
- https://registry.npmjs.org/@hey-api%2fopenapi-ts (accessed 2026-11-02)
- https://api.github.com/repos/hey-api/hey-api (accessed 2026-11-02)
- https://orval.dev/docs/reference/configuration/input (accessed 2026-11-02)
- https://heyapi.dev/docs/openapi/typescript/configuration/input (accessed 2026-11-02)
- https://raw.githubusercontent.com/orval-labs/orval/master/README.md (accessed 2026-11-02)
- https://heyapi.dev/docs/openapi/typescript/get-started (accessed 2026-11-02)
- https://api.npmjs.org/downloads/point/last-week/orval (accessed 2026-11-02)
- https://api.npmjs.org/downloads/point/last-week/@hey-api/openapi-ts (accessed 2026-11-02)
- https://api.github.com/repos/orval-labs/orval (accessed 2026-11-02)
- https://api.github.com/repos/modelcontextprotocol/rust-sdk (accessed 2026-11-02)
- https://heyapi.dev/docs/openapi/typescript/migrating (accessed 2026-11-02)
- https://api.github.com/repos/modelcontextprotocol/rust-sdk/releases (accessed 2026-11-02)
- https://api.github.com/repos/modelcontextprotocol/rust-sdk/releases/tags/rmcp-v2.0.0 (accessed 2026-11-02)
- https://api.github.com/repos/modelcontextprotocol/rust-sdk/releases/tags/rmcp-v3.0.0 (accessed 2026-11-02)
- https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/main/crates/rmcp/CHANGELOG.md (accessed 2026-11-02)
- https://api.github.com/repos/modelcontextprotocol/rust-sdk/contributors?per_page=1 (accessed 2026-11-02)
- https://api.github.com/repos/benbjohnson/litestream (accessed 2026-11-02)
- https://api.github.com/repos/benbjohnson/litestream/releases (accessed 2026-11-02)
- https://api.github.com/repos/benbjohnson/litestream/releases/tags/v0.5.0 (accessed 2026-11-02)
- https://raw.githubusercontent.com/benbjohnson/litestream/main/README.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/benbjohnson/litestream/main/CONTRIBUTING.md (accessed 2026-11-02)
- https://litestream.io/guides/s3-compatible/ (accessed 2026-11-02)
- https://litestream.io/how-it-works/ (accessed 2026-11-02)
- https://litestream.io/tips/ (accessed 2026-11-02)
- https://litestream.io/guides/docker/ (accessed 2026-11-02)
- https://litestream.io/guides/go-library/ (accessed 2026-11-02)
- https://litestream.io/guides/vfs/ (accessed 2026-11-02)
- https://litestream.io/reference/config/ (accessed 2026-11-02)
- https://api.github.com/repos/benbjohnson/litestream/issues/1098 (accessed 2026-11-02)
- https://api.github.com/repos/benbjohnson/litestream/issues/1603 (accessed 2026-11-02)
- https://api.github.com/repos/benbjohnson/litestream/issues/948 (accessed 2026-11-02)
- https://api.github.com/repos/benbjohnson/litestream/pulls/902 (accessed 2026-11-02)
- https://api.github.com/repos/benbjohnson/litestream/commits (accessed 2026-11-02)
- https://crates.io/api/v1/crates/utoipa (accessed 2026-11-02)
- https://raw.githubusercontent.com/juhaku/utoipa/master/README.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/juhaku/utoipa/master/utoipa/CHANGELOG.md (accessed 2026-11-02)
- https://raw.githubusercontent.com/juhaku/utoipa/master/utoipa-gen/CHANGELOG.md (accessed 2026-11-02)
- https://api.github.com/repos/juhaku/utoipa (accessed 2026-11-02)
- https://api.github.com/repos/juhaku/utoipa/contributors?per_page=3 (accessed 2026-11-02)
- https://api.github.com/repos/juhaku/utoipa/issues/1260 (accessed 2026-11-02)
- https://api.github.com/repos/juhaku/utoipa/issues/1636 (accessed 2026-11-02)
- https://api.github.com/repos/juhaku/utoipa/issues/1649 (accessed 2026-11-02)
- https://crates.io/api/v1/crates/utoipa-axum/0.3.0/dependencies (accessed 2026-11-02)
- https://crates.io/api/v1/crates/axum (accessed 2026-11-02)
- https://crates.io/api/v1/crates/aide (accessed 2026-11-02)
- https://crates.io/api/v1/crates/poem-openapi (accessed 2026-11-02)
- https://api.github.com/search/issues?q=repo:orval-labs/orval+%22OpenAPI+3.1%22 (accessed 2026-11-02)
- https://api.github.com/search/issues?q=utoipa+repo:hey-api/hey-api (accessed 2026-11-02)
- https://api.github.com/search/issues?q=repo:benbjohnson/litestream+R2 (accessed 2026-11-02)
Nested virtualization (KVM inside a VM) across common VPS and cloud providers
- Question: Q-0015 VERIFY: which common VPS providers expose nested virtualization / KVM
- Researched: 2026-11-02 by agent:ds-research-3
- Status: answered (11 providers now have a fetchable official statement; 4 remain “not found” and 2 are reported-only)
Short answer
Nested KVM is the exception, not the rule, on cheap VPS. It is explicitly refused by Hetzner Cloud, Hostinger VPS, Contabo VPS and netcup (SVM flag disabled), and reported refused by Akamai/Linode. It is offered on OVHcloud Public Cloud (with vmx exposed and a live-migration caveat), on DigitalOcean Droplets (per a DigitalOcean support-engineer answer; any Intel/AMD split is unconfirmed), on Contabo VDS, on Vultr VX1 only, and on specific cloud families: AWS opt-in (M8i/C8i/R8i/M7i/C7i/R7i/I7i/X8i), GCP Intel and N4D, Azure on Dv3/Dv4-class VMs but not with Trusted Launch, and OCI Intel VM shapes (OCI AMD VM shapes are excluded in writing). Bare metal is the reliable answer: a dedicated server has no hypervisor under you, so /dev/kvm is yours as long as VT-x/AMD-V is on in firmware.
For Quoro’s MVP the practical shortlist is: bare metal (Hetzner Robot/OVH/Scaleway Elastic Metal), OVHcloud Public Cloud, DigitalOcean Droplets (verify CPU family yourself — the Intel/AMD split is unconfirmed), AWS c8i/m8i/r8i with nested virtualization enabled, GCP Intel instances, or OCI Intel VM shapes. Everything else on the list must be tested with ls /dev/kvm before it is promised to a user.
“KVM VPS” in marketing means the host runs KVM. It says nothing about /dev/kvm inside your guest; most such plans do not expose it.
Findings
Provider matrix
Accessed 2026-11-02. “Bare metal” rows are inferred: with no hypervisor layer, the guest OS is the host OS and owns VT-x/AMD-V. That inference is labelled Hypothesis even where the product description is Confirmed. Rows marked “Not found” mean a real search was run and no official statement exists (the queries are listed under Open risks); they are not a negative claim.
| Provider | Product | Nested virt / /dev/kvm in guest? | Notes | Source | Label |
|---|---|---|---|---|---|
| Hetzner | Cloud (CX/CPX/CAX/CCX) | No | FAQ answer “Can I run virtual machines on cloud servers, or rather is nested virtualization possible? — No, this is not possible on cloud server.” Page last changed 2024-09-12 (older than 12 months) | https://docs.hetzner.com/cloud/servers/faq/ | Confirmed |
| Hetzner | Dedicated / Robot (bare metal) | Expected yes | Dedicated hardware, you install the OS and hypervisor; CPU must have VT-x/AMD-V enabled in BIOS. No Hetzner page verified /dev/kvm | — (inference) | Hypothesis |
| DigitalOcean | Droplets | Yes | A DigitalOcean support engineer (employee badge, “Sr. Developer Support Engineer”) answered: “Currently, DigitalOcean Droplets support KVM or nested virtualization in all regions. We do not recommend nested virtualization on the platform because there is often very poor performance.” Answer dated 2021-03-03 — old, and a community answer rather than a docs page, but it is DO-authored | https://www.digitalocean.com/community/questions/does-digitalocean-support-kvm-or-nested-virtulzation (accessed 2026-11-02) | Confirmed |
| DigitalOcean | Droplets (live migration caveat) | Nested VMs must be restarted after host migration | “If you have Droplets running workloads leveraging nested virtualization, you need to restart any nested VMs after a live migration.” | https://docs.digitalocean.com/products/droplets/details/live-migration/ (last verified 2026-07-29) | Confirmed |
| DigitalOcean | Droplets (CPU-family split) | Intel yes / AMD no | No DO page states any CPU restriction. The Intel/AMD split comes from community reports only; DO’s plan docs describe Intel vs AMD premium CPUs without mentioning virtualization | — (community) | Hypothesis |
| Vultr | Cloud Compute / Optimized Cloud Compute | Not found | No official nested-virt statement for these plan families. Vultr’s own docs index (docs.vultr.com/llms.txt, fetched) contains no “nested virtualization” entry; the only documented product is VX1 (next row) | https://docs.vultr.com/llms.txt | Not found (open risk) |
| Vultr | VX1 Cloud Compute | Yes | “For sandbox support: Vultr VX1 supports nested virtualization, enabling businesses to deploy microVM solutions with their own kernels for secure sandbox isolation.” A separate Vultr whitepaper/landing page repeats “Vultr VX1 supports nested virtualization, enabling secure provisioning of microVMs for AI sandboxes” (seen only in search snippet) | https://blogs.vultr.com/cpus-workhorse-agentic-ai-infrastructure (accessed 2026-11-02) | Confirmed (VX1 only) |
| Vultr | Bare Metal | Expected yes | “Bare Metal instances don’t use any virtualization” — single-tenant physical hardware | https://docs.vultr.com/products/compute/instances/bare-metal/provisioning.md (last updated 2026-10-23) | Confirmed (no virtualization layer); Hypothesis (KVM available) |
| Akamai / Linode | Compute Instances | No (reported) | Official answers exist only on linode.com/community and akamai.com/cloud/community, both of which return HTTP 403 to every fetch attempt. Snippets show “At present, there is no option to enable nested virtualization for a Linode” and “A Linode running on our host’s KVM hypervisor has nested virtualization disabled by default”. Seen only in search snippets; page not fetched | https://www.akamai.com/cloud/community/questions/17491/how-do-i-enable-nested-virtualization-for-my-linode (403) | Hypothesis |
| OVHcloud | VPS | Unclear; marketing implies VMs inside | The KVM VPS page says “KVM VPS turns the Linux kernel into a hypervisor and enables you to launch multiple virtual machines”, but this is generic marketing copy (it also distinguishes itself from the KVM console) and nowhere states that /dev/kvm/vmx is exposed in the guest. No OVH VPS FAQ entry on nested virt was found | https://www.ovhcloud.com/en/vps/vps-kvm/ (accessed 2026-11-02) | Hypothesis |
| OVHcloud | Public Cloud instances | Yes, with a live-migration caveat | Exact FAQ answer: “Is nested virtualization supported? Yes and no. Yes because it’s enabled (we provide vmx flag to your instance), which means you can start any virtualization technology that relies on this in your instance (such as KVM, QEMU, VirtualBox, Xen, HyperV, etc.). No because as soon we will live-migrate your instance (and this can happen any time, based on our hypervisor management), then your kernel may panic.” | https://docs.ovhcloud.com/en/guides/public-cloud/cross-functional/faq-pci (accessed 2026-11-02) | Confirmed |
| OVHcloud | Bare Metal / Rise / Advance | Expected yes | Bare metal product line; you own the hypervisor choice (OVH documents Proxmox, Hyper-V, ESXi installs) | — (inference) | Hypothesis |
| Scaleway | Instances (DEV1/PLAY2/PRO2/ENT1/POP2) | Not documented | Instance docs describe Instances as VMs on a shared hypervisor and never mention exposing vmx/svm. Scaleway’s only KVM-inside-a-server tutorial is explicitly scoped to Elastic Metal, which is consistent with (but does not prove) Instances not supporting nesting | https://www.scaleway.com/en/docs/instances/concepts | Not found (open risk) |
| Scaleway | Elastic Metal (bare metal) | Expected yes | Official tutorial walks through kvm-ok on an Elastic Metal server and expects “INFO: /dev/kvm exists / KVM acceleration can be used”, then virt-install. Reviewed 2025-07-02 | https://www.scaleway.com/en/docs/tutorials/install-kvm-elastic-metal/ (accessed 2026-11-02) | Confirmed (procedure documented); Hypothesis (works on every plan) |
| Contabo | VPS | No | “You can only setup nested virtualization with Contabo Dedicated Servers and Virtual Dedicated Servers (VDS). You cannot set up nested virtualization with Contabo Virtual Private Servers (VPS).” | https://help.contabo.com/en/support/solutions/articles/103000271595-can-i-setup-nested-virtualization-on-my-server- (accessed 2026-11-02) | Confirmed |
| Contabo | VDS (virtual dedicated server) | Yes | Same article: “You can only setup nested virtualization with Contabo Dedicated Servers and Virtual Dedicated Servers (VDS)”; and “Virtual Dedicated Servers have their own dedicated hardware resources and support nested virtualization”. Caveat: Hyper-V inside a Windows VDS is broken per Contabo’s own KB | https://help.contabo.com/en/support/solutions/articles/103000271595-can-i-setup-nested-virtualization-on-my-server- and https://contabo.com/blog/kb/103000270408-why-am-i-unable-to-create-virtual-machines-with-hyper-v-on-a-virtual-dedicated-server/ (both accessed 2026-11-02) | Confirmed |
| Netcup | VPS (x86 / ARM) | No (SVM flag disabled) | Help-centre FAQ “Can I virtualize on my server?” — “Using your own virtualization solutions on our servers is only possible to a limited extent. Activating nested virtualization (e.g., via the SVM flag) can lead to problems at the processor level… For security and stability reasons, the SVM flag is therefore disabled on our systems and cannot be activated.” Wording is AMD-specific (SVM) | https://www.netcup.com/en/helpcenter/documentation/server (accessed 2026-11-02) | Confirmed |
| Netcup | Root Server (dedicated) | Unclear / do not assume yes | The same “Servers” docs page compares VPS, ARM, Root Server and Dedicated CPU Cores products and its nesting FAQ is written for “our servers” generally, with the SVM flag “disabled on our systems”. No netcup page confirms nesting on a root server | https://www.netcup.com/en/helpcenter/documentation/server (accessed 2026-11-02) | Hypothesis |
| Hostinger | VPS (KVM) | No | “Nested virtualization is not supported on Hostinger Virtual Private Server (VPS) hosting plans. Hostinger disables it to protect resource allocation, system stability, and server performance”; “Hostinger VPS plans run on KVM virtualization without nested virtualization” | https://www.hostinger.com/support/10429687-is-nested-virtualization-supported-in-hostinger/ (updated ~2026-10-29, accessed 2026-11-02) | Confirmed |
| IONOS | VPS / Cloud Servers | Not found | No IONOS page states support or refusal for VPS or Cloud Servers. IONOS publishes KVM guidance only for Dedicated Servers (“Guidelines: Hosting Virtual Machines”, seen in search results, not fetched) | — | Not found (open risk) |
| UpCloud | Cloud Servers | Not found | The Cloud Servers docs index and product pages were fetched and contain no occurrence of “nested”; no support/KB article exists. UpCloud does appear as a sponsor in third-party microVM write-ups, which is not evidence | https://upcloud.com/docs/products/cloud-servers/ (accessed 2026-11-02) | Not found (open risk) |
| AWS | EC2 virtual (non-.metal) | Yes, opt-in, specific families | M7i, M7i-flex, M8i, M8id, M8i-flex; C7i, C7i-flex, C8i, C8id, C8i-flex; R7i, R7iz, R8i, R8id, R8i-flex, X8i; I7i, I7ie. Enable with --cpu-options "NestedVirtualization=enabled" at launch or on a stopped instance. KVM and Hyper-V supported as L1. No extra cost. AWS recommends bare metal for performance-sensitive workloads | https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/amazon-ec2-nested-virtualization.html | Confirmed |
| Google Cloud | Compute Engine | Yes, Intel (and N4D) only | Only Linux KVM as L1. Not available on E2, memory-optimized, ARM, or AMD (except N4D). Enabled per-VM; may be blocked by an org constraint | https://cloud.google.com/compute/docs/instances/nested-virtualization/overview | Confirmed |
| Microsoft Azure | Virtual Machines | Yes, on supported series | Microsoft’s own troubleshooting doc uses Dv3/Dv4 as examples of “a series that supports nested virtualization”. Trusted Launch security type does not support nested virtualization (Hyper-V install then fails with “virtualization support is not enabled in the BIOS”) | https://learn.microsoft.com/en-us/troubleshoot/azure/virtual-machines/windows/troubleshoot-vm-by-use-nested-virtualization (updated 2026-07-16) | Confirmed |
| Oracle Cloud | VM shapes (x86) — Intel | Yes | Oracle’s Oracle Linux KVM image doc lists as a release feature: “Nested virtualization for Oracle Cloud Infrastructure Intel-based virtual machine (VM) shapes”, linking to Creating a Virtual Machine. Also: “Instances with Nested Virtualization enabled do not support Live Migration.” | https://docs.oracle.com/en-us/iaas/oracle-linux/oci/index.htm and https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/movinganinstance.htm (both accessed 2026-11-02) | Confirmed |
| Oracle Cloud | VM shapes (x86) — AMD | No | “You can create Oracle KVM instances using any of the compute shapes. However, AMD processor-based virtual machines do not support nested virtualization.” | https://docs.oracle.com/en-us/iaas/oracle-linux/oci/index.htm (accessed 2026-11-02) | Confirmed |
| Oracle Cloud | Ampere A1 (Arm) | Unlikely / not stated | The OCI docs exclude AMD VM shapes by name but say nothing about Arm VM shapes; no page states A1 can expose EL2. Assume unsupported until tested | — (absence of statement) | Hypothesis |
| Oracle Cloud | Bare Metal shapes | Expected yes | Bare metal | — (inference) | Hypothesis |
| Equinix | Metal | Product is gone | “Equinix Metal was sunset on June 30, 2026. The user documentation was removed on October 1, 2026.” Timeline: End-of-Sale/EOL announced 2024-11-07; final day of service 2026-06-30; all resources removed 2026-07-01; console access removed 2027-01-01 | https://docs.equinix.com/goodbye-metal/ (accessed 2026-11-02) | Confirmed |
Why “KVM VPS” does not mean nested KVM
- The KVM kernel modules do not enable nesting by default: you must set
nested=1(AMD) ornested=Y(Intel) on the host. So a provider can run KVM and still hand you a CPU without vmx/svm. Confirmed. Source: https://www.linux-kvm.org/page/Nested_Guests (accessed 2026-11-02) - The same page calls nested guests “working but experimental” (as of Feb 2018) and documents a hard limitation: once an L1 guest runs an L2 guest it cannot be migrated, saved or loaded until the L2 shuts down. Confirmed. Same URL.
- Therefore “KVM VPS” is a statement about the host’s hypervisor (and usually about virtio/ISO support), not about guest CPU features. Choosing a plan because it says KVM tells you nothing about
/dev/kvm. Hypothesis (interpretation; no provider publishes a “KVM VPS implies no nesting” statement). - Corollary for bare metal: because there is no hypervisor under the OS,
/dev/kvmworks if the CPU exposes VT-x/AMD-V and firmware enables it. Hypothesis for every provider except where the product page states “no virtualization layer” (Vultr, Confirmed).
How a user checks support
/dev/kvmpresent and writable:ls -l /dev/kvm(expectcrw-rw---- root kvm). Then test an actual VM boot; presence alone does not prove the CPU supports it.- CPU flags:
grep -E 'vmx|svm' /proc/cpuinfo(vmx= Intel VT-x,svm= AMD-V). This is exactly the check Proxmox’s own nested-virt guide recommends. Confirmed. Source: https://pve.proxmox.com/wiki/Nested_Virtualization (accessed 2026-11-02; last edited 2025-12-03) - Module parameter on a host you control:
cat /sys/module/kvm_intel/parameters/nested(orkvm_amd). Confirmed. Same URL. kvm-ok(from thecpu-checkerpackage on Debian/Ubuntu) wraps the above and prints whether KVM acceleration can be used. Hypothesis (widely used; not verified against a vendor doc in this pass).- Quoro’s node-agent should run this check at enrolment and refuse to advertise “KVM-capable” without a real microVM boot test —
/dev/kvmcan exist while L2 guest entry fails.
Performance cost of nested virtualization
- GCP: “Even with hardware-assisted nested virtualization, nested VMs might experience a 10% or greater decrease in performance for workloads that are CPU-bound and possibly greater than a 10% decrease for workloads that are input/output bound.” Confirmed. Source: https://cloud.google.com/compute/docs/instances/nested-virtualization/overview (accessed 2026-11-02)
- Proxmox: running VMs with no hardware-assisted extensions exposed is “nearly unusable (you can expect 10x slower or more)”; with extensions exposed, nesting still “obviously adds an overhead”. Confirmed. Source: https://pve.proxmox.com/wiki/Nested_Virtualization (accessed 2026-11-02)
- AWS: recommends evaluating bare metal for workloads that need hardware virtualization extensions and are performance- or latency-sensitive. Confirmed. Source: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/amazon-ec2-nested-virtualization.html (accessed 2026-11-02)
- Proxmox also warns: “VMs with nesting active (vmx/svm flag) cannot be live-migrated”. Confirmed. Same Proxmox URL.
- Practical reading for Quoro: expect ~10%+ CPU overhead and worse I/O in a nested lab, plus no live migration; benchmarking on such a lab is not representative.
Enabling nested virtualization on a Proxmox host
Generic procedure on a physical Proxmox VE (PVE) host:
- Confirm the host CPU is AMD or a recent Intel, kernel >= 3.10. Confirmed. Source: https://pve.proxmox.com/wiki/Nested_Virtualization
- Check
cat /sys/module/kvm_intel/parameters/nested(orkvm_amd);Nmeans off. - Persist it:
- Intel:
echo "options kvm-intel nested=Y" > /etc/modprobe.d/kvm-intel.conf - AMD:
echo "options kvm-amd nested=1" > /etc/modprobe.d/kvm-amd.confThenmodprobe -r kvm_intel && modprobe kvm_intel(or reboot).
- Intel:
- Set each guest VM’s CPU type to
host:qm set <vmid> --cpu host. - Verify inside the guest with
egrep '(vmx|svm)' /proc/cpuinfo. - Expect no live migration for those VMs while nesting is active.
All six points are Confirmed by the Proxmox wiki page above (accessed 2026-11-02).
Versions checked
| Component | Version / date | Source |
|---|---|---|
| Hetzner Cloud FAQ | last changed 2024-09-12 (older than 12 months) | https://docs.hetzner.com/cloud/servers/faq/ |
| DigitalOcean Live Migrations doc | last verified 2026-07-29 | https://docs.digitalocean.com/products/droplets/details/live-migration/ |
| DigitalOcean community answer on KVM/nested virt | posted 2021-03-03 (5.5 years old) | https://www.digitalocean.com/community/questions/does-digitalocean-support-kvm-or-nested-virtulzation |
Vultr docs index (llms.txt) | no nested-virt entry | https://docs.vultr.com/llms.txt |
| Vultr blog on VX1 CPUs | undated; fetched 2026-11-02 | https://blogs.vultr.com/cpus-workhorse-agentic-ai-infrastructure |
| Vultr Bare Metal provisioning doc | last updated 2026-10-23 | https://docs.vultr.com/products/compute/instances/bare-metal/provisioning.md |
| OVHcloud Public Cloud FAQ | current, undated | https://docs.ovhcloud.com/en/guides/public-cloud/cross-functional/faq-pci |
| OVHcloud KVM VPS product page | current, undated | https://www.ovhcloud.com/en/vps/vps-kvm/ |
| Scaleway KVM on Elastic Metal tutorial | reviewed 2025-07-02 | https://www.scaleway.com/en/docs/tutorials/install-kvm-elastic-metal/ |
| Scaleway Instances concepts | current, undated | https://www.scaleway.com/en/docs/instances/concepts |
| Contabo KB on nested virtualization | last updated 2025-04-10 | https://help.contabo.com/en/support/solutions/articles/103000271595-can-i-setup-nested-virtualization-on-my-server- |
| Contabo virtualization product page | current, undated | https://contabo.com/en-us/virtualization/ |
| Contabo KB on Hyper-V in a VDS | last updated 2025-04-10 | https://contabo.com/blog/kb/103000270408-why-am-i-unable-to-create-virtual-machines-with-hyper-v-on-a-virtual-dedicated-server/ |
| netcup help centre, Servers docs | current, undated | https://www.netcup.com/en/helpcenter/documentation/server |
| Hostinger support article | “Updated 4 days ago” as of 2026-11-02 | https://www.hostinger.com/support/10429687-is-nested-virtualization-supported-in-hostinger/ |
| AWS EC2 nested virtualization doc | current (undated page) | https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/amazon-ec2-nested-virtualization.html |
| GCP nested virtualization overview | current (undated page) | https://cloud.google.com/compute/docs/instances/nested-virtualization/overview |
| Azure nested-virt troubleshooting doc | updated 2026-07-16 | https://learn.microsoft.com/en-us/troubleshoot/azure/virtual-machines/windows/troubleshoot-vm-by-use-nested-virtualization |
| OCI Oracle Linux KVM image doc | current, undated | https://docs.oracle.com/en-us/iaas/oracle-linux/oci/index.htm |
| OCI live-migration doc | current, undated | https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/movinganinstance.htm |
| Proxmox VE wiki, Nested Virtualization | last edited 2025-12-03 | https://pve.proxmox.com/wiki/Nested_Virtualization |
| KVM wiki, Nested Guests | revision 173935 (content dated Feb 2018) | https://www.linux-kvm.org/page/Nested_Guests |
| Equinix Metal shutdown doc | current, documentation removed 2026-10-01 | https://docs.equinix.com/goodbye-metal/ |
Implications for Quoro
- D-0009 is sound and the research supports keeping the container fallback deferred, but the addressable market is narrower than “VPS” suggests: of the providers checked, KVM inside a virtual product is documented for DigitalOcean Droplets, OVHcloud Public Cloud (with a live-migration caveat), Contabo VDS, Vultr VX1, AWS (specific families), GCP (Intel/N4D), Azure (Dv3/Dv4) and OCI Intel VM shapes — and AWS/GCP/Azure/OCI require an explicit opt-in or a specific CPU vendor that most users will not have chosen. Hetzner Cloud, Hostinger VPS, Contabo VPS and netcup explicitly refuse it, and Linode is reported to refuse it. Hetzner Cloud — the default cheap European VPS — is explicitly ruled out.
- Onboarding must verify, not trust the product name. A preflight (
/dev/kvm,vmx|svm, then an actual Cloud Hypervisor boot) belongs in the node-agent handshake, because “KVM VPS” is not evidence. Without this, Quoro will fail at first microVM launch on a host the user believes is supported. - Position bare metal as the supported path for self-hosters, and name the cheap ones that still exist: Hetzner Robot/dedicated, OVH Bare Metal, Scaleway Elastic Metal. Equinix Metal is no longer an option at all (sunset 2026-06-30) and must not appear in docs, pricing or provider lists.
- Cloud-provider support is a per-instance-family matrix, not a boolean. If Quoro documents AWS/GCP/Azure/OCI as “supported”, it must also document
--cpu-options NestedVirtualization=enabled(AWS) / the nested-virt flag and org constraint (GCP) / avoiding Trusted Launch (Azure) / choosing an Intel shape rather than AMD (OCI, which excludes AMD VM shapes in writing). Getting this wrong yields a “supported” host where KVM is absent. - OVHcloud Public Cloud is the one cheap virtual product with an explicit “yes”, but its own FAQ says a live migration “may panic” the guest kernel. That makes it acceptable for a lab or a failover-tolerant pool, not for a workload Quoro promises to keep up across host maintenance.
- Nested virtualization is slower (GCP states 10%+ CPU degradation; Proxmox warns nesting blocks live migration), so a nested test lab is not valid for performance benchmarks.
- A Proxmox-based test host needs
kvm_intel nested=Y/kvm_amd nested=1on the physical host in addition to CPU typehostin the guest. If lab VMs show novmx/svm, this is the fix, and it needs a module reload (and possibly a host reboot), i.e. it touches the shared host and should be scheduled with whoever owns it. - No accepted decision rests on a false premise here, but D-0009’s “VPS with nested virt” should be read as “a small, specific set of VPS products”, not as a general category.
Open risks and unknowns
- Still “not found” after a dedicated search pass (treat as unknown, not as “no”): Vultr Cloud Compute and Optimized Cloud Compute, Scaleway Instances, IONOS VPS/Cloud Servers, UpCloud Cloud Servers. Queries run per provider:
"<provider> nested virtualization","<provider> /dev/kvm","<provider> vmx","<provider> run KVM inside VPS","site:<provider docs domain> nested virtualization", plus"site:<provider docs domain> nested". For Vultr the whole docs index (docs.vultr.com/llms.txt) was fetched and contains no nested-virtualization entry; for UpCloud the Cloud Servers docs index was fetched and contains no occurrence of “nested”. Absence of documentation is not proof of refusal. - Akamai/Linode is Hypothesis, not Confirmed. Three separate official-looking answers (“no option to enable nested virtualization for a Linode”, “nested virtualization disabled by default”, “we don’t support nested virtualization at the moment”) live on
linode.com/communityandakamai.com/cloud/community, and every one of them returns HTTP 403 to a direct fetch. Only search snippets were available. - DigitalOcean’s “yes” is thin. It rests on a 2021 community answer by a DO support engineer plus the live-migration doc; there is no DO docs page that says “nested virtualization is supported”. The Intel/AMD split has no DO source at all and must be treated as folklore until tested per region/CPU.
- OVHcloud’s VPS (as opposed to Public Cloud) answer is marketing, not a statement. The KVM VPS page’s “launch multiple virtual machines” line may simply describe how the VPS itself is built. Do not promise nesting on OVH VPS without a
/dev/kvmtest. - Hetzner’s statement is from 2024-09-12 and covers Cloud only. Hetzner could change it, and dedicated servers were not verified either way.
- Contabo VDS supports nesting, but Contabo’s own KB says Hyper-V inside a Windows VDS is broken; only KVM/Proxmox/XenServer paths are safe.
- netcup’s refusal is phrased in AMD terms (“SVM flag”); an Intel-based netcup host was not checked, and netcup’s own docs page covers root servers too without confirming nesting there.
- Azure’s supported-series list is not enumerated in one authoritative page any more; Dv3/Dv4 are confirmed only as examples. Other series (Ev3, Fsv2, Mv2) are unverified, and Trusted Launch blocks nesting.
- OCI’s docs confirm Intel VM shapes and exclude AMD VM shapes, but say nothing about Ampere A1 (Arm). A1 is a Hypothesis, and the linked Oracle blog posts (blogs.oracle.com) return HTTP 403, so the how-to detail was not read.
- Actual performance overhead per workload is not measured; the 10% figure is GCP’s, on GCP hardware, and may be optimistic or pessimistic elsewhere.
- Cloud Hypervisor’s behaviour under nested KVM (not just “KVM boots”) was not tested here; some features (e.g. specific CPU features, MSR handling) may differ from bare metal.
Sources
- https://docs.hetzner.com/cloud/servers/faq/ (accessed 2026-11-02)
- https://docs.digitalocean.com/products/droplets/details/live-migration/ (accessed 2026-11-02)
- https://www.digitalocean.com/community/questions/does-digitalocean-support-kvm-or-nested-virtulzation (accessed 2026-11-02)
- https://docs.vultr.com/llms.txt (accessed 2026-11-02)
- https://blogs.vultr.com/cpus-workhorse-agentic-ai-infrastructure (accessed 2026-11-02)
- https://docs.vultr.com/products/compute/instances/bare-metal/provisioning.md (accessed 2026-11-02)
- https://www.ovhcloud.com/en/vps/vps-kvm/ (accessed 2026-11-02)
- https://docs.ovhcloud.com/en/guides/public-cloud/cross-functional/faq-pci (accessed 2026-11-02)
- https://www.scaleway.com/en/docs/instances/concepts (accessed 2026-11-02)
- https://www.scaleway.com/en/docs/tutorials/install-kvm-elastic-metal/ (accessed 2026-11-02)
- https://help.contabo.com/en/support/solutions/articles/103000271595-can-i-setup-nested-virtualization-on-my-server- (accessed 2026-11-02)
- https://contabo.com/en-us/virtualization/ (accessed 2026-11-02)
- https://contabo.com/blog/kb/103000270408-why-am-i-unable-to-create-virtual-machines-with-hyper-v-on-a-virtual-dedicated-server/ (accessed 2026-11-02)
- https://www.netcup.com/en/helpcenter/documentation/server (accessed 2026-11-02)
- https://www.hostinger.com/support/10429687-is-nested-virtualization-supported-in-hostinger/ (accessed 2026-11-02)
- https://upcloud.com/docs/products/cloud-servers/ (accessed 2026-11-02)
- https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/amazon-ec2-nested-virtualization.html (accessed 2026-11-02)
- https://cloud.google.com/compute/docs/instances/nested-virtualization/overview (accessed 2026-11-02)
- https://learn.microsoft.com/en-us/troubleshoot/azure/virtual-machines/windows/troubleshoot-vm-by-use-nested-virtualization (accessed 2026-11-02)
- https://docs.oracle.com/en-us/iaas/oracle-linux/oci/index.htm (accessed 2026-11-02)
- https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/movinganinstance.htm (accessed 2026-11-02)
- https://pve.proxmox.com/wiki/Nested_Virtualization (accessed 2026-11-02)
- https://www.linux-kvm.org/page/Nested_Guests (accessed 2026-11-02)
- https://docs.equinix.com/goodbye-metal/ (accessed 2026-11-02)
Not fetchable, cited only for a Hypothesis-level claim: https://www.akamai.com/cloud/community/questions/17491/how-do-i-enable-nested-virtualization-for-my-linode (HTTP 403), https://www.linode.com/community/questions/25027/kvm-not-available (HTTP 403), https://blogs.oracle.com/linux/kvm-nested-virtualization-in-oci (HTTP 403).
M4 web terminal and VM console
- Question: M4 spike (issue #151): how the web terminal reaches “the app’s context”, how the microVM’s serial console is streamed to the panel, and what the M4 contract pull request must add (ADR 0031, ADR 0056, ADR 0057, ADR 0089, ADR 0096, ADR 0112)
- Researched: 2026-11-04, on lab-2 (Cloud Hypervisor v54.0, the pinned guest kernel 7.2.8, nested KVM), with a throwaway guest program and host relay kept outside the repository
- Status: answered; the recommendations need maintainer decisions before the contract pull request
Short answer
- Web terminal: exec through Quoro’s own init over virtio-vsock. Every microVM gets a
--vsockdevice whose host end is a UNIX socket in the node’s root-only data directory.quoro-initlistens on one vsock port and, per connection, starts a PTY session inside the app’s root filesystem with the app’s environment. The node agent (root) is the only process that connects; the control plane (unprivileged) only relays bytes over the node session it already has. On lab-2 a prototype of exactly that ranuname -ain 21 ms, carried 56 MB/s, resized the PTY, served three sessions at once and stopped the guest cleanly when the reader stalled. A shell on the node, SSH into the guest, or a getty on the serial port are all worse (section 1.3). - VM console: read-only in M4, the serial port in
socketmode, with the node agent as its permanent single client and a node-side ring buffer. Today the serial port is a file that Cloud Hypervisor truncates at every VMM start and lets grow without bound. Cloud Hypervisor’s socket mode keeps a 1 MiB ring while no client is attached and replays it to the next client, but it serves one client and a new connection silently takes over from the old one, so neither the panel nor a second agent task may ever connect to it directly. Nothing in the guest reads the serial port, so an interactive console would have nothing to talk to. - The contract pull request adds two WebSocket session endpoints, two scopes (
app:terminal, human-only in M4;vm:console), an open and a close audit record per session with a node-local transcript referenced by hash, twostream_openkinds, a control-plane-to-nodestream_inputmessage with resize, a credit-basedstream_ack, and an engine change so one VM can have both the app-log console and a serial socket (section 3).
Every measured number below comes from nested virtualization on the lab and is an order of magnitude, not a benchmark.
Method
- A scratch microVM started directly with Cloud Hypervisor v54.0 as an unprivileged user in the
kvmgroup: no TAP, no disks, the pinned guest kernel, the same kernel command line the engine uses (console=ttyS0 loglevel=4 panic=-1 rdinit=/init), 1 vCPU, 256 MiB,--serial socket=or--serial file=,--console off,--vsock cid=3,socket=. - A throwaway static PID 1 (C, musl) standing in for
quoro-init: mounts, diagnostics onttyS0, a “tick” line every second (to see what happens to serial output while no client is attached), a reader onttyS0(to test input), and a vsock listener that runs a PTY session per connection. Frames are[u8 type][u32 length][payload]: open (rows, cols, argv), stdin data, resize, close; output data, exit status, error. A static BusyBox supplied the shell. - A host relay in Python standing in for the node agent: Cloud Hypervisor’s hybrid vsock handshake, then the frames above.
- Cloud Hypervisor v54.0 source read for the console behaviour (
serial_buffer/src/lib.rs,serial_buffer/src/socket.rs,vmm/src/serial_manager.rs,vmm/src/console_devices.rs,docs/vsock.md). - Read-only checks of the running node: the VM directory layout, the serial log of the demo app’s VM (a file read, never its sockets), and whether the demo app still answered afterwards.
Findings
What Quoro does today
| Item | Today | Evidence | Label |
|---|---|---|---|
| App output | virtio-console in socket mode under the node data dir’s consoles/, read by the node agent as tagged out/err lines (the M2 live-log path) | crates/node-agent/src/reconcile.rs, crates/engine/src/ch/vmconfig.rs, a running VM’s spec.json on lab-2 | Confirmed |
| Serial port | file mode, serial.log in the VM directory, carrying kernel messages and quoro-init: diagnostics | vmconfig.rs (Off/VirtioConsole both map serial to File) and lab-2 | Confirmed |
| Console modes | ConsoleMode is one of Off, VirtioConsole { socket }, Serial { socket }, so a VM has either the app-log socket or a serial socket, never both; Engine::console requires Serial and Engine::output requires VirtioConsole | docs/contracts/engine-trait.md section 3, crates/engine/src/ch/engine.rs | Confirmed |
| Boot output volume | With loglevel=4 a whole boot is 599 bytes: three kernel warnings and nine quoro-init: lines ending in app started | the demo VM’s serial.log on lab-2 | Confirmed |
| Guest input on the serial port | Nothing in the guest reads ttyS0: quoro-init only writes diagnostics there and starts no getty | crates/init/README.md “Console protocol”, crates/init/src/guest/ | Confirmed |
| vsock | No VM has a vsock device; the pinned guest kernel has virtio-vsock built in (virtio_vsock_init, vsock_init initcalls in the image) | lab-2, strings of the pinned vmlinux | Confirmed |
| Node protocol | stream_open kinds app_logs and deployment_logs; console and terminal are reserved for M4; stream_data.data (base64) is reserved for byte streams; there is no control-plane-to-node data message; text frames only, 1 MiB per message, 32 streams per session | docs/contracts/node-agent-protocol.md sections 3.2, 5.7, 5.15 | Confirmed |
| Panel auth on a WebSocket | The panel authenticates with the quoro_session cookie (HttpOnly; SameSite=Strict); browsers cannot set Authorization on a WebSocket; axum’s ws feature is already enabled for the node session | crates/control-plane/src/auth/mod.rs, api/auth.rs, Cargo.toml | Confirmed |
1. Web terminal
1.1 What “the app’s context” is
There are no containers or namespaces inside a Quoro microVM (ADR 0008): quoro-init mounts the overlay root, switches into it with chroot, sets the hostname, network and user, and starts the app. Anything quoro-init starts after the switch therefore runs in the app’s filesystem, network, hostname and kernel, which is the whole of the app’s context. A terminal needs no nsenter-style entry; it needs a process that the init starts on request, with the app’s environment and, by default, the app’s user.
1.2 Measurements of exec over vsock (option a)
| Check | Result | Label |
|---|---|---|
Hybrid vsock handshake (CONNECT 1024\n on the VMM’s UNIX socket, answer OK <port>) | 1.3 ms | Confirmed (lab) |
| Guest listener ready | the guest bound and listened on vsock port 1024 on its first try, 0.1 s after boot | Confirmed (lab) |
| First shell prompt after the open frame | 5.1 ms | Confirmed (lab) |
uname -a round trip (command echo to prompt, including the relay’s polling) | 21 ms; the output names the guest kernel, so it ran in the guest | Confirmed (lab) |
| PTY | tty printed /dev/pts/0; stty size printed 24 80, and 50 132 after a resize frame (TIOCSWINSZ) | Confirmed (lab) |
| Exit status | exit 7 arrived as wait status 1792 (exit code 7) | Confirmed (lab) |
| Close | a close frame sent SIGHUP to the session’s process group; the shell ended by signal 1 | Confirmed (lab) |
| Concurrency | three sessions at once, three shells, three exit codes, 1.0 s total for a sleep 1 each | Confirmed (lab) |
| Throughput | 67 MB of dd output through the PTY and vsock in 1.19 s, about 56 MB/s | Confirmed (lab) |
| Back-pressure | with yes running and the host not reading for 10 s, Cloud Hypervisor’s RSS stayed at 83.6 MiB (+60 KiB); reading resumed at the same rate. vsock’s credit flow control stops the guest, so a stalled reader costs no host memory in the VMM | Confirmed (lab) |
| Missing shell | asking for /bin/bash in an image that has none returns exec /bin/bash: No such file or directory and exit 127 | Confirmed (lab) |
| Port without listener | CONNECT 2048 is closed by the VMM with no answer | Confirmed (lab) |
| Host kernel module | Cloud Hypervisor held no /dev/vhost-* file descriptor; its vsock device is implemented in the VMM, and the host side is the UNIX socket | Confirmed (lab: /proc/<pid>/fd) |
| Guest-to-host direction | a guest can only reach the host if a process listens on <socket>_<port>; the node never creates one, so the guest cannot open connections to the host | Confirmed (Cloud Hypervisor docs/vsock.md); the node rule is a recommendation |
| The same CID for every VM | each VMM has its own UNIX socket, so a fixed guest CID per VM does not collide on the host | Hypothesis (true for the hybrid backend measured here; would not hold with vhost-vsock) |
1.3 Options compared
| Option | How | Verdict |
|---|---|---|
(a) exec through quoro-init over vsock | The node agent connects to the VM’s vsock socket; the init spawns a PTY session in the app’s root | Recommended. Runs inside the guest (threat model T-LOG-06), many sessions, resize, exit codes, back-pressure, no network exposure, no new host privilege, works for every image that has the requested program |
| (b1) a shell on the node | nsenter or a root shell on the host | Rejected. There is no namespace to enter (a microVM is not a container), and the result is a root shell on the host, which is the opposite of “in the app’s context” and of T-LOG-06 |
| (b2) mount the VM’s disks on the host | loop-mount the root and data images | Rejected. Files only, no processes; mounting a live ext4 that the guest also writes corrupts it |
| (b3) SSH into the guest | sshd in the image, keys pushed in | Rejected. Needs sshd and keys in every image, listens on the VM’s network, where every VM of the same project can reach it (ADR 0084) |
| (c1) a getty on the serial port or a second console | the init starts a shell on ttyS0 or hvc0 | Rejected. One client, and a new client silently takes over (section 2); no resize signalling; one shell shared by every user who opens it, with its history and working directory; about 30 KB/s on the lab; ttyS0 also carries kernel and init output, hvc0 carries the tagged app logs |
| (c2) an in-guest agent over the VM’s network | the init or a sidecar listens on TCP | Rejected. Same exposure as (b3) |
1.4 What quoro-init needs
- A vsock listener on one fixed port, started after the switch into the app’s root and before the app, kept for the VM’s lifetime, never accepting anything but connections from the host (vsock delivers only host connections to a guest listener).
- Per connection: read an open request (argv, optional user, rows, cols), allocate a PTY, start the program as a session leader with the PTY as its controlling terminal, the app’s environment (
TERMset), the app’s working directory and the app’s user unless the request names another, relay bytes and resize until the program exits, send the exit status, and on a close from the host sendSIGHUPto the session’s process group. - The workspace forbids
unsafe.std::os::unix::process::CommandExt::pre_exec, the usual place to callsetsidand take a controlling terminal between fork and exec, is anunsafe fn. A safe path is for the init to re-execute itself as a small session helper (/proc/self/exewith a helper argument): the helper callssetsidand opens the PTY’s secondary side, which becomes its controlling terminal, moves it onto stdin, stdout and stderr, and then replaces itself with the requested program through the safeCommandExt::exec. rustix providessetsid, the PTY calls (openpt,grantpt,unlockpt,ptsname) andtcsetwinsize. rustix 1.1.6 knowsAddressFamily::VSOCKbut has no vsock socket-address type, and itsSocketAddrArgtrait isunsafeto implement, so the listener cannot be written with rustix alone under the workspace’sforbid(unsafe_code)(Confirmed, rustix 1.1.6 source). Thevsockcrate (0.5.5, Apache-2.0, on the ADR 0080 allowlist) provides a safeVsockListener; it is the smallest addition, and keeps theunsafeinside a dependency the way rustix already does. - A versioned config change: the init’s config grammar refuses unknown keys, so an
execline (or a port) is a newquoro-init-configversion, and the engine and the init must ship together. The initramfs is rebuilt from the installed binary, so a VM gets the new init on its next start; running VMs keep the old one until they restart. - The engine adds
--vsock cid=<n>,socket=<vm dir>/vsock.sockat create. Cloud Hypervisor creates that socket at boot, not at create (measured), in the node’s root-only data directory. - Images without a shell (distroless,
scratch): the session can run any program the image has, and fails with a clear “not found” otherwise. A Quoro-supplied static toolbox (for example on a read-only extra disk mounted at a hidden path) would cover those images, but it ships a third-party binary inside every VM with its own license and update duty (BusyBox is GPL-2.0, outside the ADR 0080 allowlist, which covers Rust dependencies), so it is left for after M4.
1.5 Security of the terminal
- Who opens it. A new scope
app:terminal. A shell inside the app reads every environment variable and secret the app holds, can change the app’s files and processes, and its effect cannot be expressed as one audited API call. M4 makes it human-only and part of theownerandoperatorbundles, notviewer. Agents keep acting through scoped API calls; a later, non-interactiveapp:exec(one argv, captured output, recorded in full) reachable only through an approval grant (ADR 0030) fits agents better than an interactive PTY, and is a separate decision. - Privilege split (ADR 0089, ADR 0096). The control plane never touches a VM socket: it authorizes, audits, and relays bytes between the panel’s WebSocket and the node session it already holds. The node agent, already root and already the only process that reads VM sockets, connects to the vsock socket. No new privilege appears in the API process, and the vsock socket lives in the node’s
0700root-owned data directory. - The guest is untrusted. The app (often root in its own guest) can kill or impersonate the session, read what the human types and print anything. The node therefore bounds every frame, never parses guest output as anything but bytes, and the panel’s xterm.js must disable features that act on the browser (clipboard writes through OSC 52, link handlers that open URLs without a click) and render the session as a terminal, never as HTML (threat model T-LOG-03).
- A long-lived WebSocket is one request. The permission model checks a grant or role on every request (T-GRT-04). A session must therefore be re-checked while it is open: the primary closes it when the identity is disabled, the role changes, the session cookie or token expires, or (when agents get access) the grant is revoked, and on an idle timeout (proposal: 15 minutes without input) and a hard maximum (proposal: 8 hours).
- Cross-site WebSocket hijacking. A browser sends cookies on a WebSocket handshake from any origin.
SameSite=Strictalready blocks cross-site use, but the handshake must also checkOriginagainst the panel’s own origin, and the recommended open flow (section 3.1) adds a single-use ticket so the upgrade request itself carries no ambient authority.
1.6 Audit of a session
The audit log records one record per call (audit-log-format.md section 3), and a terminal session is a call that lasts minutes. What can be recorded:
| Option | What | Trade-off |
|---|---|---|
| Open and close only | the open as a normal record (who, on whose behalf, target VM, rows, cols, argv, result) and a close record (duration, bytes each way, exit code, close reason) | Cheap, says nothing about what was done |
| “Commands” | parse the input stream into commands | Not reliable: a PTY carries keystrokes, line editing, history recall, pasted text and full-screen programs; a parser gives a false sense of completeness |
| Open and close, plus a node-local transcript referenced by hash | the node records the session as a timed byte transcript (input and output, asciicast-style) to a size-capped file; the close record carries its SHA-256 and size | Recommended. The hash-chained close record makes later tampering with the transcript evident (ADR 0031); the transcript stays on the node like other telemetry (ADR 0014); the audit log does not grow with session size |
| Transcript in the audit log or on the primary | ship the bytes to the primary | Moves bulk session data off the node against ADR 0014 and puts secrets typed or printed in a session into the replicated, exported audit log |
Either transcript option records secrets that appear in the session (a printed environment, a pasted password). That is the purpose of an audit trail of a root-equivalent session, and it means transcripts need the same access control as the audit log (audit:read) and a retention limit.
2. VM console
2.1 Cloud Hypervisor’s serial back-ends (v54.0)
| Mode | Behaviour | Evidence | Label |
|---|---|---|---|
file=<path> | File::create at every VMM start, so the previous boot’s output is lost on restart; no size limit while the VM runs; no input | vmm/src/console_devices.rs; lab: a second VMM on the same path left 7 lines of the 318 written by the first | Confirmed |
socket=<path> | stream UNIX socket, created at vm.create (before boot) | lab | Confirmed |
socket= with no client | output goes to a ring of MAX_BUFFER_SIZE = 1 << 20 bytes that keeps the newest bytes; the ring is replayed to the next client and emptied by the replay | serial_buffer/src/lib.rs; lab: a client connecting 6 s after boot received the whole boot, and a client returning after a 12 s gap received the lines written during the gap and nothing older | Confirmed |
socket= with a slow client | bytes the client cannot take are put in the same 1 MiB ring; the guest is never slowed and the oldest bytes are dropped | serial_buffer/src/lib.rs | Confirmed |
| A second client | accepted; the VMM shuts down the current client’s connection and serves the new one (last connection wins, silently) | vmm/src/serial_manager.rs; lab: client A got end-of-file the moment client B connected, and B received the next line | Confirmed |
| Input | bytes from the client reach the guest’s ttyS0 line discipline (the guest echoed them) | lab | Confirmed |
| Speed | about 30 KB/s from the guest on nested virtualization, the same with and without a client (each byte is a port write the VMM emulates) | lab: 200 KB in 6.3 s of guest system time | Confirmed (lab), slower than bare metal is a Hypothesis |
pty, tty, tcp= | also available; tcp= is loopback-only and single-client | docs/research/cloud-hypervisor.md | Confirmed |
The CPU cost of the serial port is on the boot path: the kernel writes console messages through the emulated UART, so a loglevel=7 boot (318 lines, 21 KB on the lab) would add roughly 0.7 s of boot time at the measured rate (Hypothesis, derived from the measured rate, not timed directly). With today’s loglevel=4 a boot writes about 600 bytes.
2.2 Recommendation for the console
- Serial in
socketmode, the node agent as its only client fromvm.create. The agent connects as soon as the VM is created (the socket exists before boot), so it sees the whole boot. It appends what it reads to a node-local, size-capped ring per VM (proposal: two 512 KiB segments in the VM directory), which survives VMM restarts and therefore keeps the previous boot’s output, the one an operator needs after a crash loop. While the agent restarts (KillMode=processkeeps VMMs running, ADR 0096), Cloud Hypervisor’s own 1 MiB ring covers the gap and is replayed on reconnect. - Viewers never connect to the socket. A second connection would silently take the console away from the agent. A console session is a
stream_openof kindconsoleon the node session: the node replays the ring (a requested tail, default all of it) and then follows live output, fanning out to any number of viewers. - Read-only in M4. Nothing in the guest reads
ttyS0, so input would go nowhere; the interactive shell is the web terminal. If a later engine ships guests with a serial getty, input becomes a separate, human-only scope. - Keep
loglevel=4. The DoD asks for “the guest’s boot output”: thequoro-init:lines (root ready, hostname, network, user,app started, and anyFATAL) plus kernel warnings are that output and are what an operator needs. A higher level costs boot time on the serial port (section 2.1). - The engine contract changes.
VmSpecmust carry the app-log virtio-console and the serial socket at the same time (for examplelog_consoleandserialas two fields), andEngine::consolebecomes a read of the node’s ring plus live output instead of a direct socket connection. This is an engine-trait change and goes into the contract pull request. - Minimal alternative. If the engine change does not fit M4, the node agent can tail today’s
serial.logread-only. It needs no engine change, but it keeps the unbounded file and loses the previous boot on every restart.
2.3 Who opens the console
A new scope vm:console: read the serial console of a VM. The output is kernel and init diagnostics (the init prints counts, never environment values), which is closer to logs than to a shell, so vm:console is grantable to agents (an agent diagnosing a boot that fails is the loop of ADR 0032) and part of the owner and operator bundles. Adding it to viewer would change the role bundles; M4 can leave it out. The open is one audit record; a close record with duration and byte count matches the terminal’s shape.
3. Recommendations for the M4 contract pull request
3.1 OpenAPI
| Operation | Scope | Notes |
|---|---|---|
POST /api/v1/apps/{app_id}/terminal-sessions | app:terminal (human-only in M4) | Body { "rows", "cols", "command"?: [argv], "user"? }. Picks the app’s running VM (or vm_id in the body when there are several). Returns 201 { "id", "ticket", "expires_at", "websocket_path" }; the ticket is single use and expires in 30 s; this call is the audited open |
POST /api/v1/vms/{vm_id}/console-sessions | vm:console | Body { "tail_bytes"? }. Same answer shape |
GET /api/v1/interactive-sessions/{session_id}/ws?ticket= | the ticket | WebSocket upgrade (ADR 0057). Origin must match the panel’s origin for cookie-authenticated tickets. A spent, expired or foreign ticket is 401 before the upgrade |
DELETE /api/v1/interactive-sessions/{session_id} | the opener, or owner | Ends a session; the close record names who ended it |
Session ids need a new prefix in the identifier table (proposal: trm_ for both terminal and console sessions). The paths say interactive-sessions rather than sessions so they are not confused with the panel’s login sessions (the sessions table). Tickets can live in the primary’s memory: they are single use and short, and the primary is the only process that serves the upgrade.
WebSocket framing (panel to primary). Binary frames carry raw terminal bytes in both directions, so xterm.js writes them as they come. Text frames carry JSON control messages: client to server {"type":"resize","rows":R,"cols":C}; server to client {"type":"exit","code":N}, {"type":"closed","reason":"idle_timeout"|"max_duration"|"revoked"|"ended_by_user"|"node_disconnected"|"vm_stopped"} and {"type":"error","code":...}, then a normal close. A console session accepts no binary frames from the client (read-only).
3.2 Scopes (permission-model.md)
app:terminal: open an interactive terminal in an app’s VM. Human-only in M4; in theownerandoperatorbundles.vm:console: read a VM’s serial console. Grantable; in theownerandoperatorbundles.
3.3 Audit (audit-log-format.md)
- The open is a normal record for the
POST, withtarget_typevm(orapp),params{rows, cols, command, user}and the session id inresult_detailor a new optional fieldsession_id. - A new recorded action, the session end, written by the primary when the session closes for any reason, with the same actor,
on_behalf_ofand channel as the open,operationsession.end, andparams{session_id, kind: "terminal"|"console", duration_ms, bytes_in, bytes_out, exit_code?, close_reason, transcript_sha256?, transcript_bytes?}. It is the only way to know a session ended and how. - Transcripts stay on the node (section 1.6); retention and how
audit:readholders fetch them are decisions for the contract pull request.
3.4 Node-agent protocol (node-agent-protocol.md)
stream_openkindterminal, params{vm_id, rows, cols, argv?, user?}; kindconsole, params{vm_id, tail_bytes?}.stream_datafor both kinds uses the reserveddatafield (base64), at most 64 KiB of decoded bytes per message, coalesced over a few milliseconds so one keystroke echo is not one message.- A new message
stream_input, control plane to node:{stream_id, seq, data?, resize?: {rows, cols}, eof?}. A node answersbad_messageforstream_inputon aconsolestream. - A new message
stream_ack, control plane to node:{stream_id, acked_bytes}. The node sends at most a fixed window (proposal: 256 KiB) of unacknowledged output per stream, then stops reading the vsock socket; the measured vsock back-pressure then stops the guest program. Without it, one busy terminal can fill the node session’s queue and delay heartbeats and lease messages on the same WebSocket. stream_endgains an optionalexit_codeforterminal.
3.5 Engine trait and guest init
VmSpeccarries a vsock device and both consoles (section 2.2);Capabilitiesgainsexec: bool.- A new
Engine::exec(id, ExecRequest) -> ExecSession(a byte stream plus resize and an exit future), implemented over the vsock socket;Engine::consolereads the node’s ring. quoro-init-configv2 with the exec listener settings (section 1.4).
Implications for Quoro
- ADR 0056 (consoles are text-only, xterm.js over serial or virtio-console, “or a vsock in-guest agent”) already allows both mechanisms recommended here; no ADR has to be superseded.
- ADR 0057 (WebSockets for interactive sessions) holds; the node session needs
stream_inputandstream_ackbecause today it only carries data from node to primary. - ADR 0089 and ADR 0096 hold: no new privilege in the control plane.
- ADR 0014 holds: console bytes and transcripts stay on the node; only the audit records travel.
- ADR 0112 plans the interactive epic with the terminal and the console “both through grants”. This note keeps the console grantable but recommends a human-only
app:terminalin M4 (section 1.5), which narrows that plan for the terminal; the maintainer decides it with the contract pull request. - The engine-trait statement that
consoleuses--serial socket=and answersConflicton a second reader is accurate for the socket, but the socket’s takeover behaviour (a second connection displaces the first) makes a direct per-viewer connection unsafe; the node must own the socket.
Open risks
- A new guest dependency. The init’s vsock listener needs the
vsockcrate or an equivalent (section 1.4), the init’s second dependency after rustix; it must build static for musl and pass cargo-deny (ADR 0080, ADR 0086). - Agent restarts during a session. A node agent restart drops every open terminal (the vsock connection belongs to the agent process). Sessions are not resumable in M4; the close record says
node_disconnected. - Data lost before the agent connects. Cloud Hypervisor’s ring is 1 MiB and drops the oldest bytes; a guest that floods
ttyS0while the agent is down loses its earliest output. - A guest that floods the serial port costs host CPU in the VMM (one emulated port write per byte). The node-side ring caps disk use, not CPU.
- Transcripts hold secrets. Retention, access and export of transcripts need decisions; until then a cap and
audit:readaccess are the minimum. - Old guests. VMs started before the new init have no vsock device and no listener; the terminal answers “not available until the app restarts”.
- Images without a shell get a terminal that can only run programs the image ships (section 1.4).
- Nested virtualization numbers. Every timing here is from nested KVM on the lab and only says “fast enough”; it is not a benchmark.
Sources
- Cloud Hypervisor v54.0,
serial_buffer/src/lib.rs(MAX_BUFFER_SIZE, ring behaviour): https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/v54.0/serial_buffer/src/lib.rs (accessed 2026-11-04) - Cloud Hypervisor v54.0,
serial_buffer/src/socket.rs(one client, buffering until a client connects): https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/v54.0/serial_buffer/src/socket.rs (accessed 2026-11-04) - Cloud Hypervisor v54.0,
vmm/src/serial_manager.rs(a new connection shuts down the current client): https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/v54.0/vmm/src/serial_manager.rs (accessed 2026-11-04) - Cloud Hypervisor v54.0,
vmm/src/console_devices.rs(File::createforfile=): https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/v54.0/vmm/src/console_devices.rs (accessed 2026-11-04) - Cloud Hypervisor v54.0,
docs/vsock.md(hybrid vsock,CONNECT <port>,<socket>_<port>for guest-initiated connections): https://raw.githubusercontent.com/cloud-hypervisor/cloud-hypervisor/v54.0/docs/vsock.md (accessed 2026-11-04) - rustix 1.1.6 source,
src/net/types.rs(AddressFamily::VSOCK) andsrc/net/addr.rs(pub unsafe trait SocketAddrArg): https://crates.io/crates/rustix/1.1.6 (accessed 2026-11-04) vsockcrate 0.5.5, Apache-2.0: https://crates.io/crates/vsock (accessed 2026-11-04)- Rust standard library,
CommandExt::pre_execisunsafe,CommandExt::execis safe: https://doc.rust-lang.org/std/os/unix/process/trait.CommandExt.html (accessed 2026-11-04) - Quoro:
docs/research/cloud-hypervisor.md,docs/contracts/engine-trait.md,docs/contracts/node-agent-protocol.md,docs/contracts/permission-model.md,docs/contracts/audit-log-format.md,docs/architecture/threat-model.md,crates/init/README.md,crates/engine/src/ch/,crates/node-agent/src/,crates/control-plane/src/ - Lab measurements on lab-2, 2026-11-04 (scratch microVM, removed afterwards)
M4 builds spike: BuildKit in a builder microVM, Railpack and Nixpacks, and the Builder contract
- Question: can a BuildKit daemon run inside an ephemeral microVM under Cloud Hypervisor with Quoro’s kernel and init, and produce an OCI layout the node feeds into the OCI-to-rootfs path (ADR 0060, ADR 0061)? Are Railpack and Nixpacks still viable zero-config builders? What shape should the Builder interface and the M4 contract take?
- Researched: 2026-11-03, on lab-3 (nested virtualization, 2 vCPU / 4 GB), with the pinned Cloud Hypervisor v54.0, the pinned guest kernel (
ch-release-v7.2.8-20261009), the initramfs the node agent built fromcrates/init, and an unmodified M3 node agent running next to the spike. - Status: answered. Prototype code stayed outside the repository and was deleted from the lab afterwards.
Short answer
- BuildKit in a builder microVM: works, with four additions.
buildkitdv0.34.2 (rootful,runcexecutor,overlayfssnapshotter) ran as the app under our init and built every Dockerfile tried: a tiny Alpine image, a Node app, a multi-stage Go app cloned from Git by BuildKit itself, and a deliberately failing build.buildctl --output type=ociwrote an OCI layout tarball whose layers are alltar+gzipand whose config carries onediff_idper layer. A scratch importer that reuses the engine’s ownlayer::apply_layer,ext4::buildandcache::write_entryturned four of those layouts into root disks, and each booted under our init and answered HTTP 200. No kernel change was needed. The four additions: (a) a local-layout source inImageStore(today it only pulls from a registry); (b) a fourth disk for the BuildKit state, the build context and the output, because the guest root is an overlay and the engine rejects extra disks until M5a; (c) mountingcgroup2on/sys/fs/cgroup, which our init does not do; (d) a way for logs and the result to leave the VM, which the existing tagged serial output already covers. - Railpack: viable second Builder; Nixpacks: no. Nixpacks’ README states it is in maintenance mode and points to Railpack. Its last release, v1.41.0, came out on 2025-10-24, and it builds by shelling out to
docker build. Railpack (MIT, v0.43.0 on 2026-10-19, releases every few days) ships a BuildKit gateway frontend: a JSON plan replaces the Dockerfile, the frontend turns it into LLB, and no Dockerfile is generated. It ran inside the same builder microVM with no change to the VM and produced an image that booted and answered 200. Both projects are MIT, which is on the ADR 0080 allowlist. Neither is linked into Quoro: they run inside the builder VM as separate binaries and images. - Recommendation: a node-agent
Buildertrait with one implementation per frontend (Dockerfile first, Railpack later). It takes a source, a build spec and limits, and returns a stream of build events and, on success, an OCI layout on the node. Builds run on the node that will run the app, in an ephemeral builder microVM created through the Engine trait. Logs leave the VM as tagged lines on the serial console, as app logs already do, and reach the panel over SSE through the existingstream_openpath. The contract details and the risks are in Implications for Quoro and Open risks.
Every number below comes from nested virtualization on one shared physical host. The roadmap says the lab is not valid for benchmarks, so read the figures as orders of magnitude.
Findings
1. How the spike was run
The spike drove cloud-hypervisor directly with the same boot payload and disk layout the engine uses (crates/engine/src/ch/vmconfig.rs). The production node agent kept running its own VM the whole time, and Quoro was never stopped.
| Item | Value |
|---|---|
| Kernel and cmdline | Pinned vmlinux-x86_64, console=ttyS0 loglevel=4 panic=-1 rdinit=/init |
| Initramfs | The node agent’s initramfs.cpio (our quoro-init) |
quoro-root | moby/buildkit:v0.34.2@sha256:97719ccb2ac86fb44fd36f330a6d302f63dddd5326f3ada24f9c05ccac0d9098, flattened into a 294 MiB read-only ext4 with mkfs.ext4 -d |
quoro-data | Fresh 4 GiB sparse ext4 per VM (the overlay upper) |
quoro-config | Written by hand from the grammar in crates/init/README.md |
Fourth disk, serial quoro-work | 12 GiB sparse ext4 holding run.sh, the build context, /var/lib/buildkit and the output tarball |
| Network | A separate bridge, TAP and nftables masquerade table on a private /24, removed afterwards; guest DNS from the config disk |
| Builder VM size | 2 vCPU, 2048 MiB unless noted |
The config disk’s argv was /bin/sh -c '<find the disk whose serial is quoro-work, mount it on /mnt/work, exec /bin/sh /mnt/work/run.sh>'. The environment was the image’s PATH plus BUILDKIT_SETUP_CGROUPV2_ROOT=1, which the BuildKit image sets so that buildkitd moves itself out of the root cgroup. run.sh mounts cgroup2, starts buildkitd --root /mnt/work/buildkit --oci-worker-net=host, waits for buildctl debug workers, runs one buildctl build, prints the result and exits. Our init then reports the exit code in the status region and powers the VM off, and the cloud-hypervisor process exits.
2. What the guest needs
| Requirement | Observed in the guest | Label |
|---|---|---|
| Kernel | 7.2.8 (pinned build) | Confirmed |
| overlayfs | overlay in /proc/filesystems; BuildKit picked the overlayfs snapshotter on the ext4 work disk | Confirmed |
| cgroups | cgroup v2 available; controllers cpuset cpu io memory hugetlb pids once mounted. Our init does not mount it: run.sh did | Confirmed |
| Namespaces | cgroup ipc mnt net pid pid_for_children user uts; runc created its containers with no error | Confirmed |
| User namespaces | max_user_namespaces 7909 (not exercised: the rootful daemon was used) | Confirmed (present) |
| Kernel config | /proc/config.gz is absent, so features were probed by use, not read from a config | Confirmed |
| Executor network | --oci-worker-net=host, so RUN steps share the VM’s single interface; no CNI, no veth, no bridge inside the guest | Confirmed |
| Base-image pulls and Git | Through the VM’s NAT: Docker Hub, ghcr.io, proxy.golang.org, the npm registry and github.com all worked | Confirmed |
| Disk | The guest root stays tiny: / is an overlay whose upper is the data disk. BuildKit state needs its own ext4: 13 MB (tiny), 258 MB (Node), 688 MB (Go), 2.6 GB (Railpack Node) | Confirmed |
| Memory | Peak of the BuildKit cgroup: 650 MB for the Go build at 2048 MiB, 548 MB at 1024 MiB. The Node build succeeded with 1 vCPU and 384 MiB | Confirmed |
The init passes /proc/self/status with Seccomp: 0 to the app. The VM is the isolation boundary here (ADR 0008). Whether runc applies BuildKit’s default seccomp profile inside the VM was not checked.
3. Measurements
Cold means a fresh work disk: empty BuildKit cache, base images pulled. Warm means the same work disk on a new VM with an unchanged context. “Change” means one source file edited. Times are wall-clock for the whole VM, from cloud-hypervisor start to its exit. The guest phase columns are /proc/uptime markers, so boot means kernel plus init up to the app’s first line.
| Build | VM wall (s) | Boot to app (s) | buildkitd ready (s) | Build and export (s) | OCI tarball |
|---|---|---|---|---|---|
Tiny (alpine:3.22 + apk add busybox-extras), cold | 6.69 | 0.29 | 0.38 | 5.65 | 3.8 MB |
| Tiny, warm | 2.64 | 0.31 | 0.27 | 1.74 | 3.8 MB |
Node (node:22-alpine, npm install express), cold | 27.28 | 0.30 | 0.34 | 25.56 | 64 MB |
| Node, warm | 3.08 | 0.28 | 0.21 | 1.91 | 64 MB |
| Node, one source file changed | 4.22 | 0.29 | 0.24 | 2.77 | 64 MB |
| Node, cold, 1 vCPU / 384 MiB | 26.67 | 0.27 | 0.55 | 25.17 | 64 MB |
Go (traefik/whoami v1.12.0 from Git, multi-stage to scratch), cold | 57.12 | 0.30 | 0.29 | 55.85 | 5.4 MB |
| Go, warm | 3.98 | 0.29 | 0.28 | 2.74 | 5.4 MB |
| Go, cold, 2 vCPU / 1024 MiB | 55.83 | 0.26 | 0.31 | 54.56 | 5.4 MB |
| Failing build (step 3 of 4 exits 1), cache warm | 2.69 | 0.31 | 0.20 | 1.80 | none |
| Railpack Node, cold, 1536 MiB, while a compile ran on the host | 126.39 | 0.28 | 0.40 | 9.6 (prepare) + 114.2 | 152 MB |
| Railpack Node, warm | 18.31 | 0.28 | 0.32 | 10.3 (prepare) + 4.8 | 152 MB |
The fixed cost of an ephemeral builder VM is about 0.3 s to boot and 0.2 to 0.4 s for buildkitd to come up. Cold builds are dominated by downloads and compilation. A warm cache on a persistent work disk brings typical rebuilds down to 2 to 4 s. In an earlier variant of the tiny image, a warm cached rebuild produced the same manifest digest as the cold build, because BuildKit reused the cached export.
Import into the engine’s cache with the scratch importer (verify every blob digest, apply the layers with openat2(RESOLVE_IN_ROOT), mkfs.ext4 -d, write image.json):
| Image | Import (s) | Root disk | Boot to first HTTP 200 |
|---|---|---|---|
Tiny (busybox httpd on 8080) | 0.25 | 45 MB | 200 within 1.2 s |
Node (node server.js on 3000) | 4.45 | 258 MB | 200 within 1.2 s |
Go (/whoami on 80) | 0.32 | 52 MB | 200 within 1.2 s |
Railpack Node (npm run start on 3000) | 11.12 | 599 MB | 200 within 1.2 s |
The app VMs used 1 vCPU and 512 MiB, our init, and the config the engine would derive from image.json (entrypoint plus cmd, env, workdir). An earlier tiny image failed with quoro-init: FATAL entrypoint "httpd" not found, because Alpine 3.22’s busybox has no httpd applet. The init’s failed-boot diagnostic surfaced the mistake as designed. The Dockerfile then added busybox-extras.
4. Logs and the failing step
-
Transport: works with the existing path.
buildctl --progress=plainwrites to the app’s stdout and stderr. Our init tags every line (outorerr) on the serial console, interleaved withquoro-init:diagnostics, exactly as for app logs (engine-trait.md section 6). Nothing else is needed to get the log out of the VM. Every step line carries BuildKit’s vertex number (#6 ...). Confirmed. -
Failing step: available in two forms. The plain output ends with the failing vertex, its last log lines and the Dockerfile excerpt:
#6 [3/4] RUN echo "about to fail" && ls /does-not-exist #6 0.187 ls: /does-not-exist: No such file or directory #6 ERROR: process "/bin/sh -c echo \"about to fail\" && ls /does-not-exist" did not complete successfully: exit code: 1 Dockerfile:3 -------------------- 2 | RUN echo "step one ok" 3 | >>> RUN echo "about to fail" && ls /does-not-exist 4 | RUN echo "never reached" --------------------With
--progress=rawjsoneach status update is one JSON object, so the failing step is machine-readable: the vertex’sdigest,name([3/4] RUN ...),started,completedanderror, plus per-vertexlogsentries. The Dockerfile excerpt is still printed as text. Confirmed. -
Result:
buildctlexits 1, the app exits 1, and the init writesexit=1to the status region, so the node sees a failed build without parsing the log.--metadata-filegives the manifest digest (containerimage.digest) on success. Confirmed. -
Output: the shared work disk works. The OCI tarball, the metadata file and the BuildKit cache sit on the work disk, which the node reads after the VM exits. One wrinkle: the init powers off without unmounting that disk, so the host had to mount it with
noload(journal not replayed). The builder’srun.sh, or the init, must unmount it, or the node must rune2fsck -pbefore reading it. Confirmed. -
vsock and virtio-fs were not needed. vsock would allow a structured channel later, but adds a guest agent. virtio-fs needs a
virtiofsdper share (seecloud-hypervisor.md). Hypothesis, not tested.
5. OCI layout compatibility with the engine pipeline
| Check in the engine pipeline | BuildKit type=oci output | Label |
|---|---|---|
Layer media type must be gzip (layer.rs rejects zstd by name) | application/vnd.oci.image.layer.v1.tar+gzip for every layer, Railpack’s included (BuildKit’s default compression is gzip) | Confirmed |
diff_ids.len() == layers.len() | 2/2, 11/11 and others, all equal | Confirmed |
| Manifest selection | index.json with one manifest.v1+json entry, platform: linux/amd64; no nested index because attestations were not requested | Confirmed |
| Blob digests | Every blob matches its digest | Confirmed |
| Config fields | Entrypoint, Cmd, Env, WorkingDir, ExposedPorts mapped by parse_image_config as for registry images | Confirmed |
Source for ImageStore | Missing: registry::pull is the only source, and oci-client speaks HTTPS to a registry. The scratch importer (about 90 lines) reads index.json, follows an index if one is present, verifies blobs, and reuses apply_layer, ext4::build, cache::write_entry and cache::commit unchanged | Confirmed |
6. Rootless and daemonless
- Rootful inside the VM is the recommended mode. Rootless BuildKit protects the host from the daemon. Here the microVM already does that (ADR 0008), and rootless would add user-namespace mapping,
rootlesskitand the--oci-worker-no-process-sandboxtrade-offs for no gain. Rootless was not tested. - Daemonless works. The image ships
buildctl-daemonless.sh. The spike’srun.shfollows the same pattern (startbuildkitd, wait, build, stop), so the builder VM needs no long-lived daemon. Confirmed. - Licenses of what runs inside the builder VM: BuildKit Apache-2.0, runc Apache-2.0 (GitHub repository metadata). They are separate binaries in a separate image, not Rust dependencies, so cargo-deny does not see them. They should still be recorded the way the Cloud Hypervisor binary is. Confirmed (licenses).
- Supply chain: BuildKit’s v0.34.2 release publishes
provenance.json,sbom.jsonand asigstore.jsonbundle per asset, which fits ADR 0027 better than an unpinnedmoby/buildkit:latestwould. The builder image must be pinned by digest the waypins.txtpins Cloud Hypervisor and the kernel. Confirmed (assets exist). Whether the image itself carries attestations was not checked.
7. Railpack and Nixpacks (re-verification for ADR 0060)
| Item | Nixpacks | Railpack | Label |
|---|---|---|---|
| Status | README: “Maintenance Mode: This project is currently in maintenance mode and is not under active development. We recommend using Railpack as a replacement.” | README: “It is the successor to Nixpacks” | Confirmed |
| Latest release | v1.41.0, 2025-10-24; last commit on main 2025-11-24 | v0.43.0, 2026-10-19 (v0.42.0 on 10-15, v0.41.0 on 10-13); last push 2026-11-03 | Confirmed |
| License | MIT (LICENSE, Railway Corp.) | MIT (LICENSE, Railway Corp.) | Confirmed |
| ADR 0080 allowlist | MIT is allowed | MIT is allowed | Confirmed |
| Output | nixpacks build runs docker build (src/nixpacks/builder/docker/docker_image_builder.rs, Command::new("docker")); --out saves the build directory instead of building | railpack prepare writes a JSON plan (--plan-out) and an info file (--info-out); the frontend image ghcr.io/railwayapp/railpack-frontend turns the plan into LLB through buildctl --frontend=gateway.v0 --opt source=... | Confirmed |
| Runs in our builder VM | Not tried: maintenance mode and the Docker dependency rule it out | Yes: railpack prepare (static musl binary on the work disk) plus the gateway frontend built a Node app with no Dockerfile; the image booted and answered 200 | Confirmed |
| Cost | n/a | Images are large (599 MB root disk for an Express app), the cold build pulled builder and runtime images (2.6 GB cache), and prepare took about 10 s even warm | Confirmed |
| Production guidance | n/a | Railway’s docs recommend the frontend over the CLI for production; the version of the plan and the frontend must match; secrets are passed by name in the plan and by value at build time; --build-arg cache-key= isolates caches between tenants | Confirmed |
Railpack fits ADR 0060’s “second Builder implementation” with no change to the builder VM: it is a different frontend and a prepare step in front of the same buildctl call. The ADR 0060 hypothesis that Nixpacks is in maintenance and Railpack is its successor is now confirmed.
Versions checked
| Component | Version | Source |
|---|---|---|
| BuildKit | v0.34.2 (2026-11-03), image digest above | GitHub releases API, Docker Hub via crane digest |
| Cloud Hypervisor | v54.0 (pinned) | crates/engine/pins.txt |
| Guest kernel | ch-release-v7.2.8-20261009 (pinned) | crates/engine/pins.txt |
| crane (used only to flatten the builder image) | v0.22.1 | GitHub releases API |
| Railpack | v0.43.0, frontend ghcr.io/railwayapp/railpack-frontend:v0.43.0@sha256:355ef61fae6f2551103cd8b10c710cb07167dd353d561f15c69b8d7ac1c676cc | GitHub releases and packages API |
| Nixpacks | v1.41.0 | GitHub releases API |
Implications for Quoro
-
ADR 0060: the hypothesis holds. Dockerfile builds by BuildKit inside an ephemeral builder microVM work under Cloud Hypervisor with our kernel and init, and the output feeds the OCI-to-rootfs path once
ImageStoregets a local-layout source. Railpack is confirmed as Nixpacks’ successor and runs in the same VM. -
Where builds run. On the node that will run the app, in an ephemeral builder microVM created through the Engine trait, one VM per build. The layout is produced and imported on the same node, so no registry is needed and nothing is pushed anywhere. That matches ADR 0008 (no Docker on nodes) and ADR 0014 (logs stay on the node). The cost is that every node needs builder capacity (about 2 vCPU, 1 to 2 GiB and a cache disk while a build runs), and a build cache is per node.
-
Engine contract gaps (an additive Engine-trait change, standalone PR per ADR 0036):
prepare_imagefrom a local OCI layout: anImageSource::Layout { path }next to the registry reference, or a separateimport_layoutmethod, both cached by manifest digest.- An extra writable disk on
VmSpecfor the build, identified by serial (for examplequoro-work).VolumeAttachis rejected by the Cloud Hypervisor backend until M5a (“volumes ship in M5a”), so either a dedicated “scratch disk” field or an early, node-local subset of volumes. - Either
cgroup2mounted by the init (a small init change that is harmless for apps) or a documented builder entrypoint that mounts it. Mounting it in the init is simpler and also helps app images that expect it. - Unmount, or
syncfsplus remount read-only, extra disks before power-off, so the node can read the work disk without journal recovery.
-
Builder interface (node agent). A trait the node agent owns, not the Engine. It uses the Engine to run the VM:
#![allow(unused)] fn main() { pub trait Builder { /// Runs one build; events stream while it runs. fn build(&self, spec: &BuildSpec) -> impl Future<Output = Result<BuildHandle, BuildError>> + Send; } pub struct BuildSpec { pub build_id: BuildId, // bld_<ULID> pub source: BuildSource, // Git { url, ref_, commit } | Upload { archive } pub frontend: Frontend, // Dockerfile { path, target } | Railpack (later) pub context_dir: String, // subdirectory of the repo pub build_args: Vec<(String, String)>, pub secrets: Vec<SecretRef>, // names; values decrypted on the node pub limits: BuildLimits, // vcpus, memory_mb, timeout, cache_gb, output_gb pub cache_key: String, // per app: isolates caches } pub enum BuildEvent { Phase(BuildPhase), Line { stream, vertex: Option<u32>, text }, Step(StepStatus) } pub struct BuildOutput { pub layout: PathBuf, pub manifest_digest: ImageDigest } pub struct BuildFailure { pub phase: BuildPhase, pub step: Option<FailedStep>, pub excerpt: Vec<String> } pub struct FailedStep { pub vertex: u32, pub name: String, pub error: String, pub dockerfile_line: Option<u32> } }Implementations:
DockerfileBuilder(M4) andRailpackBuilder(later). Both run the same builder VM and differ only in the frontend options and apreparestep. AFakeBuilderfor tests mirrorsFakeEngine. -
Git source. BuildKit clones
https://...git#refcontexts itself, tested with a public repository, which avoids a host-side clone. A host-side clone onto the work disk is still the better default: the node resolves the ref to a commit SHA and records it, Quoro handles Git credentials (deploy keys, tokens) on the node instead of inside the build, and the same checkout feeds Railpack’sprepare. Private-repository authentication is out of this spike. -
Build records and states (control plane, replicated).
buildswithid,app_id,source(URL, ref, resolved commit),frontend,status(queued,preparing(VM and checkout),building,exporting,importing,succeeded,failed,cancelled),phaseon failure,failed_step(vertex, name, error, Dockerfile line),image_digest, timings,created_by,trigger(api,push,redeploy). A successful build starts a deployment withtrigger: build, whichopenapi.yamlalready lists, as it already listsbuildas a deployment phase. -
Build logs over SSE (ADR 0057, ADR 0014). The log stays on the node, next to the build’s work directory, as tagged lines parsed into
BuildEvents. The panel reads it through a newstream_openkindbuild_logs(paramsbuild_id,tail) and an SSE endpointGET /api/v1/builds/{id}/logs/stream, plus a paginatedGET .../logsfor finished builds. Only the status and the failing step (a few lines) are replicated, so the panel shows the failure even when the node is down. Use--progress=rawjsonand keep the plain lines as the human log: rawjson gives the failing vertex structurally and plain gives the Dockerfile excerpt. -
Cache and limits. One persistent work disk per app per node (BuildKit cache plus output), sized by
cache_gb, pruned withbuildctl prune --keep-storageat the end of each build, and deleted with the app. Per build: vCPU and memory of the builder VM, a wall-clock timeout (on expiry, stop the VM and mark the buildfailedwith phasebuilding), an output-size cap, and one concurrent build per node by default. Builds count against the node’s capacity like any VM. -
Audit and grants. Starting, cancelling and reading the logs of a build are API operations with scopes (
build:writeandbuild:read, orapp:deployandapp:logs), recorded in the audit log like deployments (ADR 0031).
Open risks and unknowns
- Untrusted Dockerfiles on a production node. The VM is the boundary, but the builder VM has outbound network to fetch bases and dependencies. It should get its own isolation group (ADR 0084) with egress only, no route to app VMs or the node API.
- Secrets in builds. Build args end up in image history. BuildKit
--secretneeds the values inside the VM, so the contract must say how they get there (config disk, work disk, or a later channel) and that they are never logged. - Disk pressure. Railpack’s cold cache was 2.6 GB for one small app. Per-app caches on every node can fill a small homelab disk, so the cap and prune policy must be in the contract, and the node must watch its free space.
- Reproducibility. Manifests carry
org.opencontainers.image.created, so rebuilding after a cache prune changes the digest.SOURCE_DATE_EPOCHcan be set if build reproducibility is wanted (ADR 0027 covers Quoro’s own artifacts, not app images). - Base-image rate limits. Every node pulls bases from Docker Hub anonymously, with no shared mirror. Registry credentials or a pull-through cache are later items.
- Architecture. Only
linux/amd64was built. Multi-platform builds (QEMU binfmt inside the VM,buildkit-qemu-*ships in the image) were not tried. - Builder image updates. The builder rootfs is about 300 MB and must be pinned, verified and refreshed like the kernel. The node prepares it with the normal
prepare_imagepath, since it is an ordinary OCI image. - Nested-virt numbers only. Real hosts should be faster. The relative costs (0.3 s boot, cold downloads dominating, 2 to 4 s warm rebuilds) are what the plan can rely on.
- Not exercised: rootless BuildKit, seccomp inside the VM, private Git and private registries, cancellation mid-build (expected to be “stop the VM”), concurrent builds on one node, and Nixpacks’
--outDockerfile path.
Sources
- BuildKit v0.34.2 release and assets: https://github.com/moby/buildkit/releases/tag/v0.34.2 (accessed 2026-11-03)
- BuildKit license (Apache-2.0): https://github.com/moby/buildkit (repository metadata, accessed 2026-11-03)
- runc license (Apache-2.0): https://github.com/opencontainers/runc (repository metadata, accessed 2026-11-03)
- Nixpacks README with the maintenance-mode notice: https://github.com/railwayapp/nixpacks (accessed 2026-11-03)
- Nixpacks releases: https://github.com/railwayapp/nixpacks/releases (accessed 2026-11-03)
- Nixpacks Docker builder: https://github.com/railwayapp/nixpacks/blob/main/src/nixpacks/builder/docker/docker_image_builder.rs (accessed 2026-11-03)
- Nixpacks CLI (
build,--out): https://nixpacks.com/docs/cli (accessed 2026-11-03) - Railpack README and license: https://github.com/railwayapp/railpack (accessed 2026-11-03)
- Railpack releases: https://github.com/railwayapp/railpack/releases (accessed 2026-11-03)
- Railpack in production (frontend, plan,
buildctlinvocation): https://railpack.com/platforms/running-railpack-in-production (accessed 2026-11-03) - Railpack frontend image versions: https://github.com/orgs/railwayapp/packages/container/package/railpack-frontend (accessed 2026-11-03)
- Engine code read for the pipeline:
crates/engine/src/image/(mod.rs,registry.rs,layer.rs,oci.rs,cache.rs,ext4.rs),crates/engine/src/ch/vmconfig.rs,crates/engine/src/ch/engine.rs - Init behaviour:
crates/init/README.md,crates/init/src/guest/mounts.rs,crates/init/src/guest/console.rs