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

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:

  1. 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).
  2. 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).
  3. Every grant is bounded in time. A TTL is mandatory; there is no grant without an expiry (D-0030).
  4. 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).
  5. 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).
  6. 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).
  7. 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).
  8. 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).

IdentityCredentialLifetimeWhat it authorizesStored as
human (usr_)panel session cookie24 h absolute expiry, no sliding renewalthe panel and its API calls, as that human’s rolesessions.token_hash
human (usr_)personal API token (tok_)optional expires_at; valid until expiry or revoked_atthe HTTP API and the CLI, as that human’s roleapi_tokens.token_hash
agent (agt_)agent key (akey_)long-lived until revoked_atnothing else: it may only mint access tokensagent_keys.key_hash
agent (agt_)access token (atk_)5 minutesAPI and MCP calls, subject to the agent’s grantsaccess_tokens.token_hash
node (nod_)node credential (enrollment key pair, njt_ join token at enrollment)as long as the node is enrolled and not removedthe node-agent session only (x-quoro-scope: node)nodes.public_key; node_join_tokens.token_hash
systemnone—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 with human_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 with human_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.disabled is read as part of the identity lookup on every request, before any grant is consulted, so the next call after disabled is set answers forbidden with reason agent_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 node in 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”).

RoleHolds
ownerevery scope
operatorevery scope except user:write
viewerevery :read scope plus app:logs

Consequences of the bundles:

  • A viewer can 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 operator cannot create or change human users; that is user:write and owner-only (README.md, “Scopes”).
  • Every role holds user:read, so an operator or viewer can list users and turn a user id (owner_user_id, on_behalf_of, decided_by, created_by) into a name. user:read is human-only: agents never list users.
  • app:terminal and vm:console are in the owner and operator bundles and not in viewer, because neither is a :read scope (ADR 0114). A viewer can 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).
  • role is 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:

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 (section 7.6)
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 secretshuman-only
user:readList human users, to resolve user ids to nameshuman-only
user:writeManage human users and their roleshuman-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_failed at the grant endpoint.
  • An incident grant can never contain a destructive scope. webhook.wake.grant_scopes is 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 kind approval, 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), not forbidden: 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 scopes contains a human-only scope is refused at creation with validation_failed, and a hypothetical row that contained one is refused at call time with reason human_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:read is human-only so an agent never reads the list of human identities and their emails. app:terminal is 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:decide is 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 by prj_ id; apps and nodes are matched by id (README.md, “Resource selectors”).
  • An app matches projects through its own project_id’s slug, so project=blog covers every app in the blog project.
  • {"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 with selector_mismatch.

Matching per target type:

Target typeMatched by
appapps entry equal to its id, or projects entry equal to the slug of its project, or {"all": true}
VMapps 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}
deploymentthe app it belongs to (as above), or {"all": true}
buildthe app it belongs to (as above), or {"all": true}
interactive sessionits VM (as above; a terminal session’s VM is an instance of its app), or {"all": true}
domainthe app it belongs to (as above), or {"all": true}
projectprojects entry equal to its slug, or {"all": true}
nodenodes entry equal to its id, or {"all": true}
incidentits subject (resolved as the subject’s own type above), or {"all": true}
approval requestits 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:read with selector {"projects":["blog"]} may read the event stream, but only records whose subject or project belongs to blog (constraint below).
  • Results are filtered, never refused. A list call under a selector returns the matching items; it does not answer forbidden because 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 by project_id when set).
  • Audit records are filtered by target_type/target_id and project_id, and by actor_id when 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:

FieldTypeRequiredMeaning
idgrt_ idyesidentity of the grant
agent_idagt_ idyesthe agent it empowers
kindstandard | incident | approvalyeswhy the grant exists (section 6.1)
scopesarray of scope stringsyeswhat it allows; never human-only, destructive only for approval
selectorselector objectyeswhere it applies (section 5)
expires_atRFC 3339 timestampyesTTL is mandatory; no grant is open-ended
max_usesinteger or nullnomaximum authorized mutating calls; null means unlimited within the TTL
usesintegeryeshow many mutating calls it has authorized
statusactive | expired | revoked | exhaustedyeslifecycle state (section 6.2)
incident_idinc_ id or nullfor kind=incidentthe incident the grant was issued for
approval_idapr_ id or nullfor kind=approvalthe approval request that produced it
on_behalf_ofusr_ idyesthe human whose authority is being lent
created_by_typeuser | systemyeswho issued it
created_by_ididyesthat human or subsystem
reasontextyeswhy it was issued, in words
created_atRFC 3339 timestampyeswhen it was issued
revoked_atRFC 3339 timestamp or nullnowhen a human or the system revoked it
revoked_byid or nullnowho revoked it

The field names match the shared entity model and the grants table in db-schema.sql.

6.1 Kinds

