Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

FileWhat it fixesMain consumers
openapi.yamlHTTP 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, webhookscontrol-plane, panel, mcp, installer
events.schema.jsonJSON Schema (draft 2020-12) of the event envelope and every event typecontrol-plane, node-agent, panel, mcp, webhook receivers
node-agent-protocol.mdEnrollment, 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.mdAudit record format, hash chaining, verification, off-site copy with object lockcontrol-plane, mcp, panel
permission-model.mdIdentities, scopes, resource selectors, grants (TTL, max uses), incident grants, JIT approvalcontrol-plane, mcp, panel
db-schema.sqlControl-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_versioncontrol-plane
engine-trait.mdThe 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; run python3 scripts/check_contracts.py): events.schema.json parses as JSON, its event-type list equals the Event types table, and every example’s type is in that list and carries the schema’s required top-level keys; db-schema.sql loads into an in-memory SQLite database, the migrations in migrations/ apply in order on top of it with schema_version ending at one plus their count, and its tables equal the Database tables list; in openapi.yaml (checked line by line, without a YAML parser) the document declares OpenAPI 3.1, every operation has x-quoro-scope with a scope from the table or a special value, every response has a description or a $ref, every x-quoro-mcp-tool matches 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.yaml against OpenAPI 3.1 and of events.schema.json against 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 quoro binary (D-0012): node, witness, edge. They combine (for example witness+edge, D-0018).
  • Control-plane status of a node: primary (single writer and source of truth) or replica (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.

ResourcePrefixNotes
clustercls_One per installation
nodenod_A machine running the binary
node join tokennjt_One-time enrollment token
projectprj_Groups apps; also has a unique slug ([a-z0-9-]{1,40}) used in selectors
VMvm_One microVM managed by the engine (M1). An app’s instances are VMs whose app_id is set
appapp_A deployable service; runs as one microVM per instance (D-0008)
deploymentdep_One rollout of an image to an app
buildbld_One image build of an app’s Git source, run on the app’s node (ADR 0117, ADR 0118)
domaindom_A hostname routed to an app
volumevol_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
agentagt_An AI identity: name, model, owner (D-0029)
agent keyakey_Long-lived agent credential that authorizes nothing by itself
access token (agent)atk_Short-lived token minted from an agent key
grantgrt_Scope + selector + TTL (+ max uses) (D-0030)
approval requestapr_JIT approval for a destructive action (D-0030)
incidentinc_Opened by a detection; scopes incident grants (D-0030, D-0032)
eventevt_One event; also has a numeric seq
webhookwhk_Subscription that delivers events
webhook deliverywhd_One delivery attempt record
providerprv_Credentials of one external provider (D-0051)
leaselse_Lease on an app or on the primary status (D-0019)
requestreq_Correlation id of one API or MCP call
interactive sessiontrm_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_state and lease messages, and nodes pin it at enrollment. Rotation uses the node-agent message signing_key_rotate, signed by the old key.
  • cluster data-encryption key: 256-bit key for XChaCha20-Poly1305. It encrypts every *_enc column; a *_enc BLOB 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_id set) or stand alone (M1 lifecycle, app_id null). 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 stateful or 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). Only none is implemented before M5a; the field exists from M0.
  • A domain belongs to one app and has a TLS mode: acme (public-IP hosts) or tunnel (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 with trigger: 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. VM observed_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 policy none/cold: never expires when the arbiter is unreachable; ends by lease_revoke or an operator declaring the node lost) or expiring (HA policy warm/hot, from M5b: held -> uncertain -> expired, self-fence at expired; 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:

ScopeAllowsFlags
cluster:readRead cluster info, version, log position
node:readList and read nodes and node metrics
node:enrollCreate node join tokenshuman-only
node:removeRemove a node from the clusterdestructive, human-only
node:fence_ackAcknowledge a node’s failed fence after verifying its VMs are stopped, so its leases may be granted againhuman-only
project:readList and read projects
project:writeCreate and update projects
project:deleteDelete an empty projectdestructive
vm:readList and read VMs
vm:createCreate a VM
vm:startStart a VM
vm:stopStop a VM
vm:deleteDelete a VM and its disksdestructive
vm:consoleRead a VM’s serial console, live and its recent output (ADR 0116)
app:readList and read apps, their Git sources, builds, deployments, domains, env var names and app metrics
app:createCreate an app
app:updateChange app settings, the Git source, env vars, domains
app:restartRestart an app’s instances
app:redeployRoll out the current image again
app:deployDeploy a new image (new deployment); start and cancel builds, whose success deploys
app:startStart a stopped app
app:stopStop an app
app:deleteDelete an app and its datadestructive
app:logsRead app logs and build logs, including live streams
app:terminalOpen an interactive terminal in an app’s VM (ADR 0113, ADR 0114)human-only
agent:readList and read agents
agent:writeCreate, update, disable agents; rotate agent keyshuman-only
grant:readList and read grants
grant:writeCreate and revoke grantshuman-only
approval:readList and read approval requests
approval:createFile an approval request
approval:decideApprove or deny an approval requesthuman-only
incident:readList and read incidents
incident:resolveResolve an incident
audit:readRead the audit log and run verification; humans also fetch session transcripts
event:readRead events and the event stream
webhook:readList and read webhooks and deliveries
webhook:writeCreate, update, delete, test webhookshuman-only
provider:manageCreate, list, read, delete providers and rotate their secrets; map nodes to tunnel providershuman-only
user:readList human users, to resolve user ids to nameshuman-only
user:writeManage human users and their roleshuman-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 except user:write), viewer (every :read scope plus app:logs). user:read is a :read scope, so every role holds it. app:terminal and vm:console are not :read scopes, so a viewer holds 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 need node:read and app metrics app: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:

TypeEmitted whenD-0032 example
node.enrolledA node completed enrollment
node.connectedA node’s session started
node.disconnectedA node’s session ended or missed heartbeats
node.fencedA node self-fenced stateful apps after losing a lease, every bound VM confirmed stopped
node.fence_failedA node could not confirm a fence stopped its stateful VMs; it marked itself failed
node.disk_lowA node’s free disk fell below its thresholddisk full
vm.createdA VM was created
vm.startedA VM reached running
vm.stoppedA VM stopped on request
vm.crashedA VM exited without being asked to stopVM down
vm.deletedA VM was deleted
app.createdAn app was created
app.updatedAn app’s settings changed
app.deletedAn app was deleted
app.downAn app’s health check failed past its thresholdVM down
app.upAn app recovered
app.restartedAn app’s instances were restarted
deployment.startedA deployment began
deployment.succeededA deployment’s instances are healthy
deployment.failedA deployment failed (image pull, rootfs, boot, health)
build.startedA build was created and queued
build.succeededA build produced its image; the deployment it starts follows
build.failedA build failed (source checkout, a build step, export, import, timeout)build failed
build.cancelledA build was cancelled before it finished
certificate.issuedA TLS certificate was obtained or renewed
certificate.expiringA certificate expires within its warning windowcertificate expiring
certificate.failedObtaining or renewing a certificate failed
grant.createdA grant was issued
grant.revokedA grant was revoked
grant.expiredA grant’s TTL passed or its uses ran out
approval.requestedAn agent filed an approval request
approval.approvedA human approved a request
approval.deniedA human denied a request
approval.expiredA request expired undecided
incident.openedA detection opened an incident
incident.resolvedAn incident was resolved
audit.chain_brokenAudit 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-Key on every POST that 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 by POST /api/v1/setup only (D-0054).
  • Quoro-Incident: the incident id the call belongs to, when an agent acts on an incident.
  • Last-Event-ID on 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 given seq. 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-Id and 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.

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).

ToolEndpointScope
get_clusterGET /api/v1/clustercluster:read
list_nodesGET /api/v1/nodesnode:read
get_nodeGET /api/v1/nodes/{node_id}node:read
list_projectsGET /api/v1/projectsproject:read
list_appsGET /api/v1/appsapp:read
get_appGET /api/v1/apps/{app_id}app:read
list_deploymentsGET /api/v1/apps/{app_id}/deploymentsapp:read
get_deploymentGET /api/v1/deployments/{deployment_id}app:read
get_app_logsGET /api/v1/apps/{app_id}/logsapp:logs
list_eventsGET /api/v1/eventsevent:read
list_incidentsGET /api/v1/incidentsincident:read
get_incidentGET /api/v1/incidents/{incident_id}incident:read
resolve_incidentPOST /api/v1/incidents/{incident_id}/resolveincident:resolve
restart_appPOST /api/v1/apps/{app_id}/restartapp:restart
redeploy_appPOST /api/v1/apps/{app_id}/redeployapp:redeploy
delete_appDELETE /api/v1/apps/{app_id}app:delete (destructive)
request_approvalPOST /api/v1/approvalsapproval:create
get_approvalGET /api/v1/approvals/{approval_id}approval:read
list_auditGET /api/v1/auditaudit: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

ElementRecommendedOpen alternative
Cluster key custody on promotionReplicate both keys wrapped to each replica’s node keyRe-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).