KindIssued byTypical TTLMay carry destructive scopesEnds when
standarda human with grant:writechosen by that humannoTTL, max uses, or revocation
incidentthe system, when an incident openswake.grant_ttl_s (default 3600 s)noTTL, max uses, the incident resolving, or revocation
approvalthe system, on a human’s approval10 minutesyes, exactly oneone 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 (event grant.created).
  • The transition to expired, revoked or exhausted emits grant.expired or grant.revoked; grant.expired carries reason ttl or max_uses in its data (README.md, “Event types”).
  • revoked and expired are 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).
  • exhausted is computed from uses >= max_uses, so a grant with max_uses 1 that authorized one call is exhausted even 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_at is expired and authorizes nothing, even for a read (D-0030).

6.4 Maximum uses

  • max_uses counts authorized mutating calls, that is calls that pass authorization and are executed with result ok.
  • Reads do not consume uses. An agent can poll get_app under a one-use grant as often as it likes (D-0030).
  • A refused call does not consume a use; a failed call (result error after 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_uses of 1 is the shape of every approval grant (section 9).

6.5 Revocation

  • A human with grant:write revokes 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.revoked and 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 standard grant (section 4.1);
  • a missing expires_at, an expires_at in the past, or a max_uses below 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:

ReasonStatusWhen
human_only403 forbiddenthe call needs a scope that can never be granted to an agent
agent_disabled403 forbiddenthe agent is disabled
scope_missing403 forbiddenno active grant contains the required scope, or the human’s role lacks it
selector_mismatch403 forbiddena grant has the scope but its selector does not cover the target
grant_expired403 forbiddenthe only grants with the scope have passed expires_at
grant_revoked403 forbiddenthe only grants with the scope were revoked
grant_exhausted403 forbiddenthe only grants with the scope have no uses left
approval_required403 approval_requireda 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:

ConditionCheckedclose_reason
the human is disabled or deleted, or the agent is disabledon change, and at least every 30 saccess_revoked
the human’s role no longer holds the scopeon change, and at least every 30 saccess_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 VMon change, and at least every 30 saccess_revoked
the panel session cookie, the personal API token or the agent access token that opened it expires or is revokedat its expirycredential_expired
no input from the client for 15 minutes (a console has no input: no WebSocket message from the client)continuouslyidle_timeout
the session has been open for 8 hourscontinuouslymax_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):

FieldTypeMeaning
agent_idagt_ idthe agent the webhook wakes
grant_scopesarray of scope stringsthe scopes the incident grant will carry; non-destructive only
grant_ttl_sinteger, default 3600lifetime of the incident grant
grant_max_usesinteger or nulloptional cap on how many mutating calls the incident grant allows
  • grant_scopes is validated when the webhook is saved: a human-only scope or a destructive scope receives validation_failed. A webhook therefore cannot be used to route around section 4.1 (D-0032).
  • webhook:write is human-only, so the agent that will be woken cannot configure its own future grant (README.md, “Scopes”).
  • created_by is the human who saved the webhook and becomes on_behalf_of of every incident grant the webhook causes, which is how “on whose behalf” is answered for a wake nobody was present for (D-0029).
  • wake can be changed with PATCH /api/v1/webhooks/{webhook_id} but not removed. To stop a webhook from waking an agent, delete the webhook and create it again without wake; the new webhook gets a new signing secret.

8.2 When the grant is issued

  1. A detection (node agent report, health check, certificate state) opens an incident with subject {type, id} (D-0032).
  2. incident.opened is emitted.
  3. For each enabled webhook whose event_types matches and whose selector covers the incident’s subject, the system delivers the event and, if the webhook has wake, issues an incident grant:
    • kind incident;
    • 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_type system, reason naming the incident and the webhook.
  4. grant.created is emitted and the issuance is audited with actor_type system.
  5. The delivery body is the incident.opened event with data.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 same data.wake. A delivery whose webhook issued no grant (no wake, or the incident was resolved before the delivery) carries no data.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}/resolve revokes every incident grant with that incident_id, emitting grant.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 revoked so 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, then restart_app with a justification naming the incident. Both pass; only the restart consumes a use, and max_uses is null so the hour is the only bound.
  • The app comes back; the agent calls resolve_incident (it holds incident:resolve because the webhook declared it; without it, the agent reports and a human resolves). On resolve, the grant becomes revoked.

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:

  1. The agent attempts the destructive call. For example DELETE /api/v1/apps/{app_id} (scope app:delete) or the delete_app MCP tool (D-0031, README.md “MCP tools”).
  2. The service refuses with approval_required (403) carrying scope and target. Nothing is deleted, and the refusal is audited with result refused, result_detail approval_required (D-0030).
  3. The agent files a request: POST /api/v1/approvals with approval:create, body {scope, target, operation, params, justification, incident_id?}. The service computes params_hash as the SHA-256 hex of the RFC 8785 canonical serialization of params, stores status pending and expires_at = now + 1 hour, and emits approval.requested (D-0030).
  4. 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”).
  5. On approve the system, in one transaction:
    • sets status approved, decided_by, decided_at, optional decision_note;
    • creates an approval grant: kind approval, scopes = exactly the one requested scope, selector = that target only, max_uses 1, expires_at = now + 10 minutes, approval_id set, on_behalf_of = the deciding human;
    • emits approval.approved and grant.created;
    • audits the decision with actor_type user.
  6. The agent repeats the same call with the same params. Authorization matches the approval grant by target and params_hash; the call executes; uses reaches 1; the grant becomes exhausted and emits grant.expired with reason max_uses (section 6.2). The audit record carries approval_id and grant_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}/deny with approval:decide and a note; status denied, approval.denied, no grant. The agent’s retry answers approval_required (D-0030).
  • Expired: a pending request past its expires_at (default 1 hour) becomes expired and emits approval.expired; no grant is created and a late decision answers conflict (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: owner and operator (README.md, “Scopes”).
  • Never the agent that filed it, never another agent, and never a caller reaching the endpoint through MCP: approval:decide is human-only, so an MCP client cannot call the decide operation at all (D-0030).
  • The deciding human is recorded as decided_by and as the approval grant’s on_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 justification string and an optional incident_id. They are the MCP equivalents of the Quoro-Justification and Quoro-Incident headers (README.md, “MCP tools”; D-0031). A mutating tool call without justification is validation_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:decide has 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-scope and, 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).
  • rmcp is 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.

QuestionFieldWhere it comes from
who actedactor_type, actor_idthe resolved identity
which modelagent_modelagents.model, agents only
on behalf of whomon_behalf_ofthe grant’s on_behalf_of, or the deciding human for an approval
whyjustificationQuoro-Justification or the MCP justification argument, required for mutating agent calls
what woke ittrigger_event_id, incident_idthe incident and the event that opened it
what it didoperation, scope, target_type, target_id, paramsthe call
what happenedresult, result_detail, change_seqthe outcome
which authoritygrant_id, approval_idthe grant that allowed it, the approval that produced it

Per identity type:

Identityactor_type / actor_idagent_modelon_behalf_of
humanuser / usr_omittedomitted (a human acting for themselves); set to the human who decided, on an approval decision record
agentagent / agt_the agent’s modelthe matched grant’s on_behalf_of
nodenode / nod_omittedomitted
systemsystem / the subsystem nameomittedset 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 system action that a webhook caused still names the human who created the webhook, through the incident grant and trigger_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) in application/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 refused with result_detail rate_limited when the identity is known, so a burst that an agent produced is visible in the log (D-0031).
  • POST /api/v1/auth/password is 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.

#IdentityAuthorityCallResultAudit result_detail
1agent agt_Aincident grant, app:read + app:restart, selector {"apps":["app_1"]}restart_app on app_1ok; app restartsok, with change_seq, incident_id, grant_id
2agent agt_Astandard grant, app:read onlyrestart_app on app_1403 forbiddenscope_missing
3agent agt_Astandard grant, app:restart, expires_at 10 minutes agorestart_app on app_1403 forbiddengrant_expired
4agent agt_Astandard grant, app:restart, revoked by a human a second agorestart_app on app_1403 forbiddengrant_revoked
5agent agt_Astandard grant, app:restart, selector {"projects":["blog"]}restart_app on an app in project shop403 forbiddenselector_mismatch
6agent agt_Astandard grant, app:restart, max_uses 1, one use already spentrestart_app on app_1403 forbiddengrant_exhausted
7agent agt_Aany grant (even {"all": true})delete_app on app_1403 approval_required, body carries scope and targetapproval_required
8agent agt_A, after the human approvesapproval grant, app:delete, selector {"apps":["app_1"]}, max_uses 1, TTL 10 mindelete_app on app_1 with the approved paramsok; app deleted; grant exhaustedok, with approval_id and grant_id
9agent agt_Agrant minus the destructive scopedelete_app on app_1, unchanged params, expired approval grant403 approval_required againapproval_required
10agent agt_Aany grant containing a human-only scope (malformed row)POST /api/v1/grants (grant:write)403 forbiddenhuman_only
11agent agt_Avalid grant, but the agent was disabled a moment agoget_app on app_1403 forbiddenagent_disabled
12human usr_H, role viewerrole bundlerestart_app on app_1403 forbiddenscope_missing
13human usr_H, role operatorrole bundledelete_app on app_1ok; no approval needed for a humanok
14agent agt_Aincident grant, event:read, selector {"projects":["blog"]}list_eventsok; only blog subjects returnedok
15agent agt_Astandard grant, app:restart, TTL 1 h, max_uses nullget_app on app_1 twenty times, then restart_appall ok; only the restart consumes a useok on each
16agent agt_Astandard grant, app:terminal (malformed row), selector {"all": true}POST /api/v1/apps/{app_id}/terminal-sessions403 forbiddenhuman_only
17agent agt_Astandard grant, vm:console, selector {"apps":["app_1"]}POST /api/v1/vms/{vm_id}/console-sessions on app_1’s VM201 with a ticket; the session ends when the access token expiresok; a later session.end with credential_expired
18human usr_H, role viewerrole bundlePOST /api/v1/vms/{vm_id}/console-sessions403 forbiddenscope_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_hash in 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).