openapi: 3.1.0
info:
  title: Quoro API
  version: 0.1.0
  summary: HTTP API of the Quoro control plane.
  description: |
    HTTP API for the M1-M4 surface of Quoro, a self-hosted tool that unifies a
    hypervisor (Cloud Hypervisor + KVM, one microVM per app) with application
    deployment. One binary serves this API on every node: the primary is the
    single writer and the source of truth, replicas hold a copy of the change log
    and forward writes to the primary (D-0012).

    This hand-written file is the frozen source of truth: the frozen M0 contract
    (D-0033) and the reference the control plane's utoipa-generated document is
    diffed against in CI, where any semantic difference fails the build; the
    TypeScript client for the panel is generated from this file with orval
    (D-0048). Every operation carries `x-quoro-scope` and, where an MCP tool
    exposes it, `x-quoro-mcp-tool` (D-0031, D-0033). Mutating calls made by an
    agent carry a justification header that is recorded in the append-only audit
    log (D-0031).

    Vocabulary, identifiers, statuses, scopes, event types, error codes and
    headers are fixed by `docs/contracts/README.md`; the entity fields are fixed
    by the shared entity model used by every contract file. Timestamps are
    RFC 3339 UTC strings. Ids are `<prefix>_<ULID>` strings with a Crockford
    base32 ULID (26 characters). Telemetry such as app logs and metrics stays on
    each node and is read on demand through the control plane; only state is
    replicated (D-0014).
  license:
    name: AGPL-3.0-only
    identifier: AGPL-3.0-only
servers:
  - url: /
    description: |
      The API as served by the node the client talks to. A write that reaches a
      replica is forwarded to the primary; if the replica cannot forward it, the
      call fails with `not_primary` (503) (D-0012).
security:
  - bearerAuth: []
  - sessionCookie: []
tags:
  - name: health
    description: Liveness and first-run setup.
  - name: auth
    description: Human sessions, agent token minting and the current identity.
  - name: tokens
    description: Personal API tokens of a human user.
  - name: cluster
    description: Cluster identity, version and change-log position.
  - name: nodes
    description: Nodes of the cluster, enrollment and the node-agent session.
  - name: projects
    description: Projects, the container of apps.
  - name: vms
    description: MicroVM lifecycle.
  - name: apps
    description: Apps, their settings, environment variables and lifecycle.
  - name: deployments
    description: Rollouts of an image to an app.
  - name: domains
    description: Hostnames routed to an app and their TLS state.
  - name: logs
    description: App logs read on demand from the node that runs the app.
  - name: users
    description: Human identities and their roles.
  - name: agents
    description: AI identities and their long-lived keys.
  - name: grants
    description: Scoped, time-limited authority of an agent.
  - name: approvals
    description: Just-in-time approval of destructive actions.
  - name: incidents
    description: Incidents opened by a detection and closed by a human or agent.
  - name: audit
    description: The append-only, hash-chained audit log and its verification.
  - name: events
    description: The event log and the event stream.
  - name: webhooks
    description: Event subscriptions that deliver to an external receiver.
  - name: providers
    description: Credentials for external providers (DNS, tunnels, load balancers, object storage).
  - name: builds
    description: Image builds of an app's Git source, run on its node in a builder microVM.
  - name: metrics
    description: Node and app CPU and memory read on demand from the nodes.
  - name: interactive
    description: Web terminal and read-only serial console sessions over WebSocket.
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        A human API token (`tok_...`) or an agent access token (`atk_...`).
        Agent access tokens are minted from an agent key at
        `POST /api/v1/auth/agent-token` and live 5 minutes (D-0030).
    sessionCookie:
      type: apiKey
      in: cookie
      name: quoro_session
      description: |
        Session cookie set by `POST /api/v1/auth/login`. The web panel uses this
        scheme; agents never do.
    agentKey:
      type: http
      scheme: bearer
      description: |
        An agent key (`akey_...`). It authorizes nothing by itself; it is only
        accepted to mint a short-lived access token (D-0030).
    nodeCredential:
      type: http
      scheme: bearer
      description: |
        The node session credential returned by enrollment. It authenticates the
        node-agent session only (see `docs/contracts/node-agent-protocol.md`).
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-chosen key for a create. A repeated call with the same key and the
        same body returns the original result instead of creating a second
        resource (D-0031 records the call either way).
      schema:
        type: string
        minLength: 1
        maxLength: 128
    QuoroJustification:
      name: Quoro-Justification
      in: header
      required: false
      description: |
        The agent's stated reason for the call. Required when the caller is an
        agent; it is recorded in the audit log (D-0031). Human callers may omit
        it.
      schema:
        type: string
        minLength: 1
        maxLength: 2000
    QuoroIncident:
      name: Quoro-Incident
      in: header
      required: false
      description: |
        The incident (`inc_...`) the call belongs to, when an agent acts on an
        incident (D-0032). Recorded in the audit log and used to evaluate
        incident grants (D-0030).
      schema:
        type: string
        pattern: '^inc_[0-9A-HJKMNP-TV-Z]{26}$'
    QuoroSetupToken:
      name: Quoro-Setup-Token
      in: header
      required: true
      description: |
        One-time token printed by the installer or by `quoro setup-token`. It is
        single use and expires one hour after it was issued (D-0054).
      schema:
        type: string
        minLength: 1
    Limit:
      name: limit
      in: query
      required: false
      description: Maximum number of items to return (default 50, maximum 500).
      schema:
        type: integer
        minimum: 1
        maximum: 500
        default: 50
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque cursor returned as `next_cursor` by the previous page.
      schema:
        type: string
    AfterSeq:
      name: after_seq
      in: query
      required: false
      description: Return only entries whose `seq` is greater than this value.
      schema:
        type: integer
        minimum: 0
    NodeId:
      name: node_id
      in: path
      required: true
      description: Node id (`nod_...`).
      schema:
        type: string
        pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
    ProjectId:
      name: project_id
      in: path
      required: true
      description: Project id (`prj_...`).
      schema:
        type: string
        pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
    VmId:
      name: vm_id
      in: path
      required: true
      description: VM id (`vm_...`).
      schema:
        type: string
        pattern: '^vm_[0-9A-HJKMNP-TV-Z]{26}$'
    AppId:
      name: app_id
      in: path
      required: true
      description: App id (`app_...`).
      schema:
        type: string
        pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
    DeploymentId:
      name: deployment_id
      in: path
      required: true
      description: Deployment id (`dep_...`).
      schema:
        type: string
        pattern: '^dep_[0-9A-HJKMNP-TV-Z]{26}$'
    DomainId:
      name: domain_id
      in: path
      required: true
      description: Domain id (`dom_...`).
      schema:
        type: string
        pattern: '^dom_[0-9A-HJKMNP-TV-Z]{26}$'
    TokenId:
      name: token_id
      in: path
      required: true
      description: API token id (`tok_...`).
      schema:
        type: string
        pattern: '^tok_[0-9A-HJKMNP-TV-Z]{26}$'
    UserId:
      name: user_id
      in: path
      required: true
      description: User id (`usr_...`).
      schema:
        type: string
        pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
    AgentId:
      name: agent_id
      in: path
      required: true
      description: Agent id (`agt_...`).
      schema:
        type: string
        pattern: '^agt_[0-9A-HJKMNP-TV-Z]{26}$'
    KeyId:
      name: key_id
      in: path
      required: true
      description: Agent key id (`akey_...`).
      schema:
        type: string
        pattern: '^akey_[0-9A-HJKMNP-TV-Z]{26}$'
    GrantId:
      name: grant_id
      in: path
      required: true
      description: Grant id (`grt_...`).
      schema:
        type: string
        pattern: '^grt_[0-9A-HJKMNP-TV-Z]{26}$'
    ApprovalId:
      name: approval_id
      in: path
      required: true
      description: Approval request id (`apr_...`).
      schema:
        type: string
        pattern: '^apr_[0-9A-HJKMNP-TV-Z]{26}$'
    IncidentId:
      name: incident_id
      in: path
      required: true
      description: Incident id (`inc_...`).
      schema:
        type: string
        pattern: '^inc_[0-9A-HJKMNP-TV-Z]{26}$'
    WebhookId:
      name: webhook_id
      in: path
      required: true
      description: Webhook id (`whk_...`).
      schema:
        type: string
        pattern: '^whk_[0-9A-HJKMNP-TV-Z]{26}$'
    ProviderId:
      name: provider_id
      in: path
      required: true
      description: Provider id (`prv_...`).
      schema:
        type: string
        pattern: '^prv_[0-9A-HJKMNP-TV-Z]{26}$'
    BuildId:
      name: build_id
      in: path
      required: true
      description: Build id (`bld_...`).
      schema:
        type: string
        pattern: '^bld_[0-9A-HJKMNP-TV-Z]{26}$'
    InteractiveSessionId:
      name: session_id
      in: path
      required: true
      description: Terminal or console session id (`trm_...`).
      schema:
        type: string
        pattern: '^trm_[0-9A-HJKMNP-TV-Z]{26}$'
    Seq:
      name: seq
      in: path
      required: true
      description: Sequence number of an audit record or event.
      schema:
        type: integer
        minimum: 1
  headers:
    QuoroRequestId:
      description: |
        Correlation id of this call (`req_...`). It appears in the audit record
        of the call (D-0031).
      required: true
      schema:
        type: string
        pattern: '^req_[0-9A-HJKMNP-TV-Z]{26}$'
  responses:
    Unauthenticated:
      description: |
        No valid credential was presented (`code: unauthenticated`, 401).
      headers:
        Quoro-Request-Id:
          $ref: '#/components/headers/QuoroRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Forbidden:
      description: |
        The caller is authenticated but not allowed (`code: forbidden`, 403).
        `reason` says whether the scope, the selector, the grant state or the
        identity was the obstacle.
      headers:
        Quoro-Request-Id:
          $ref: '#/components/headers/QuoroRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    ApprovalRequired:
      description: |
        The action needs just-in-time approval (`code: approval_required`, 403).
        The body names the `scope` and the `target` so the agent can file an
        approval request (D-0030).
      headers:
        Quoro-Request-Id:
          $ref: '#/components/headers/QuoroRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    NotFound:
      description: |
        The addressed resource does not exist (`code: not_found`, 404).
      headers:
        Quoro-Request-Id:
          $ref: '#/components/headers/QuoroRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Conflict:
      description: |
        The request conflicts with the current state, for example a duplicate
        slug, a non-empty project or a second setup (`code: conflict`, 409).
      headers:
        Quoro-Request-Id:
          $ref: '#/components/headers/QuoroRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    ValidationFailed:
      description: |
        The body or query is invalid (`code: validation_failed`, 422). Used also
        for an HA policy other than `none` before M5a, a scope an agent grant may
        not carry, and a missing mandatory TTL.
      headers:
        Quoro-Request-Id:
          $ref: '#/components/headers/QuoroRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    RateLimited:
      description: |
        Too many requests (`code: rate_limited`, 429).
      headers:
        Quoro-Request-Id:
          $ref: '#/components/headers/QuoroRequestId'
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            minimum: 1
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    NotPrimary:
      description: |
        The write reached a replica that cannot forward it to the primary
        (`code: not_primary`, 503) (D-0012). The client may retry against the
        primary.
      headers:
        Quoro-Request-Id:
          $ref: '#/components/headers/QuoroRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    NodeUnreachable:
      description: |
        On-demand telemetry could not reach the node (`code: node_unreachable`,
        504). Telemetry stays on the node and is read through the control plane
        on demand (D-0014).
      headers:
        Quoro-Request-Id:
          $ref: '#/components/headers/QuoroRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Internal:
      description: |
        An unexpected error occurred (`code: internal`, 500).
      headers:
        Quoro-Request-Id:
          $ref: '#/components/headers/QuoroRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
  schemas:
    Problem:
      type: object
      title: Problem
      description: |
        RFC 9457 problem detail with Quoro's extra fields. The media type is
        `application/problem+json`. `code` is the machine-readable error; `reason`
        refines `forbidden`, and `scope` plus `target` refine `approval_required`
        so an agent can file the matching approval request (D-0030).
      required:
        - type
        - title
        - status
        - code
      properties:
        type:
          type: string
          format: uri-reference
          default: about:blank
          description: URI identifying the problem type; `about:blank` when none.
        title:
          type: string
          description: Short human-readable summary of the problem type.
        status:
          type: integer
          minimum: 400
          maximum: 599
          description: HTTP status code of this response.
        detail:
          type: string
          description: Human-readable explanation of this occurrence.
        instance:
          type: string
          format: uri-reference
          description: URI of the request that produced the problem.
        code:
          type: string
          description: Machine-readable error code.
          enum:
            - unauthenticated
            - forbidden
            - approval_required
            - not_found
            - conflict
            - validation_failed
            - rate_limited
            - not_primary
            - node_unreachable
            - internal
        reason:
          type: string
          description: Why a `forbidden` response was returned. Absent for other codes.
          enum:
            - scope_missing
            - selector_mismatch
            - grant_expired
            - grant_revoked
            - grant_exhausted
            - human_only
            - agent_disabled
        scope:
          type: string
          description: |
            The scope that was missing, or that an approval must grant
            (`approval_required`). One scope from `x-quoro-scope`.
        target:
          $ref: '#/components/schemas/Target'
    HealthStatus:
      type: object
      title: HealthStatus
      description: Liveness answer of the node that served the call.
      required:
        - status
      properties:
        status:
          type: string
          enum:
            - ok
          description: Fixed liveness value.
    SetupRequest:
      type: object
      title: SetupRequest
      description: |
        First-run setup. Creates the first user as `owner`. Accepted only while
        no user exists; afterwards the call fails with `conflict`.
      required:
        - email
        - name
        - password
      properties:
        email:
          type: string
          format: email
          description: Email of the first owner.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Display name of the first owner.
        password:
          type: string
          format: password
          minLength: 12
          writeOnly: true
          description: Password of the first owner. Stored as an Argon2id hash; never returned.
    LoginRequest:
      type: object
      title: LoginRequest
      description: Human login with email and password; answers with a session cookie.
      required:
        - email
        - password
      properties:
        email:
          type: string
          format: email
          description: Email of the user.
        password:
          type: string
          format: password
          minLength: 1
          writeOnly: true
          description: Password of the user.
    ChangePasswordRequest:
      type: object
      title: ChangePasswordRequest
      description: The signed-in human's current password and the new one (ADR 0135).
      required:
        - current_password
        - new_password
      properties:
        current_password:
          type: string
          format: password
          minLength: 1
          writeOnly: true
          description: The password the user signs in with today.
        new_password:
          type: string
          format: password
          minLength: 12
          writeOnly: true
          description: |
            The new password, different from the current one. Stored as an
            Argon2id hash.
    MeResponse:
      type: object
      title: MeResponse
      description: |
        The identity behind the credential. A human answers with `user`; an agent
        answers with `agent` and its active grants (D-0029, D-0030).
      required:
        - type
      properties:
        type:
          type: string
          enum:
            - user
            - agent
          description: Kind of identity.
        user:
          $ref: '#/components/schemas/User'
        agent:
          $ref: '#/components/schemas/Agent'
        grants:
          type: array
          description: Active grants of the agent; empty for a human.
          items:
            $ref: '#/components/schemas/Grant'
    AccessTokenResponse:
      type: object
      title: AccessTokenResponse
      description: |
        A short-lived agent access token minted from an agent key. It is valid for
        5 minutes and carries no scope of its own: every call is authorized
        against the agent's grants (D-0030).
      required:
        - access_token
        - expires_at
      properties:
        access_token:
          type: string
          pattern: '^atk_[0-9A-HJKMNP-TV-Z]{26}$'
          description: The access token. It is shown only in this response.
        expires_at:
          type: string
          format: date-time
          description: Expiry, 5 minutes after issue.
    Cluster:
      type: object
      title: Cluster
      description: |
        The single installation (D-0012). The primary is the source of truth; the
        change log is the state (D-0015).
      required:
        - id
        - name
        - version
        - primary_node_id
        - log_seq
        - log_head_hash
        - term
        - created_at
      properties:
        id:
          type: string
          pattern: '^cls_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Cluster id.
        name:
          type: string
          description: Cluster name chosen at setup.
        version:
          type: string
          description: Version of the running binary.
        primary_node_id:
          type: string
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Node that currently holds the primary status.
        log_seq:
          type: integer
          minimum: 0
          description: Sequence number of the last change-log entry.
        log_head_hash:
          type: string
          pattern: '^[0-9a-f]{64}$'
          description: Hash of the last change-log entry.
        term:
          type: integer
          minimum: 1
          description: Term of the current primary; 1 until elections exist (M6).
        created_at:
          type: string
          format: date-time
          description: When the cluster was created.
    NodeCapacity:
      type: object
      title: NodeCapacity
      description: |
        Capacity of a node, and the part of it allocated to VMs. Both use the same
        shape.
      required:
        - cpus
        - memory_mb
        - disk_gb
      properties:
        cpus:
          type: integer
          minimum: 1
          description: Number of logical CPUs.
        memory_mb:
          type: integer
          minimum: 0
          description: Memory in mebibytes, as reported by the kernel.
        disk_gb:
          type: integer
          minimum: 0
          description: Disk in gibibytes available to VM disks.
    KvmInfo:
      type: object
      title: KvmInfo
      description: Result of the KVM probe a node runs at enrollment and on request.
      required:
        - available
        - kernel
      properties:
        available:
          type: boolean
          description: Whether `/dev/kvm` is usable.
        kernel:
          type: string
          description: Kernel release reported by the probe.
        nested:
          type: boolean
          description: Whether the node itself runs as a guest with nested virtualization.
    Node:
      type: object
      title: Node
      description: |
        A machine running the Quoro binary (D-0012). `cp_status` is the
        control-plane status; whether a node follows the change log is visible in
        its `log_seq`, not in `cp_status`.
      required:
        - id
        - name
        - roles
        - cp_status
        - status
        - public_key
        - behind_nat
        - labels
        - capacity
        - allocated
        - kvm
        - desired_rev
        - applied_rev
        - log_seq
        - fence_failure
        - tunnel
        - created_at
      properties:
        id:
          type: string
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Node id.
        name:
          type: string
          description: Node name, unique in the cluster.
        roles:
          type: array
          description: Roles this node runs; they combine (D-0018).
          items:
            type: string
            enum:
              - node
              - witness
              - edge
        cp_status:
          description: |
            Control-plane status. `replica` only once read-serving by replica
            nodes is enabled with manual promotion in M5; before that every
            non-primary node is null. In M1-M4 the cluster has exactly one
            primary and no witness (D-0016).
          oneOf:
            - type: string
              enum:
                - primary
                - replica
            - type: 'null'
        status:
          type: string
          description: Session lifecycle status of the node.
          enum:
            - pending
            - connected
            - disconnected
            - fenced
            - removed
        public_key:
          type: string
          description: Ed25519 public key of the node, base64.
        address:
          type: [string, 'null']
          description: Reachable address if the node is public, else null.
        behind_nat:
          type: boolean
          description: Whether the node dials out instead of accepting inbound sessions.
        labels:
          type: object
          additionalProperties:
            type: string
          description: Operator labels used by placement.
        capacity:
          $ref: '#/components/schemas/NodeCapacity'
        allocated:
          $ref: '#/components/schemas/NodeCapacity'
        kvm:
          $ref: '#/components/schemas/KvmInfo'
        engine_version:
          type: [string, 'null']
          description: Version of the engine implementation on the node.
        agent_version:
          type: [string, 'null']
          description: Version of the node agent on the node.
        desired_rev:
          type: integer
          minimum: 0
          description: Revision of the desired state the control plane wants applied.
        applied_rev:
          type: integer
          minimum: 0
          description: Revision the node last reported as applied.
        log_seq:
          type: integer
          minimum: 0
          description: Last change-log seq the node has verified.
        fence_failure:
          description: |
            Failed fences of this node that no operator has acknowledged yet, or
            null. While a lease is listed here the primary grants it to no node
            (D-0074); `POST /api/v1/nodes/{node_id}/fence-ack` clears it (D-0079).
          oneOf:
            - $ref: '#/components/schemas/NodeFenceFailure'
            - type: 'null'
        tunnel:
          description: |
            The node's Cloudflare Tunnel mapping, or null when the node is not
            mapped to a `tunnel` provider (D-0094, D-0095). Set with
            `PUT /api/v1/nodes/{node_id}/tunnel`. The run token is never returned.
          oneOf:
            - $ref: '#/components/schemas/NodeTunnel'
            - type: 'null'
        last_seen_at:
          type: [string, 'null']
          description: Last heartbeat or message from the node.
        created_at:
          type: string
          format: date-time
          description: When the node first enrolled.
    NodeFenceFailure:
      type: object
      title: NodeFenceFailure
      description: |
        Unacknowledged failed fences of a node: a forced stop could not confirm
        that these VMs stopped, so they may still run (D-0074,
        node-agent-protocol.md section 8.5). Ids accumulate across failures
        until an operator acknowledges them.
      required:
        - detected_at
        - lease_ids
        - app_ids
        - vm_ids
      properties:
        detected_at:
          type: string
          format: date-time
          description: When the primary recorded the first of these failures.
        lease_ids:
          type: array
          description: Leases whose fence failed; none of them is granted again until acknowledged.
          items:
            type: string
            pattern: '^lse_[0-9A-HJKMNP-TV-Z]{26}$'
        app_ids:
          type: array
          description: Apps bound to those leases.
          items:
            type: string
            pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
        vm_ids:
          type: array
          description: VMs the node could not confirm stopped.
          items:
            type: string
            pattern: '^vm_[0-9A-HJKMNP-TV-Z]{26}$'
    NodeTunnel:
      type: object
      title: NodeTunnel
      description: |
        Which `tunnel` provider runs this node's tunnel (D-0094, D-0095). One
        tunnel per node. The primary creates or looks up the tunnel with the
        provider's account credential and sends the node only the tunnel id and
        its run token (node-agent-protocol.md section 5.11); the account
        credential never leaves the primary and the run token is never returned
        by the API.
      required:
        - provider_id
        - tunnel_id
        - origin_port
      properties:
        provider_id:
          type: string
          pattern: '^prv_[0-9A-HJKMNP-TV-Z]{26}$'
          description: The `tunnel` provider whose account holds the tunnel.
        tunnel_id:
          type: [string, 'null']
          description: The provider's id of the tunnel, or null until the primary has created it.
        origin_port:
          type: integer
          minimum: 1
          maximum: 65535
          description: Loopback port on the node where its proxy accepts the tunnel's traffic.
    SetNodeTunnelRequest:
      type: object
      title: SetNodeTunnelRequest
      description: Map a node to the `tunnel` provider that runs its tunnel.
      required:
        - provider_id
      properties:
        provider_id:
          type: string
          pattern: '^prv_[0-9A-HJKMNP-TV-Z]{26}$'
          description: A provider of kind `tunnel`; any other kind is refused with `validation_failed`.
        origin_port:
          type: integer
          minimum: 1
          maximum: 65535
          default: 8081
          description: Loopback port on the node where its proxy accepts the tunnel's traffic.
    FenceAckRequest:
      type: object
      title: FenceAckRequest
      description: |
        The failed fences an operator verified out of band (for example by
        power-cycling the host). `lease_ids` must equal the node's outstanding
        `fence_failure.lease_ids`, so an acknowledgement never covers a failure
        the operator has not seen.
      required:
        - lease_ids
      properties:
        lease_ids:
          type: array
          minItems: 1
          uniqueItems: true
          description: Lease ids of the failed fences being acknowledged.
          items:
            type: string
            pattern: '^lse_[0-9A-HJKMNP-TV-Z]{26}$'
    NodeList:
      type: object
      title: NodeList
      description: One page of nodes.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Node'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    NodeJoinToken:
      type: object
      title: NodeJoinToken
      description: |
        A one-time enrollment token (`njt_...`). The secret is shown once on
        creation; only its SHA-256 hash is stored, so the secret can never be read
        again. Like every other credential hash, the hash is not part of the API.
      required:
        - id
        - created_by
        - expires_at
      properties:
        id:
          type: string
          pattern: '^njt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Join token id.
        created_by:
          type: string
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: User who created the token.
        expires_at:
          type: string
          format: date-time
          description: Expiry of the token; default 1 hour after creation.
        used_at:
          type: [string, 'null']
          description: When the token was consumed, or null.
        node_id:
          type: [string, 'null']
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Node that consumed the token, or null.
    NodeJoinTokenRequest:
      type: object
      title: NodeJoinTokenRequest
      description: Create a one-time join token.
      properties:
        expires_at:
          type: string
          format: date-time
          description: Expiry of the token. Defaults to 1 hour after creation.
    NodeJoinTokenCreated:
      type: object
      title: NodeJoinTokenCreated
      description: |
        The new join token. `token` is shown once and cannot be retrieved again;
        `join_command` is the single line the operator runs on the new machine.
      required:
        - id
        - token
        - expires_at
        - join_command
      properties:
        id:
          type: string
          pattern: '^njt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Join token id.
        token:
          type: string
          readOnly: true
          description: The token secret. Shown only in this response.
        expires_at:
          type: string
          format: date-time
          description: Expiry of the token.
        join_command:
          type: string
          description: One-line command that enrolls the node and starts the node agent.
    EnrollNodeRequest:
      type: object
      title: EnrollNodeRequest
      description: |
        Node enrollment. The node proves possession of a join token and presents
        its Ed25519 public key; the reply contains a node credential (see
        `docs/contracts/node-agent-protocol.md`).
      required:
        - join_token
        - name
        - public_key
        - roles
      properties:
        join_token:
          type: string
          writeOnly: true
          description: The one-time join token secret.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Node name, unique in the cluster.
        public_key:
          type: string
          description: Ed25519 public key of the node, base64.
        roles:
          type: array
          description: Roles the node will run.
          items:
            type: string
            enum:
              - node
              - witness
              - edge
        capacity:
          $ref: '#/components/schemas/NodeCapacity'
        kvm:
          $ref: '#/components/schemas/KvmInfo'
    EnrollNodeResponse:
      type: object
      title: EnrollNodeResponse
      description: |
        Enrollment result. `credential` is the node session credential; it is
        shown once and authenticates the node-agent session.
      required:
        - node_id
        - cluster_id
        - primary_url
        - credential
        - cluster_public_key
      properties:
        node_id:
          type: string
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Id of the enrolled node.
        cluster_id:
          type: string
          pattern: '^cls_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Id of the cluster the node joined.
        primary_url:
          type: string
          format: uri
          description: Base URL the node uses to dial the primary.
        credential:
          type: string
          readOnly: true
          description: Node session credential. Shown only in this response.
        cluster_public_key:
          type: string
          readOnly: true
          description: Base64 Ed25519 public key of the cluster. The node uses it to verify the signature on every `desired_state` (see `docs/contracts/node-agent-protocol.md`).
    Project:
      type: object
      title: Project
      description: A group of apps; also the unit a resource selector can name.
      required:
        - id
        - slug
        - name
        - created_at
        - updated_at
      properties:
        id:
          type: string
          pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Project id.
        slug:
          type: string
          pattern: '^[a-z0-9-]{1,40}$'
          description: Unique slug used in selectors and the CLI.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Display name.
        created_at:
          type: string
          format: date-time
          description: When the project was created.
        updated_at:
          type: string
          format: date-time
          description: When the project was last changed.
    ProjectList:
      type: object
      title: ProjectList
      description: One page of projects.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Project'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    CreateProjectRequest:
      type: object
      title: CreateProjectRequest
      description: Create a project.
      required:
        - slug
        - name
      properties:
        slug:
          type: string
          pattern: '^[a-z0-9-]{1,40}$'
          description: Unique slug.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Display name.
    UpdateProjectRequest:
      type: object
      title: UpdateProjectRequest
      description: Change a project. Absent fields stay unchanged.
      properties:
        slug:
          type: string
          pattern: '^[a-z0-9-]{1,40}$'
          description: New unique slug.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: New display name.
    Vm:
      type: object
      title: Vm
      description: |
        One microVM managed by the engine (M1). A VM belongs to an app when
        `app_id` is set; a standalone VM has `app_id` null and belongs to its
        project. `desired_status` is what the control plane wants,
        `observed_status` what the node reports.
      required:
        - id
        - project_id
        - app_id
        - node_id
        - name
        - vcpus
        - memory_mb
        - disk_gb
        - image
        - desired_status
        - observed_status
        - restart_generation
        - restart_count
        - created_at
        - updated_at
      properties:
        id:
          type: string
          pattern: '^vm_[0-9A-HJKMNP-TV-Z]{26}$'
          description: VM id.
        project_id:
          type: string
          pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Project the VM belongs to.
        app_id:
          type: [string, 'null']
          pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
          description: App whose instance this VM is, or null for a standalone VM.
        node_id:
          type: string
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Node the VM is placed on.
        name:
          type: string
          description: VM name.
        vcpus:
          type: integer
          minimum: 1
          description: Number of virtual CPUs.
        memory_mb:
          type: integer
          minimum: 1
          description: Memory in mebibytes.
        disk_gb:
          type: integer
          minimum: 1
          description: Root disk in gibibytes.
        image:
          type: string
          description: OCI reference with digest, the rootfs the VM boots.
        desired_status:
          type: string
          description: Status the control plane wants.
          enum:
            - running
            - stopped
        observed_status:
          type: string
          description: Status the node last reported.
          enum:
            - creating
            - stopped
            - starting
            - running
            - stopping
            - crashed
            - deleted
            - unknown
        ip:
          type: [string, 'null']
          description: Private address of the VM on the node bridge.
        restart_generation:
          type: integer
          minimum: 0
          description: Counter bumped by a restart; the node restarts the VM when it changes.
        restart_count:
          type: integer
          minimum: 0
          description: Number of restarts the node has performed.
        last_exit_code:
          type: [integer, 'null']
          description: Exit code of the last VM process, or null.
        created_at:
          type: string
          format: date-time
          description: When the VM was created.
        updated_at:
          type: string
          format: date-time
          description: When the VM record last changed.
    VmList:
      type: object
      title: VmList
      description: One page of VMs.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Vm'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    CreateVmRequest:
      type: object
      title: CreateVmRequest
      description: Create a standalone VM or an instance of an app.
      required:
        - project_id
        - node_id
        - name
        - vcpus
        - memory_mb
        - disk_gb
        - image
      properties:
        project_id:
          type: string
          pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Project the VM belongs to.
        app_id:
          type: [string, 'null']
          pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
          description: App whose instance the VM is. Omit for a standalone VM.
        node_id:
          type: string
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Node to place the VM on.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: VM name.
        vcpus:
          type: integer
          minimum: 1
          description: Number of virtual CPUs.
        memory_mb:
          type: integer
          minimum: 1
          description: Memory in mebibytes.
        disk_gb:
          type: integer
          minimum: 1
          description: Root disk in gibibytes.
        image:
          type: string
          description: OCI reference the VM boots.
    HealthCheck:
      type: object
      title: HealthCheck
      description: Health check an app declares; the node probes it and reports events.
      required:
        - path
        - interval_s
        - timeout_s
        - failures
      properties:
        path:
          type: string
          default: /
          description: HTTP path probed on the app port.
        interval_s:
          type: integer
          minimum: 1
          default: 10
          description: Seconds between probes.
        timeout_s:
          type: integer
          minimum: 1
          default: 2
          description: Seconds a probe may take.
        failures:
          type: integer
          minimum: 1
          default: 3
          description: Consecutive failures before the app is declared down.
    AppResources:
      type: object
      title: AppResources
      description: Resources of one app instance.
      required:
        - vcpus
        - memory_mb
        - disk_gb
      properties:
        vcpus:
          type: integer
          minimum: 1
          description: Number of virtual CPUs.
        memory_mb:
          type: integer
          minimum: 1
          description: Memory in mebibytes.
        disk_gb:
          type: integer
          minimum: 1
          description: Disk in gibibytes.
    AppPlacement:
      type: object
      title: AppPlacement
      description: Placement constraint of an app; an empty `node_ids` lets the scheduler pick.
      required:
        - node_ids
      properties:
        node_ids:
          type: array
          description: Nodes the app may run on; empty means any node.
          items:
            type: string
            pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
    App:
      type: object
      title: App
      description: |
        A deployable service. It runs as one microVM per instance (D-0008); in
        M1-M4 an app has one instance. `ha_policy` exists from M0 but only `none`
        is accepted before M5a (D-0020). An app either deploys images given by
        reference (`source` null) or builds them from a Git source (ADR 0117).
      required:
        - id
        - project_id
        - name
        - stateful
        - ha_policy
        - image
        - port
        - health_check
        - resources
        - placement
        - status
        - restart_generation
        - created_at
        - updated_at
      properties:
        id:
          type: string
          pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
          description: App id.
        project_id:
          type: string
          pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Project the app belongs to.
        name:
          type: string
          pattern: '^[a-z0-9-]{1,40}$'
          description: Name, unique in the project.
        stateful:
          type: boolean
          description: |
            Whether the app has volumes. A stateful app may run only on its lease
            holder (D-0019); volumes are used from M5.
        ha_policy:
          type: string
          description: |
            HA policy. Only `none` is accepted before M5a; other values return
            `validation_failed` (D-0020).
          enum:
            - none
            - cold
            - warm
            - hot
        image:
          type: string
          description: |
            Current OCI reference of the app. For an app with a Git source it is
            the reference of its last successful build,
            `build.quoro.invalid/<app_id>:<build_id>`, or the empty string before
            the first one.
        source:
          description: |
            The Git source the app builds its images from, or null (or absent)
            for an app that deploys images given by reference (ADR 0117).
          oneOf:
            - $ref: '#/components/schemas/AppSource'
            - type: 'null'
        port:
          type: integer
          minimum: 1
          maximum: 65535
          description: Container port the app listens on.
        health_check:
          $ref: '#/components/schemas/HealthCheck'
        resources:
          $ref: '#/components/schemas/AppResources'
        placement:
          $ref: '#/components/schemas/AppPlacement'
        status:
          type: string
          description: Aggregated status of the app and its instances.
          enum:
            - deploying
            - running
            - degraded
            - down
            - stopped
        current_deployment_id:
          type: [string, 'null']
          pattern: '^dep_[0-9A-HJKMNP-TV-Z]{26}$'
          description: The deployment whose image the app runs, or null before the first one.
        restart_generation:
          type: integer
          minimum: 0
          description: Counter bumped by a restart or redeploy.
        created_at:
          type: string
          format: date-time
          description: When the app was created.
        updated_at:
          type: string
          format: date-time
          description: When the app record last changed.
    AppDetail:
      type: object
      title: AppDetail
      description: |
        An app together with its instances (its VMs) and its domains. A read of
        one app answers this shape.
      allOf:
        - $ref: '#/components/schemas/App'
        - type: object
          required:
            - instances
            - domains
          properties:
            instances:
              type: array
              description: VMs whose `app_id` is this app.
              items:
                $ref: '#/components/schemas/Vm'
            domains:
              type: array
              description: Hostnames routed to this app.
              items:
                $ref: '#/components/schemas/Domain'
    AppList:
      type: object
      title: AppList
      description: One page of apps.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/App'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    CreateAppRequest:
      type: object
      title: CreateAppRequest
      description: |
        Create an app. Exactly one of `image` and `source` is given; both or
        neither answer `validation_failed`. The first deployment (for `image`) or
        the first build (for `source`) is started separately.
      required:
        - project_id
        - name
        - port
      properties:
        project_id:
          type: string
          pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Project the app belongs to.
        name:
          type: string
          pattern: '^[a-z0-9-]{1,40}$'
          description: Name, unique in the project.
        image:
          type: string
          description: OCI reference of the app image. Required unless `source` is given.
        source:
          $ref: '#/components/schemas/AppSource'
        port:
          type: integer
          minimum: 1
          maximum: 65535
          description: Container port the app listens on.
        stateful:
          type: boolean
          default: false
          description: Whether the app has volumes (used from M5).
        ha_policy:
          type: string
          default: none
          description: HA policy. Only `none` before M5a (D-0020).
          enum:
            - none
            - cold
            - warm
            - hot
        health_check:
          $ref: '#/components/schemas/HealthCheck'
        resources:
          $ref: '#/components/schemas/AppResources'
        placement:
          $ref: '#/components/schemas/AppPlacement'
    UpdateAppRequest:
      type: object
      title: UpdateAppRequest
      description: |
        Change app settings. The image is not changed here: a new image is rolled
        out as a deployment, or built from the Git source. Absent fields stay
        unchanged. `source` replaces the whole Git source of an app that has one,
        and takes effect with the next build; it cannot be added to an app created
        from an image, nor removed, in M4 (`conflict`, 409).
      properties:
        name:
          type: string
          pattern: '^[a-z0-9-]{1,40}$'
          description: New name, unique in the project.
        port:
          type: integer
          minimum: 1
          maximum: 65535
          description: New container port.
        source:
          $ref: '#/components/schemas/AppSource'
        ha_policy:
          type: string
          description: HA policy. Only `none` before M5a (D-0020).
          enum:
            - none
            - cold
            - warm
            - hot
        health_check:
          $ref: '#/components/schemas/HealthCheck'
        resources:
          $ref: '#/components/schemas/AppResources'
        placement:
          $ref: '#/components/schemas/AppPlacement'
    AppEnvVar:
      type: object
      title: AppEnvVar
      description: |
        The readable part of an environment variable: its `name` and when it
        changed. Values are write-only and never returned (they are stored
        encrypted at rest with the cluster key).
      required:
        - app_id
        - name
        - updated_at
      properties:
        app_id:
          type: string
          pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
          description: App the variable belongs to.
        name:
          type: string
          pattern: '^[A-Za-z_][A-Za-z0-9_]*$'
          description: Variable name.
        updated_at:
          type: string
          format: date-time
          description: When the value last changed.
    AppEnvVarInput:
      type: object
      title: AppEnvVarInput
      description: One variable in a full replacement of an app's environment.
      required:
        - name
        - value
      properties:
        name:
          type: string
          pattern: '^[A-Za-z_][A-Za-z0-9_]*$'
          description: Variable name.
        value:
          type: string
          writeOnly: true
          description: Variable value. It is stored encrypted and never returned.
    AppEnvList:
      type: object
      title: AppEnvList
      description: Names and change times of an app's environment variables.
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/AppEnvVar'
    AppEnvReplaceRequest:
      type: object
      title: AppEnvReplaceRequest
      description: |
        Full replacement of an app's environment. Variables not listed are
        removed. Names must be unique in the list.
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/AppEnvVarInput'
    Deployment:
      type: object
      title: Deployment
      description: |
        One rollout of an image to an app. `phase` is the current or failing phase.
        The `build` phase is reserved and never reported: a failed build is a
        failed `Build` with its own `build.failed` event and starts no deployment
        (ADR 0119). A successful build starts a deployment of the built image with
        `trigger: build`.
      required:
        - id
        - app_id
        - image
        - status
        - trigger
        - created_by_type
        - created_by_id
        - created_at
      properties:
        id:
          type: string
          pattern: '^dep_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Deployment id.
        app_id:
          type: string
          pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
          description: App the deployment rolls out.
        image:
          type: string
          description: Image reference as requested.
        image_digest:
          type: [string, 'null']
          description: Digest the reference resolved to, once known.
        status:
          type: string
          description: Deployment status.
          enum:
            - pending
            - preparing
            - starting
            - succeeded
            - failed
            - superseded
        phase:
          type: [string, 'null']
          description: Current or failing phase.
          enum:
            - pull
            - rootfs
            - boot
            - health
            - build
            - null
        error:
          type: [string, 'null']
          description: Failure message when `status` is `failed`.
        trigger:
          type: string
          description: What started the deployment.
          enum:
            - api
            - redeploy
            - rollback
            - build
        created_by_type:
          type: string
          description: Kind of identity that started the deployment.
          enum:
            - user
            - agent
            - system
        created_by_id:
          type: string
          description: Id of the identity that started the deployment.
        created_at:
          type: string
          format: date-time
          description: When the deployment started.
        finished_at:
          type: [string, 'null']
          description: When the deployment reached a terminal status.
    DeploymentList:
      type: object
      title: DeploymentList
      description: One page of deployments, newest first.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Deployment'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    CreateDeploymentRequest:
      type: object
      title: CreateDeploymentRequest
      description: Roll out a new image to an app.
      required:
        - image
      properties:
        image:
          type: string
          description: OCI reference to deploy.
    DeploymentLogEntry:
      type: object
      title: DeploymentLogEntry
      description: One progress line of a deployment.
      required:
        - time
        - phase
        - message
      properties:
        time:
          type: string
          format: date-time
          description: When the line was produced.
        phase:
          type: string
          description: Phase the line belongs to.
          enum:
            - pull
            - rootfs
            - boot
            - health
            - build
        message:
          type: string
          description: The progress line.
    DeploymentLogList:
      type: object
      title: DeploymentLogList
      description: Progress lines of a deployment in order.
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/DeploymentLogEntry'
    AppSource:
      type: object
      title: AppSource
      description: |
        The Git source an app builds its images from (ADR 0117). Only public
        `https://` repositories and the Dockerfile builder exist in M4; private
        repositories need a credential reference that a later contract change
        adds. Build arguments are not secrets: they end up in the image history.
        Build secrets are names of the app's environment variables; the node
        receives their values in the signed desired state and passes them to
        BuildKit as `--secret`, never as arguments, and never logs them.
      required:
        - kind
        - repo_url
        - ref
      properties:
        kind:
          type: string
          description: Kind of source. Only `git` in M4.
          enum:
            - git
        repo_url:
          type: string
          pattern: '^https://[^\s]+$'
          maxLength: 2000
          description: HTTPS URL of a public Git repository.
        ref:
          type: string
          minLength: 1
          maxLength: 255
          description: Branch, tag or full commit SHA to build. The node resolves it to a commit at the start of each build.
        dockerfile_path:
          type: string
          minLength: 1
          maxLength: 500
          default: Dockerfile
          description: Path of the Dockerfile, relative to `context_dir`; never absolute and never outside it.
        context_dir:
          type: string
          minLength: 1
          maxLength: 500
          default: .
          description: Build context, relative to the repository root; `.` is the root.
        target:
          type: [string, 'null']
          maxLength: 128
          description: Stage of a multi-stage Dockerfile to build, or null for the last stage.
        build_args:
          type: array
          maxItems: 100
          description: Build arguments, passed as `--build-arg`. Names are unique.
          items:
            $ref: '#/components/schemas/BuildArg'
        secrets:
          type: array
          maxItems: 50
          uniqueItems: true
          description: |
            Names of the app's environment variables to expose to the build as
            BuildKit secrets, with the variable name as the secret id
            (`RUN --mount=type=secret,id=<name>`). A name that is not an
            environment variable of the app fails the build in `preparing`.
          items:
            type: string
            pattern: '^[A-Za-z_][A-Za-z0-9_]*$'
    BuildArg:
      type: object
      title: BuildArg
      description: One `--build-arg` of a Git source. Not a secret.
      required:
        - name
        - value
      properties:
        name:
          type: string
          pattern: '^[A-Za-z_][A-Za-z0-9_]*$'
          description: Argument name.
        value:
          type: string
          maxLength: 4096
          description: Argument value. Stored and returned as sent; the audit log redacts it like every `value` key.
    BuildFailedStep:
      type: object
      title: BuildFailedStep
      description: |
        The BuildKit step that failed, read from BuildKit's machine-readable
        progress, with a few lines of its log and the Dockerfile excerpt. It is
        replicated with the build, so the panel shows it while the node is down
        (ADR 0119).
      required:
        - vertex
        - name
        - error
        - dockerfile_line
        - excerpt
      properties:
        vertex:
          type: integer
          minimum: 0
          description: BuildKit's step number (`#6` in the plain log).
        name:
          type: string
          description: The step's name, for example `[3/4] RUN npm ci`.
        error:
          type: string
          description: BuildKit's error for the step.
        dockerfile_line:
          type: [integer, 'null']
          minimum: 1
          description: Line of the Dockerfile the step comes from, when BuildKit names it.
        excerpt:
          type: array
          maxItems: 20
          description: The step's last log lines and the Dockerfile excerpt, at most 20 lines of at most 500 characters.
          items:
            type: string
    Build:
      type: object
      title: Build
      description: |
        One image build of an app's Git source, run on the node that runs the
        app in an ephemeral builder microVM (ADR 0117, ADR 0118). Only the
        status and the failing step are replicated; the log stays on that node
        and is read on demand (ADR 0014, ADR 0119). A build that succeeds starts
        a deployment of its image with `trigger: build`; `deployment_id` names
        it. The built image's reference is
        `build.quoro.invalid/<app_id>:<build_id>`, which no node ever pulls.
      required:
        - id
        - app_id
        - node_id
        - source
        - status
        - commit
        - image
        - image_digest
        - failed_phase
        - error
        - failed_step
        - deployment_id
        - trigger
        - created_by_type
        - created_by_id
        - created_at
        - started_at
        - finished_at
      properties:
        id:
          type: string
          pattern: '^bld_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Build id.
        app_id:
          type: string
          pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
          description: App the build belongs to.
        node_id:
          type: string
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Node that runs the build and keeps its log.
        source:
          $ref: '#/components/schemas/AppSource'
        status:
          type: string
          description: |
            Build status. `queued` waits for the node; `preparing` boots the
            builder VM and checks out the source; `building` runs the build;
            `exporting` writes the OCI layout; `importing` turns it into a root
            disk. `succeeded`, `failed` and `cancelled` are terminal.
          enum:
            - queued
            - preparing
            - building
            - exporting
            - importing
            - succeeded
            - failed
            - cancelled
        commit:
          type: [string, 'null']
          pattern: '^[0-9a-f]{40}$'
          description: Commit the ref resolved to, once the checkout finished.
        image:
          type: [string, 'null']
          description: The built image's reference, `build.quoro.invalid/<app_id>:<build_id>`, on success.
        image_digest:
          type: [string, 'null']
          description: Manifest digest of the built image, on success.
        failed_phase:
          type: [string, 'null']
          description: Phase the build failed in; null unless `status` is `failed`.
          enum:
            - preparing
            - building
            - exporting
            - importing
            - null
        error:
          type: [string, 'null']
          description: One-line failure reason when `status` is `failed`, for example the step error or `timeout`.
        failed_step:
          oneOf:
            - $ref: '#/components/schemas/BuildFailedStep'
            - type: 'null'
          description: The failing step, when the build failed in a build step.
        deployment_id:
          type: [string, 'null']
          pattern: '^dep_[0-9A-HJKMNP-TV-Z]{26}$'
          description: The deployment the build started, on success.
        trigger:
          type: string
          description: What started the build. Only an explicit start (`api`) in M4.
          enum:
            - api
        created_by_type:
          type: string
          description: Kind of identity that started the build.
          enum:
            - user
            - agent
            - system
        created_by_id:
          type: string
          description: Id of the identity that started the build.
        created_at:
          type: string
          format: date-time
          description: When the build was queued.
        started_at:
          type: [string, 'null']
          format: date-time
          description: When the node started it.
        finished_at:
          type: [string, 'null']
          format: date-time
          description: When it reached a terminal status.
    BuildList:
      type: object
      title: BuildList
      description: One page of builds, newest first.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Build'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    StartBuildRequest:
      type: object
      title: StartBuildRequest
      description: Start a build of the app's Git source. Every field is optional.
      properties:
        ref:
          type: string
          minLength: 1
          maxLength: 255
          description: Build this branch, tag or commit instead of the source's `ref`, for this build only.
    BuildLogLine:
      type: object
      title: BuildLogLine
      description: One line of a build's log, as kept on the node that ran it (ADR 0119).
      required:
        - seq
        - time
        - stream
        - line
      properties:
        seq:
          type: integer
          minimum: 1
          description: Position in the build's log, gap-free from 1.
        time:
          type: string
          format: date-time
          description: When the node recorded the line.
        stream:
          $ref: '#/components/schemas/LogStreamName'
        vertex:
          type: [integer, 'null']
          minimum: 0
          description: BuildKit step the line belongs to, when it names one.
        line:
          type: string
          description: The line, without its trailing newline.
    BuildLogList:
      type: object
      title: BuildLogList
      description: One page of a build's log, oldest first.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/BuildLogLine'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null when no more lines exist yet.
    Domain:
      type: object
      title: Domain
      description: |
        A hostname routed to one app. TLS is obtained with ACME for public-IP
        hosts or terminated by a Cloudflare Tunnel for hosts behind NAT (D-0024).
      required:
        - id
        - app_id
        - hostname
        - tls_mode
        - cert_status
        - created_at
      properties:
        id:
          type: string
          pattern: '^dom_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Domain id.
        app_id:
          type: string
          pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
          description: App the hostname routes to.
        hostname:
          type: string
          format: hostname
          description: Hostname served for the app.
        tls_mode:
          type: string
          description: How TLS is provided.
          enum:
            - acme
            - tunnel
        cert_status:
          type: string
          description: Certificate state; `not_applicable` in tunnel mode.
          enum:
            - pending
            - issued
            - renewing
            - failed
            - not_applicable
        cert_expires_at:
          type: [string, 'null']
          description: Expiry of the current certificate, when one exists.
        provider_id:
          type: [string, 'null']
          pattern: '^prv_[0-9A-HJKMNP-TV-Z]{26}$'
          description: |
            The provider whose credential manages the hostname's DNS records: a
            `dns` provider for `acme` (the primary writes the DNS-01 TXT records
            with it) or a `tunnel` provider for `tunnel` (the primary writes the
            tunnel ingress rule and the proxied CNAME with it) (D-0094, D-0095).
            Null only for domains created before the field existed.
        created_at:
          type: string
          format: date-time
          description: When the domain was added.
    DomainList:
      type: object
      title: DomainList
      description: One page of domains.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Domain'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    CreateDomainRequest:
      type: object
      title: CreateDomainRequest
      description: Route a hostname to an app.
      required:
        - hostname
        - tls_mode
      properties:
        hostname:
          type: string
          format: hostname
          description: Hostname to serve for the app.
        tls_mode:
          type: string
          description: How TLS is provided (D-0024).
          enum:
            - acme
            - tunnel
        provider_id:
          type: string
          pattern: '^prv_[0-9A-HJKMNP-TV-Z]{26}$'
          description: |
            The provider that manages the hostname's DNS records: kind `dns` for
            `acme`, kind `tunnel` for `tunnel`. Optional when exactly one provider
            of that kind exists, which is then used; with none or several, or a
            provider of the wrong kind, the request fails with `validation_failed`.
    Volume:
      type: object
      title: Volume
      description: |
        Persistent disk of a stateful app. Shape only: used from M5 (D-0019). No
        endpoint manages volumes in M1-M4.
      required:
        - id
        - app_id
        - node_id
        - size_gb
        - mount_path
        - created_at
      properties:
        id:
          type: string
          pattern: '^vol_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Volume id.
        app_id:
          type: string
          pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
          description: App that owns the volume.
        node_id:
          type: string
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Node holding the data.
        size_gb:
          type: integer
          minimum: 1
          description: Size in gibibytes.
        mount_path:
          type: string
          description: Mount path inside the app.
        created_at:
          type: string
          format: date-time
          description: When the volume was created.
    Lease:
      type: object
      title: Lease
      description: |
        A lease on an app or on the primary status. `fencing_token` changes only
        on ownership change: it stays the same across renewals by the same holder
        and increases strictly when the lease goes to a new holder or is granted
        again after expiry or revoke; never reused (D-0019, D-0073). Shape only in
        the API until M5b.
      required:
        - id
        - resource_type
        - resource_id
        - holder_node_id
        - mode
        - fencing_token
        - granted_at
      properties:
        id:
          type: string
          pattern: '^lse_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Lease id.
        resource_type:
          type: string
          description: What the lease is on.
          enum:
            - app
            - primary
        resource_id:
          type: string
          description: Id of the leased resource (an app id or a cluster id).
        holder_node_id:
          type: string
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Node holding the lease.
        mode:
          type: string
          description: Local leases need no renewal; expiring leases do.
          enum:
            - local
            - expiring
        fencing_token:
          type: integer
          minimum: 1
          description: Changes only on ownership change; the same across renewals by the same holder, strictly higher for a new holder or a grant after expiry or revoke; never reused.
        duration_ms:
          type: [integer, 'null']
          description: Lease duration in milliseconds; expiring leases only.
        granted_at:
          type: string
          format: date-time
          description: When the lease was granted.
        renewed_at:
          type: [string, 'null']
          description: When the lease was last renewed.
        expires_at:
          type: [string, 'null']
          description: Arbiter-side expiry estimate; expiring leases only.
        revoked_at:
          type: [string, 'null']
          description: When the lease was revoked.
    User:
      type: object
      title: User
      description: |
        A human identity. The password is stored as an Argon2id hash and is never
        part of this representation.
      required:
        - id
        - email
        - name
        - role
        - disabled
        - created_at
      properties:
        id:
          type: string
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: User id.
        email:
          type: string
          format: email
          description: Email, unique in the cluster.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Display name.
        role:
          type: string
          description: |
            Fixed scope bundle: `owner` has every scope, `operator` every scope
            except `user:write`, `viewer` every `:read` scope plus `app:logs`.
          enum:
            - owner
            - operator
            - viewer
        disabled:
          type: boolean
          description: A disabled user cannot log in or use tokens.
        created_at:
          type: string
          format: date-time
          description: When the user was created.
    UserList:
      type: object
      title: UserList
      description: One page of users.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/User'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    CreateUserRequest:
      type: object
      title: CreateUserRequest
      description: Create a human user.
      required:
        - email
        - name
        - role
        - password
      properties:
        email:
          type: string
          format: email
          description: Unique email.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Display name.
        role:
          type: string
          description: Fixed scope bundle.
          enum:
            - owner
            - operator
            - viewer
        password:
          type: string
          format: password
          minLength: 12
          writeOnly: true
          description: Initial password. Stored as an Argon2id hash.
        disabled:
          type: boolean
          default: false
          description: Whether the user starts disabled.
    UpdateUserRequest:
      type: object
      title: UpdateUserRequest
      description: Change a user. Absent fields stay unchanged.
      properties:
        email:
          type: string
          format: email
          description: New unique email.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: New display name.
        role:
          type: string
          description: New fixed scope bundle.
          enum:
            - owner
            - operator
            - viewer
        disabled:
          type: boolean
          description: Whether the user is disabled.
        password:
          type: string
          format: password
          minLength: 12
          writeOnly: true
          description: New password. Stored as an Argon2id hash.
    ApiToken:
      type: object
      title: ApiToken
      description: |
        A personal API token of a human. Only its hash is stored; the secret is
        shown once on creation.
      required:
        - id
        - user_id
        - name
        - created_at
      properties:
        id:
          type: string
          pattern: '^tok_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Token id.
        user_id:
          type: string
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: User the token belongs to.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Label of the token.
        expires_at:
          type: [string, 'null']
          description: Expiry of the token, or null when it does not expire.
        last_used_at:
          type: [string, 'null']
          description: When the token was last used.
        created_at:
          type: string
          format: date-time
          description: When the token was created.
        revoked_at:
          type: [string, 'null']
          description: When the token was revoked.
    ApiTokenList:
      type: object
      title: ApiTokenList
      description: One page of API tokens.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ApiToken'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    ApiTokenCreated:
      type: object
      title: ApiTokenCreated
      description: |
        The new token together with its secret. The secret is shown once and
        cannot be retrieved again.
      allOf:
        - $ref: '#/components/schemas/ApiToken'
        - type: object
          required:
            - token
          properties:
            token:
              type: string
              readOnly: true
              description: The token secret. Shown only in this response.
    CreateTokenRequest:
      type: object
      title: CreateTokenRequest
      description: Create a personal API token.
      required:
        - name
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Label of the token.
        expires_at:
          type: [string, 'null']
          description: Optional expiry.
    Agent:
      type: object
      title: Agent
      description: |
        An AI identity, separate from humans (D-0029). It records the human owner
        and the model, so the audit log can say who acted, on whose behalf and
        with which model.
      required:
        - id
        - name
        - model
        - owner_user_id
        - disabled
        - created_at
        - updated_at
      properties:
        id:
          type: string
          pattern: '^agt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Agent id.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Agent name, unique in the cluster.
        model:
          type: string
          description: Free-text model identifier the agent runs.
        owner_user_id:
          type: string
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Human who owns the agent and is its `on_behalf_of`.
        description:
          type: [string, 'null']
          description: What the agent is for.
        disabled:
          type: boolean
          description: |
            A disabled agent cannot mint access tokens; the attempt is refused
            with `forbidden` and `reason: agent_disabled`.
        created_at:
          type: string
          format: date-time
          description: When the agent was created.
        updated_at:
          type: string
          format: date-time
          description: When the agent record last changed.
    AgentList:
      type: object
      title: AgentList
      description: One page of agents.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Agent'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    CreateAgentRequest:
      type: object
      title: CreateAgentRequest
      description: Create an agent. It starts with no grants.
      required:
        - name
        - model
        - owner_user_id
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Unique agent name.
        model:
          type: string
          description: Free-text model identifier.
        owner_user_id:
          type: string
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Human owner of the agent.
        description:
          type: string
          maxLength: 2000
          description: What the agent is for.
    UpdateAgentRequest:
      type: object
      title: UpdateAgentRequest
      description: Change an agent. Absent fields stay unchanged.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: New unique agent name.
        model:
          type: string
          description: New model identifier.
        owner_user_id:
          type: string
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: New human owner.
        description:
          type: string
          maxLength: 2000
          description: What the agent is for.
        disabled:
          type: boolean
          description: Disable or re-enable the agent.
    AgentKey:
      type: object
      title: AgentKey
      description: |
        A long-lived agent credential. It authorizes nothing by itself; it only
        mints short-lived access tokens (D-0030).
      required:
        - id
        - agent_id
        - created_at
      properties:
        id:
          type: string
          pattern: '^akey_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Agent key id.
        agent_id:
          type: string
          pattern: '^agt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Agent the key belongs to.
        created_at:
          type: string
          format: date-time
          description: When the key was created.
        revoked_at:
          type: [string, 'null']
          description: When the key was revoked.
        last_used_at:
          type: [string, 'null']
          description: When the key last minted an access token.
    AgentKeyCreated:
      type: object
      title: AgentKeyCreated
      description: |
        A new agent key with its secret. The secret is shown once and cannot be
        retrieved again.
      allOf:
        - $ref: '#/components/schemas/AgentKey'
        - type: object
          required:
            - key
          properties:
            key:
              type: string
              readOnly: true
              description: The key secret. Shown only in this response.
    AgentKeyList:
      type: object
      title: AgentKeyList
      description: |
        One page of an agent's keys, newest first, revoked keys included. A key
        never carries its secret here.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/AgentKey'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    Selector:
      type: object
      title: Selector
      description: |
        The resources a grant's scopes apply to. A resource matches if it matches
        any listed entry; projects match by slug, others by id. `{"all": true}`
        matches everything and an empty object `{}` matches nothing.
      properties:
        projects:
          type: array
          description: Project slugs.
          items:
            type: string
            pattern: '^[a-z0-9-]{1,40}$'
        apps:
          type: array
          description: App ids.
          items:
            type: string
            pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
        nodes:
          type: array
          description: Node ids.
          items:
            type: string
            pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
        all:
          type: boolean
          description: Match every resource.
    Scope:
      type: string
      title: Scope
      description: |
        One scope, `<resource>:<verb>` (D-0030). Wildcards are not allowed in
        grants; scopes are listed explicitly.
      enum:
        - cluster:read
        - node:read
        - node:enroll
        - node:remove
        - node:fence_ack
        - project:read
        - project:write
        - project:delete
        - vm:read
        - vm:create
        - vm:start
        - vm:stop
        - vm:delete
        - vm:console
        - app:read
        - app:create
        - app:update
        - app:restart
        - app:redeploy
        - app:deploy
        - app:start
        - app:stop
        - app:delete
        - app:logs
        - app:terminal
        - agent:read
        - agent:write
        - grant:read
        - grant:write
        - approval:read
        - approval:create
        - approval:decide
        - incident:read
        - incident:resolve
        - audit:read
        - event:read
        - webhook:read
        - webhook:write
        - provider:manage
        - user:read
        - user:write
    Grant:
      type: object
      title: Grant
      description: |
        Scope plus resource selector plus a mandatory expiry, and optionally a
        maximum number of uses (D-0030). An incident grant is scoped to one
        incident; an approval grant is the extra grant a human approval issues.
      required:
        - id
        - agent_id
        - kind
        - scopes
        - selector
        - expires_at
        - uses
        - status
        - on_behalf_of
        - created_by_type
        - created_by_id
        - reason
        - created_at
      properties:
        id:
          type: string
          pattern: '^grt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Grant id.
        agent_id:
          type: string
          pattern: '^agt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Agent the grant authorizes.
        kind:
          type: string
          description: What the grant is for.
          enum:
            - standard
            - incident
            - approval
        scopes:
          type: array
          description: Scopes granted. Listed explicitly; no wildcards.
          items:
            $ref: '#/components/schemas/Scope'
        selector:
          $ref: '#/components/schemas/Selector'
        expires_at:
          type: string
          format: date-time
          description: Expiry; a TTL is mandatory.
        max_uses:
          type: [integer, 'null']
          minimum: 1
          description: Maximum number of uses, or null when unlimited until expiry.
        uses:
          type: integer
          minimum: 0
          description: Uses consumed so far.
        status:
          type: string
          description: Grant status.
          enum:
            - active
            - expired
            - revoked
            - exhausted
        incident_id:
          type: [string, 'null']
          pattern: '^inc_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Incident the grant is scoped to, when `kind` is `incident`.
        approval_id:
          type: [string, 'null']
          pattern: '^apr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Approval request that produced the grant, when `kind` is `approval`.
        on_behalf_of:
          type: string
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Human who authorized the grant.
        created_by_type:
          type: string
          description: Kind of identity that created the grant.
          enum:
            - user
            - system
        created_by_id:
          type: string
          description: Id of the identity that created the grant.
        reason:
          type: string
          description: Why the grant was issued.
        created_at:
          type: string
          format: date-time
          description: When the grant was issued.
        revoked_at:
          type: [string, 'null']
          description: When the grant was revoked.
        revoked_by:
          type: [string, 'null']
          description: Id of the identity that revoked the grant.
    GrantList:
      type: object
      title: GrantList
      description: One page of grants.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Grant'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    CreateGrantRequest:
      type: object
      title: CreateGrantRequest
      description: |
        Create a standard grant. Incident and approval grants are issued by the
        system when an incident opens or a request is approved, never by hand.
        `expires_at` or `ttl_s` is required: grants always expire.
      required:
        - agent_id
        - scopes
        - selector
        - reason
      properties:
        agent_id:
          type: string
          pattern: '^agt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Agent to authorize.
        kind:
          type: string
          default: standard
          description: Only `standard` is accepted here.
          enum:
            - standard
        scopes:
          type: array
          description: |
            Scopes to grant. Human-only and destructive scopes are rejected with
            `validation_failed`: an agent may never widen its own power, and a
            destructive action needs approval (D-0030).
          items:
            $ref: '#/components/schemas/Scope'
        selector:
          $ref: '#/components/schemas/Selector'
        expires_at:
          type: string
          format: date-time
          description: Absolute expiry. Either this or `ttl_s` is required.
        ttl_s:
          type: integer
          minimum: 1
          description: Lifetime in seconds. Either this or `expires_at` is required.
        max_uses:
          type: integer
          minimum: 1
          description: Optional maximum number of uses.
        reason:
          type: string
          description: Why the agent needs this grant.
    Approval:
      type: object
      title: Approval
      description: |
        A request for just-in-time approval of a destructive action (D-0030). An
        approval issues at most one extra grant: that one scope, selector limited
        to that target, `max_uses` 1, TTL 10 minutes.
      required:
        - id
        - agent_id
        - scope
        - target
        - operation
        - params
        - params_hash
        - justification
        - status
        - expires_at
        - created_at
      properties:
        id:
          type: string
          pattern: '^apr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Approval request id.
        agent_id:
          type: string
          pattern: '^agt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Agent that filed the request.
        scope:
          $ref: '#/components/schemas/Scope'
        target:
          $ref: '#/components/schemas/Target'
        operation:
          type: string
          description: |
            The operation the agent intends to call, as an endpoint template or
            an MCP tool name, for example `DELETE /api/v1/apps/{app_id}` or
            `delete_app`.
        params:
          type: object
          additionalProperties: true
          description: The intended call parameters.
        params_hash:
          type: string
          pattern: '^[0-9a-f]{64}$'
          description: SHA-256 hex of the canonical params; an approved request may only run with these params.
        justification:
          type: string
          description: The agent's stated reason.
        incident_id:
          type: [string, 'null']
          pattern: '^inc_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Incident the request belongs to, if any.
        status:
          type: string
          description: Approval status.
          enum:
            - pending
            - approved
            - denied
            - expired
        expires_at:
          type: string
          format: date-time
          description: Expiry of the request; default 1 hour after filing.
        decided_by:
          type: [string, 'null']
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Human who decided the request.
        decided_at:
          type: [string, 'null']
          description: When the request was decided.
        decision_note:
          type: [string, 'null']
          description: Note the deciding human left.
        grant_id:
          type: [string, 'null']
          pattern: '^grt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Approval grant issued on approve.
        created_at:
          type: string
          format: date-time
          description: When the request was filed.
    ApprovalList:
      type: object
      title: ApprovalList
      description: One page of approval requests.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Approval'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    Target:
      type: object
      title: Target
      description: One addressed resource.
      required:
        - type
        - id
      properties:
        type:
          type: string
          description: Resource kind, for example `app`, `vm`, `project` or `node`.
        id:
          type: string
          description: Id of the resource.
    Subject:
      type: object
      title: Subject
      description: The resource an event or incident is about.
      required:
        - type
        - id
      properties:
        type:
          type: string
          description: Resource kind, for example `app`, `vm`, `node` or `cluster`.
        id:
          type: string
          description: Id of the resource.
    CreateApprovalRequest:
      type: object
      title: CreateApprovalRequest
      description: |
        File an approval request. Agents only: a human does not need approval for
        a destructive action (D-0030).
      required:
        - scope
        - target
        - operation
        - params
        - justification
      properties:
        scope:
          $ref: '#/components/schemas/Scope'
        target:
          $ref: '#/components/schemas/Target'
        operation:
          type: string
          description: Endpoint template or MCP tool name the agent intends to call.
        params:
          type: object
          additionalProperties: true
          description: The intended call parameters.
        justification:
          type: string
          minLength: 1
          maxLength: 2000
          description: Why the agent needs the exception.
        incident_id:
          type: [string, 'null']
          pattern: '^inc_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Incident the request belongs to, if any.
    DecisionRequest:
      type: object
      title: DecisionRequest
      description: Approve or deny a request, optionally with a note.
      properties:
        note:
          type: string
          maxLength: 2000
          description: Note recorded with the decision.
    Incident:
      type: object
      title: Incident
      description: |
        Opened by a detection and scoped to one subject; it scopes the incident
        grants an agent receives when an event wakes it (D-0030, D-0032).
      required:
        - id
        - status
        - title
        - trigger_event_id
        - subject
        - opened_at
      properties:
        id:
          type: string
          pattern: '^inc_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Incident id.
        status:
          type: string
          description: Incident status.
          enum:
            - open
            - resolved
        title:
          type: string
          description: One-line summary of the incident.
        trigger_event_id:
          type: string
          pattern: '^evt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Event that opened the incident.
        subject:
          $ref: '#/components/schemas/Subject'
        project_id:
          type: [string, 'null']
          pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Project of the subject, when it has one.
        opened_at:
          type: string
          format: date-time
          description: When the incident opened.
        resolved_at:
          type: [string, 'null']
          description: When the incident was resolved.
        resolved_by_type:
          type: [string, 'null']
          description: Kind of identity that resolved the incident.
          enum:
            - user
            - agent
            - system
            - null
        resolved_by_id:
          type: [string, 'null']
          description: Id of the identity that resolved the incident.
        resolution_note:
          type: [string, 'null']
          description: What was done to resolve it.
    IncidentList:
      type: object
      title: IncidentList
      description: One page of incidents.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Incident'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    IncidentDetail:
      type: object
      title: IncidentDetail
      description: An incident with the event that opened it and the grants issued for it.
      allOf:
        - $ref: '#/components/schemas/Incident'
        - type: object
          required:
            - trigger_event
            - grants
          properties:
            trigger_event:
              $ref: '#/components/schemas/Event'
            grants:
              type: array
              description: Incident grants issued for this incident.
              items:
                $ref: '#/components/schemas/Grant'
    ResolveIncidentRequest:
      type: object
      title: ResolveIncidentRequest
      description: Close an incident and record what was done.
      properties:
        resolution_note:
          type: string
          maxLength: 2000
          description: What was done to resolve it. Recorded in the audit log.
    EventType:
      type: string
      title: EventType
      description: |
        Event type, `<subject>.<what>` in lowercase with underscores. The list is
        closed; new types are added to the contract in a standalone change.
      enum:
        - node.enrolled
        - node.connected
        - node.disconnected
        - node.fenced
        - node.fence_failed
        - node.disk_low
        - vm.created
        - vm.started
        - vm.stopped
        - vm.crashed
        - vm.deleted
        - app.created
        - app.updated
        - app.deleted
        - app.down
        - app.up
        - app.restarted
        - deployment.started
        - deployment.succeeded
        - deployment.failed
        - build.started
        - build.succeeded
        - build.failed
        - build.cancelled
        - certificate.issued
        - certificate.expiring
        - certificate.failed
        - grant.created
        - grant.revoked
        - grant.expired
        - approval.requested
        - approval.approved
        - approval.denied
        - approval.expired
        - incident.opened
        - incident.resolved
        - audit.chain_broken
    EventSource:
      type: object
      title: EventSource
      description: What produced the event.
      required:
        - kind
      properties:
        kind:
          type: string
          description: Whether the control plane or a node produced it.
          enum:
            - control_plane
            - node
        node_id:
          type: [string, 'null']
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Node that produced the event, when `kind` is `node`.
    EventActor:
      type: object
      title: EventActor
      description: The identity behind the action the event reports, when there is one.
      required:
        - type
        - id
      properties:
        type:
          type: string
          description: Kind of identity.
          enum:
            - user
            - agent
            - node
            - system
        id:
          type: string
          description: Id of the identity.
    Event:
      type: object
      title: Event
      description: |
        One event in the append-only event log. The full envelope and the
        per-type `data` payloads are fixed by `docs/contracts/events.schema.json`.
        Webhook deliveries carry this object as their body.
      required:
        - id
        - seq
        - type
        - time
        - source
        - subject
        - data
      properties:
        id:
          type: string
          pattern: '^evt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Event id.
        seq:
          type: integer
          minimum: 1
          description: Gap-free sequence number of the event log.
        type:
          $ref: '#/components/schemas/EventType'
        time:
          type: string
          format: date-time
          description: When the event was recorded.
        source:
          $ref: '#/components/schemas/EventSource'
        subject:
          $ref: '#/components/schemas/Subject'
        project_id:
          type: [string, 'null']
          pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Project of the subject, when it has one.
        incident_id:
          type: [string, 'null']
          pattern: '^inc_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Incident the event belongs to, if any.
        request_id:
          type: [string, 'null']
          pattern: '^req_[0-9A-HJKMNP-TV-Z]{26}$'
          description: API or MCP call that caused the event.
        actor:
          $ref: '#/components/schemas/EventActor'
        data:
          type: object
          additionalProperties: true
          description: Type-specific payload, defined per event type in `events.schema.json`.
    EventList:
      type: object
      title: EventList
      description: One page of events in ascending `seq` order.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Event'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    WebhookWake:
      type: object
      title: WebhookWake
      description: |
        Present when the webhook wakes an agent: the delivery starts the agent
        with Quoro's MCP server, an incident grant over the non-destructive scopes
        listed here, and the incident context (D-0032).
      required:
        - agent_id
      properties:
        agent_id:
          type: string
          pattern: '^agt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Agent to wake.
        grant_scopes:
          type: array
          description: Non-destructive scopes the incident grant carries.
          items:
            $ref: '#/components/schemas/Scope'
        grant_ttl_s:
          type: integer
          minimum: 1
          default: 3600
          description: TTL of the incident grant in seconds.
        grant_max_uses:
          type: [integer, 'null']
          minimum: 1
          description: Optional maximum number of uses of the incident grant.
    Webhook:
      type: object
      title: Webhook
      description: |
        A subscription that delivers events to an external receiver (D-0032).
        `selector` limits which subjects fire it; an empty `event_types` list means
        every type. `secret` is write-only and shown once.
      required:
        - id
        - name
        - url
        - event_types
        - selector
        - enabled
        - created_by
        - created_at
        - updated_at
      properties:
        id:
          type: string
          pattern: '^whk_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Webhook id.
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Label of the subscription.
        url:
          type: string
          format: uri
          description: Receiver URL. HTTPS only, except `http://localhost` for testing.
        event_types:
          type: array
          description: Event types that fire this webhook; empty means all.
          items:
            $ref: '#/components/schemas/EventType'
        selector:
          $ref: '#/components/schemas/Selector'
        secret:
          type: string
          writeOnly: true
          description: HMAC-SHA256 signing secret. Shown once on creation; stored encrypted.
        enabled:
          type: boolean
          description: Whether deliveries are attempted.
        wake:
          $ref: '#/components/schemas/WebhookWake'
        created_by:
          type: string
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: User who created it; also the `on_behalf_of` of the grants it wakes.
        created_at:
          type: string
          format: date-time
          description: When the webhook was created.
        updated_at:
          type: string
          format: date-time
          description: When the webhook record last changed.
    WebhookList:
      type: object
      title: WebhookList
      description: One page of webhooks.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Webhook'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    WebhookCreated:
      type: object
      title: WebhookCreated
      description: |
        The new webhook with its signing secret. The secret is shown once and
        cannot be retrieved again.
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          required:
            - secret
          properties:
            secret:
              type: string
              readOnly: true
              description: HMAC-SHA256 signing secret. Shown only in this response.
    CreateWebhookRequest:
      type: object
      title: CreateWebhookRequest
      description: Subscribe a receiver to events.
      required:
        - name
        - url
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Label of the subscription.
        url:
          type: string
          format: uri
          description: Receiver URL; HTTPS only, except `http://localhost`.
        event_types:
          type: array
          description: Event types that fire the webhook; empty means all.
          items:
            $ref: '#/components/schemas/EventType'
        selector:
          $ref: '#/components/schemas/Selector'
        enabled:
          type: boolean
          default: true
          description: Whether deliveries are attempted.
        wake:
          $ref: '#/components/schemas/WebhookWake'
    UpdateWebhookRequest:
      type: object
      title: UpdateWebhookRequest
      description: |
        Change a webhook. Absent fields stay unchanged. The signing secret cannot
        be changed: delete and recreate the webhook to rotate it. `wake` cannot
        be cleared here; delete and recreate the webhook without it (its secret
        rotates).
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: New label.
        url:
          type: string
          format: uri
          description: New receiver URL.
        event_types:
          type: array
          description: New event types; empty means all.
          items:
            $ref: '#/components/schemas/EventType'
        selector:
          $ref: '#/components/schemas/Selector'
        enabled:
          type: boolean
          description: Whether deliveries are attempted.
        wake:
          $ref: '#/components/schemas/WebhookWake'
    WebhookDelivery:
      type: object
      title: WebhookDelivery
      description: |
        One delivery attempt of an event to a webhook. Failed deliveries are
        retried with exponential backoff from 10 seconds doubling to 1 hour, at
        most 10 attempts. A test delivery (`test` true) is attempted once and
        never retried.
      required:
        - id
        - webhook_id
        - event_id
        - attempt
        - status
        - test
        - created_at
      properties:
        id:
          type: string
          pattern: '^whd_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Delivery id; also sent as `Quoro-Delivery-Id`.
        webhook_id:
          type: string
          pattern: '^whk_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Webhook the delivery belongs to.
        event_id:
          type: string
          pattern: '^evt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Event that was delivered.
        attempt:
          type: integer
          minimum: 1
          description: Attempt number, starting at 1.
        status:
          type: string
          description: Delivery status.
          enum:
            - pending
            - delivered
            - failed
        response_code:
          type: [integer, 'null']
          minimum: 100
          maximum: 599
          description: HTTP status the receiver answered, when it answered.
        error:
          type: [string, 'null']
          description: Transport or receiver error.
        next_attempt_at:
          type: [string, 'null']
          description: When the next attempt is due.
        test:
          type: boolean
          description: |
            True for a test delivery sent by `testWebhook`, false for every real
            delivery. A test delivery is always attempt 1 with no next attempt.
        created_at:
          type: string
          format: date-time
          description: When the attempt was made.
    WebhookDeliveryList:
      type: object
      title: WebhookDeliveryList
      description: One page of deliveries, newest first.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/WebhookDelivery'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    ProviderKind:
      type: string
      title: ProviderKind
      description: |
        What the provider entry is used for: DNS records, tunnels, load balancing
        or object storage (D-0025, D-0051).
      enum:
        - dns
        - tunnel
        - lb
        - object_store
    Provider:
      type: object
      title: Provider
      description: |
        Credentials for one external provider (D-0025, D-0051). The secret is
        write-only: it is stored encrypted with the cluster data-encryption key
        (D-0050), never returned by the API and redacted from audit params. The
        off-site audit-copy credential is a separate `object_store` provider entry
        whose key is put-only (D-0031).
      required:
        - id
        - kind
        - name
        - config
        - has_secret
        - created_by
        - created_at
      properties:
        id:
          type: string
          pattern: '^prv_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Provider id.
        kind:
          $ref: '#/components/schemas/ProviderKind'
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Label of the provider entry; unique in the cluster.
        config:
          type: object
          additionalProperties: true
          description: |
            Non-secret settings such as zone id, account id, bucket, endpoint or
            region.
        has_secret:
          type: boolean
          description: |
            Whether a secret is stored for this provider. The secret itself is
            never returned.
        created_by:
          type: string
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: User who created the provider entry.
        created_at:
          type: string
          format: date-time
          description: When the provider entry was created.
        rotated_at:
          type: [string, 'null']
          format: date-time
          description: When the secret was last replaced, or null if never.
    ProviderList:
      type: object
      title: ProviderList
      description: One page of providers.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Provider'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    CreateProviderRequest:
      type: object
      title: CreateProviderRequest
      description: Add credentials for an external provider.
      required:
        - kind
        - name
        - config
        - secret
      properties:
        kind:
          $ref: '#/components/schemas/ProviderKind'
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Label of the provider entry; unique in the cluster.
        config:
          type: object
          additionalProperties: true
          description: |
            Non-secret settings such as zone id, account id, bucket, endpoint or
            region.
        secret:
          type: string
          writeOnly: true
          minLength: 1
          description: API token, tunnel token or access key secret. Write-only; never returned.
    RotateProviderSecretRequest:
      type: object
      title: RotateProviderSecretRequest
      description: Replace the stored secret of a provider.
      required:
        - secret
      properties:
        secret:
          type: string
          writeOnly: true
          minLength: 1
          description: API token, tunnel token or access key secret. Write-only; never returned.
    AuditRecord:
      type: object
      title: AuditRecord
      description: |
        One record of the append-only audit log; the format is fixed by
        `docs/contracts/audit-log-format.md`. It has no prefixed id: it is
        identified by `seq`. Secrets in `params` are redacted (D-0031).
      required:
        - seq
        - time
        - actor_type
        - actor_id
        - channel
        - operation
        - params
        - request_id
        - result
        - prev_hash
        - hash
      properties:
        seq:
          type: integer
          minimum: 1
          description: Gap-free sequence number of the audit log.
        time:
          type: string
          format: date-time
          description: When the call was recorded.
        actor_type:
          type: string
          description: Kind of identity behind the call.
          enum:
            - user
            - agent
            - node
            - system
        actor_id:
          type: string
          description: Id of the identity.
        agent_model:
          type: [string, 'null']
          description: Model of the agent, when the actor is an agent.
        on_behalf_of:
          type: [string, 'null']
          pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Human the actor acted for.
        channel:
          type: string
          description: Channel the call came through.
          enum:
            - api
            - panel
            - mcp
            - cli
            - system
        operation:
          type: string
          description: Endpoint or MCP tool name.
        scope:
          type: [string, 'null']
          description: Scope the call needed, when it needed one.
        target_type:
          type: [string, 'null']
          description: Kind of the addressed resource.
        target_id:
          type: [string, 'null']
          description: Id of the addressed resource.
        project_id:
          type: [string, 'null']
          pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Project of the target, when it has one.
        params:
          type: object
          additionalProperties: true
          description: Call parameters, with secrets redacted.
        justification:
          type: [string, 'null']
          description: The agent's stated reason, from `Quoro-Justification`.
        trigger_event_id:
          type: [string, 'null']
          pattern: '^evt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Event that triggered the call.
        incident_id:
          type: [string, 'null']
          pattern: '^inc_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Incident the call belongs to.
        grant_id:
          type: [string, 'null']
          pattern: '^grt_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Grant that authorized the call.
        approval_id:
          type: [string, 'null']
          pattern: '^apr_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Approval request behind the call.
        request_id:
          type: string
          pattern: '^req_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Correlation id of the call.
        result:
          type: string
          description: Outcome of the call.
          enum:
            - ok
            - refused
            - error
        result_detail:
          type: [string, 'null']
          description: Refusal reason or error code.
        change_seq:
          type: [integer, 'null']
          description: Change-log seq the action produced, if any.
        prev_hash:
          type: string
          pattern: '^[0-9a-f]{64}$'
          description: Hash of the previous record.
        hash:
          type: string
          pattern: '^[0-9a-f]{64}$'
          description: Hash of this record.
    AuditList:
      type: object
      title: AuditList
      description: One page of audit records in ascending `seq` order.
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/AuditRecord'
        next_cursor:
          type: [string, 'null']
          description: Cursor for the next page, or null on the last page.
    AuditVerificationRequest:
      type: object
      title: AuditVerificationRequest
      description: Verify a range of the audit chain; both bounds default to the whole log.
      properties:
        from_seq:
          type: integer
          minimum: 1
          description: First record to verify.
        to_seq:
          type: integer
          minimum: 1
          description: Last record to verify.
    AuditVerificationResult:
      type: object
      title: AuditVerificationResult
      description: |
        Result of verifying the hash chain. `broken_at_seq` and `reason` are set
        when a link does not match; verification emits `audit.chain_broken`.
      required:
        - ok
        - checked
        - first_seq
        - last_seq
        - head_hash
      properties:
        ok:
          type: boolean
          description: Whether every checked link matched.
        checked:
          type: integer
          minimum: 0
          description: Number of records verified.
        first_seq:
          type: integer
          minimum: 1
          description: First record verified.
        last_seq:
          type: integer
          minimum: 1
          description: Last record verified.
        head_hash:
          type: string
          pattern: '^[0-9a-f]{64}$'
          description: Hash of the last record in the log.
        broken_at_seq:
          type: [integer, 'null']
          description: First record whose link does not match.
        reason:
          type: [string, 'null']
          description: Why the link does not match.
    LogStreamName:
      type: string
      title: LogStreamName
      description: Which output stream a log line came from.
      enum:
        - stdout
        - stderr
    LogEntry:
      type: object
      title: LogEntry
      description: |
        One line of app output, as read from the node that runs the instance.
        Logs are not replicated; they are read on demand (D-0014).
      required:
        - time
        - vm_id
        - stream
        - line
      properties:
        time:
          type: string
          format: date-time
          description: When the node recorded the line.
        vm_id:
          type: string
          pattern: '^vm_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Instance that produced the line.
        stream:
          $ref: '#/components/schemas/LogStreamName'
        line:
          type: string
          description: The line, without its trailing newline.
    LogList:
      type: object
      title: LogList
      description: Log lines in time order.
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/LogEntry'
    NodeMetricSample:
      type: object
      title: NodeMetricSample
      description: One sample of a node's CPU and memory, taken by the node agent every `interval_s`.
      required:
        - time
        - cpu_usage
        - cpu_count
        - memory_total_bytes
        - memory_used_bytes
      properties:
        time:
          type: string
          format: date-time
          description: When the node took the sample.
        cpu_usage:
          type: number
          minimum: 0
          maximum: 1
          description: Busy fraction of all host CPUs over the sampling interval.
        cpu_count:
          type: integer
          minimum: 1
          description: Number of host CPUs.
        memory_total_bytes:
          type: integer
          minimum: 0
          description: Host memory.
        memory_used_bytes:
          type: integer
          minimum: 0
          description: Host memory in use, `MemTotal - MemAvailable`.
    NodeMetrics:
      type: object
      title: NodeMetrics
      description: |
        A node's recent CPU and memory samples, read from the node on demand
        and never stored by the control plane (ADR 0014). The node keeps the
        last 10 minutes in memory; an agent restart empties it.
      required:
        - node_id
        - interval_s
        - samples
      properties:
        node_id:
          type: string
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: The node.
        interval_s:
          type: integer
          minimum: 1
          description: Seconds between samples (5).
        samples:
          type: array
          description: Samples, oldest first.
          items:
            $ref: '#/components/schemas/NodeMetricSample'
    VmMetricSample:
      type: object
      title: VmMetricSample
      description: |
        One sample of a VM's CPU and memory as the host sees its VMM process;
        no guest agent is involved.
      required:
        - time
        - cpu_usage
        - vcpus
        - memory_rss_bytes
        - memory_bytes
      properties:
        time:
          type: string
          format: date-time
          description: When the node took the sample.
        cpu_usage:
          type: number
          minimum: 0
          description: |
            CPU time of the VM's VMM process over the interval, divided by the
            interval and by `vcpus`. 1 means every vCPU busy; the VMM's own
            threads can push it slightly above 1.
        vcpus:
          type: integer
          minimum: 1
          description: The VM's virtual CPUs.
        memory_rss_bytes:
          type: integer
          minimum: 0
          description: |
            Resident memory of the VMM process on the host. It counts the guest
            pages the guest has touched and does not shrink when the guest frees
            memory.
        memory_bytes:
          type: integer
          minimum: 0
          description: The VM's configured memory.
    AppInstanceMetrics:
      type: object
      title: AppInstanceMetrics
      description: Recent samples of one instance of an app.
      required:
        - vm_id
        - node_id
        - samples
      properties:
        vm_id:
          type: string
          pattern: '^vm_[0-9A-HJKMNP-TV-Z]{26}$'
          description: The instance.
        node_id:
          type: string
          pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Node that runs it.
        samples:
          type: array
          description: Samples, oldest first; empty when the instance is not running.
          items:
            $ref: '#/components/schemas/VmMetricSample'
    AppMetrics:
      type: object
      title: AppMetrics
      description: |
        Recent CPU and memory samples of an app's instances, read from their
        nodes on demand and never stored by the control plane (ADR 0014).
      required:
        - app_id
        - interval_s
        - instances
      properties:
        app_id:
          type: string
          pattern: '^app_[0-9A-HJKMNP-TV-Z]{26}$'
          description: The app.
        interval_s:
          type: integer
          minimum: 1
          description: Seconds between samples (5).
        instances:
          type: array
          description: One entry per instance.
          items:
            $ref: '#/components/schemas/AppInstanceMetrics'
    CreateTerminalSessionRequest:
      type: object
      title: CreateTerminalSessionRequest
      description: |
        Open a web terminal in one of the app's VMs (ADR 0113). The program runs
        inside the app's guest, in the app's root filesystem, with the app's
        environment and working directory.
      required:
        - rows
        - cols
      properties:
        rows:
          type: integer
          minimum: 1
          maximum: 1000
          description: Initial terminal height in rows.
        cols:
          type: integer
          minimum: 1
          maximum: 1000
          description: Initial terminal width in columns.
        command:
          type: array
          minItems: 1
          maxItems: 64
          description: |
            The argv to run. Default `["/bin/sh"]`. Only programs the image
            ships can run; a missing program ends the session with exit code 127.
          items:
            type: string
            minLength: 1
            maxLength: 4096
        user:
          type: string
          minLength: 1
          maxLength: 64
          description: User to run as, a name or uid optionally followed by `:` and a group name or gid. Default the app's own user.
        vm_id:
          type: string
          pattern: '^vm_[0-9A-HJKMNP-TV-Z]{26}$'
          description: The instance to open it in. Default the app's running instance (M4 apps have one).
    CreateConsoleSessionRequest:
      type: object
      title: CreateConsoleSessionRequest
      description: Open a read-only view of a VM's serial console (ADR 0116). Every field is optional.
      properties:
        tail_bytes:
          type: integer
          minimum: 0
          maximum: 1048576
          description: Replay at most this many bytes of the VM's recent console output before live output. Default all the node keeps (up to 1 MiB, covering the current and the previous boot).
    InteractiveSessionTicket:
      type: object
      title: InteractiveSessionTicket
      description: |
        An opened terminal or console session and the single-use ticket that
        attaches a WebSocket to it (ADR 0114). The ticket is valid for 30
        seconds and for one upgrade; it is returned once and never stored.
      required:
        - id
        - kind
        - vm_id
        - ticket
        - expires_at
        - websocket_path
      properties:
        id:
          type: string
          pattern: '^trm_[0-9A-HJKMNP-TV-Z]{26}$'
          description: Session id.
        kind:
          type: string
          description: Whether this is a web terminal or a read-only console.
          enum:
            - terminal
            - console
        vm_id:
          type: string
          pattern: '^vm_[0-9A-HJKMNP-TV-Z]{26}$'
          description: The VM the session is attached to.
        ticket:
          type: string
          minLength: 43
          maxLength: 64
          description: Single-use ticket, at least 256 random bits, base64url without padding.
        expires_at:
          type: string
          format: date-time
          description: When the ticket stops being accepted (30 s after the open).
        websocket_path:
          type: string
          description: Path to upgrade, `/api/v1/interactive-sessions/{session_id}/ws`; the ticket goes in the `ticket` query parameter.
    InteractiveSessionCloseReason:
      type: string
      title: InteractiveSessionCloseReason
      description: |
        Why a terminal or console session closed. It is sent to the client in
        the `closed` control message and recorded in `session.end`
        (audit-log-format.md section 3.3).

        - `exited`: the terminal's program exited.
        - `ended_by_client`: the client closed the WebSocket.
        - `ended_by_request`: `DELETE /api/v1/interactive-sessions/{session_id}`.
        - `ticket_expired`: no WebSocket attached within 30 s.
        - `idle_timeout`: no client input for 15 minutes.
        - `max_duration`: open for 8 hours.
        - `access_revoked`: the identity was disabled, its role or grant no longer covers the session.
        - `credential_expired`: the cookie or token that opened it expired or was revoked.
        - `vm_stopped`: the VM stopped (terminal) or was deleted (console).
        - `node_disconnected`: the node session dropped; sessions do not resume.
        - `transcript_limit`: the terminal's transcript reached its 16 MiB cap.
        - `primary_restarted`: the primary restarted while the session was open.
        - `error`: anything else; the `error` control message carried the detail.
      enum:
        - 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
paths:
  /api/v1/health:
    get:
      tags: [health]
      operationId: getHealth
      summary: Liveness of the node that served the call
      description: |
        Unauthenticated liveness probe. It answers whether this node's API is up
        and reveals no state and no secret. Readiness of the cluster, the change
        log position and the version are on `GET /api/v1/cluster`.
      x-quoro-scope: public
      security: []
      responses:
        '200':
          description: The node is alive.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthStatus'
        '500':
          $ref: '#/components/responses/Internal'
  /api/v1/setup:
    post:
      tags: [health]
      operationId: setup
      summary: Create the first owner while no user exists
      description: |
        First-run setup. It creates the first human as `owner` and is accepted only
        while the `users` table is empty and the call carries a valid, unexpired,
        unused setup token (D-0054). This is the only way a cluster gets its first
        user. The call is public because no identity exists yet, and it is still
        listed as `security: []`. A missing, wrong, expired or already-used token
        is refused with `unauthenticated` (401); the token is consumed on success
        and every later call fails with `conflict` (409).
      x-quoro-scope: public
      security: []
      parameters:
        - $ref: '#/components/parameters/QuoroSetupToken'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: Email, name and password of the first owner.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetupRequest'
      responses:
        '201':
          description: The first owner was created and the setup token was consumed.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/v1/auth/login:
    post:
      tags: [auth]
      operationId: login
      summary: Log a human in and set the session cookie
      description: |
        Email and password login for humans. On success the response sets the
        `quoro_session` cookie and answers with the identity. Agents never log in
        this way; they mint access tokens from an agent key (D-0029, D-0030).
      x-quoro-scope: public
      security: []
      requestBody:
        required: true
        description: Email and password.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Login succeeded; the session cookie is set.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
            Set-Cookie:
              description: The `quoro_session` session cookie.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MeResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/auth/logout:
    post:
      tags: [auth]
      operationId: logout
      summary: End the current session
      description: |
        Deletes the session behind the current cookie. Other sessions of the same
        user stay valid. `Quoro-Justification` is required when the caller is an
        agent (D-0031).
      x-quoro-scope: authenticated
      parameters:
        - $ref: '#/components/parameters/QuoroJustification'
      responses:
        '204':
          description: The session was deleted.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
  /api/v1/auth/me:
    get:
      tags: [auth]
      operationId: getMe
      summary: Read the current identity
      description: |
        The identity behind the credential. A human answers with `user`; an agent
        answers with `agent` and its active grants, which is what the agent checks
        before it plans a call (D-0029, D-0030).
      x-quoro-scope: authenticated
      responses:
        '200':
          description: The current identity.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MeResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/auth/password:
    post:
      tags: [auth]
      operationId: changePassword
      summary: Change the signed-in human's own password
      description: |
        Self-service password change for the human behind the panel session
        (ADR 0135). It needs no `user:write`: every human may change their own
        password, and only their own. Only the `quoro_session` cookie
        authenticates this operation. A request that carries an
        `Authorization` header is `unauthenticated` (401) whatever the
        credential, so neither a personal API token nor an agent access token
        can change a password. The request must prove the current password: a
        wrong `current_password` answers `validation_failed` (422) and is
        audited as `refused` with `result_detail` `wrong_password`
        (audit-log-format.md section 3.4). `new_password` follows the rule of
        every other password (at least 12 characters) and must differ from the
        current one. Attempts are rate limited per user; a limited
        call answers `rate_limited` (429) and is audited (permission-model.md
        section 12). On success the password hash changes through the change
        log, every other panel session of the user ends, the session that made
        the call stays valid, and personal API tokens are left as they are.
      x-quoro-scope: human
      security:
        - sessionCookie: []
      requestBody:
        required: true
        description: The current password and the new one.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangePasswordRequest'
      responses:
        '204':
          description: The password changed and the user's other panel sessions ended.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/auth/agent-token:
    post:
      tags: [auth]
      operationId: createAgentToken
      summary: Mint a short-lived agent access token from an agent key
      description: |
        Exchanges an agent key for an access token valid 5 minutes (D-0030). The
        token carries no scope: each call is authorized against the agent's grants,
        so revoking a grant locks the agent out within seconds. A disabled agent is
        refused with `forbidden` and `reason: agent_disabled`. The call creates a
        token, so `Idempotency-Key` is accepted, and `Quoro-Justification` is
        required because the caller is an agent (D-0031).
      x-quoro-scope: authenticated
      security:
        - agentKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
      responses:
        '200':
          description: A new access token.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/tokens:
    get:
      tags: [tokens]
      operationId: listTokens
      summary: List the caller's API tokens
      description: |
        Personal API tokens of the authenticated human. The secret is never
        returned; only the hash is stored.
      x-quoro-scope: human
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of the caller's tokens.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiTokenList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [tokens]
      operationId: createToken
      summary: Create a personal API token
      description: |
        Creates a token for the authenticated human. The secret is returned once in
        this response and cannot be read again. Only a human may call this
        operation; an agent caller is refused with `403 forbidden`,
        `reason: human_only`.
      x-quoro-scope: human
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: Label and optional expiry of the token.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTokenRequest'
      responses:
        '201':
          description: The token was created; the secret is in this response only.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiTokenCreated'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/tokens/{token_id}:
    delete:
      tags: [tokens]
      operationId: revokeToken
      summary: Revoke one of the caller's API tokens
      description: |
        Revokes a token of the authenticated human. Revocation is immediate. The
        caller may not revoke a token of another user. Only a human may call this
        operation; an agent caller is refused with `403 forbidden`,
        `reason: human_only`.
      x-quoro-scope: human
      parameters:
        - $ref: '#/components/parameters/TokenId'
      responses:
        '204':
          description: The token was revoked.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/cluster:
    get:
      tags: [cluster]
      operationId: getCluster
      summary: Read cluster identity and change-log position
      description: |
        Cluster id and name, binary version, the node that holds the primary
        status, and the position of the change log. The primary is the single
        writer and source of truth (D-0012); `log_seq` and `log_head_hash` say how
        far the state has been written. Exposed as the MCP tool `get_cluster`
        (D-0033).
      x-quoro-scope: cluster:read
      x-quoro-mcp-tool: get_cluster
      responses:
        '200':
          description: The cluster.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Cluster'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '503':
          $ref: '#/components/responses/NotPrimary'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/nodes:
    get:
      tags: [nodes]
      operationId: listNodes
      summary: List nodes
      description: |
        All nodes of the cluster with capacity, allocation, KVM probe, session
        status and how far each has verified the change log. Exposed as the MCP
        tool `list_nodes` (D-0033).
      x-quoro-scope: node:read
      x-quoro-mcp-tool: list_nodes
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of nodes.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NodeList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/nodes/{node_id}:
    get:
      tags: [nodes]
      operationId: getNode
      summary: Read one node
      description: |
        One node. `status` is the session lifecycle, `cp_status` the control-plane
        status, `desired_rev` and `applied_rev` the reconciliation gap. Exposed as
        the MCP tool `get_node` (D-0033).
      x-quoro-scope: node:read
      x-quoro-mcp-tool: get_node
      parameters:
        - $ref: '#/components/parameters/NodeId'
      responses:
        '200':
          description: The node.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Node'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags: [nodes]
      operationId: removeNode
      summary: Remove a node from the cluster
      description: |
        Removes a node. Removing a node is destructive and human-only (D-0030):
        an agent is refused with `forbidden` and `reason: human_only`, or with
        `approval_required` when it asks for approval first. A node that still
        hosts running instances is refused with `conflict`. The node moves to
        `removed` and reconciliation stops targeting it. `Quoro-Justification` is
        required when the caller is an agent (D-0031).
      x-quoro-scope: node:remove
      parameters:
        - $ref: '#/components/parameters/NodeId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '202':
          description: Removal accepted; the node status becomes `removed`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Node'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/nodes/{node_id}/fence-ack:
    post:
      tags: [nodes]
      operationId: ackNodeFence
      summary: Acknowledge a node's failed fences
      description: |
        Records a human's acknowledgement that the VMs of the listed failed
        fences are stopped (D-0074, D-0079). The scope is human-only: an agent is
        refused with `forbidden` and `reason: human_only`. `lease_ids` must equal
        the node's outstanding `fence_failure.lease_ids`; a node without one, or a
        list that differs, is refused with `conflict`. In one transaction the
        primary increments the fencing token of each listed lease, as when a node
        is declared lost, clears `fence_failure`, and queues the `fence_ack`
        command for the node (node-agent-protocol.md sections 5.17 and 8.5); from
        then on those leases may be granted again. The call is audited (D-0031).
      x-quoro-scope: node:fence_ack
      parameters:
        - $ref: '#/components/parameters/NodeId'
      requestBody:
        required: true
        description: The lease ids of the failed fences being acknowledged.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FenceAckRequest'
      responses:
        '200':
          description: The acknowledgement was recorded; the node is returned with `fence_failure` null.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Node'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/nodes/{node_id}/tunnel:
    put:
      tags: [nodes]
      operationId: setNodeTunnel
      summary: Map a node to a tunnel provider
      description: |
        Records which `tunnel` provider runs this node's tunnel (D-0094, D-0095).
        The primary then creates or looks up the node's tunnel with that
        provider's account credential and sends the node only the tunnel id and
        its run token in desired state (node-agent-protocol.md section 5.11).
        Replacing the provider of a node that serves `tunnel` domains is refused
        with `conflict`; remove those domains first. Mapping nodes to provider
        credentials is human-only: an agent's access token is refused with
        `forbidden` and `reason: human_only` (D-0029, D-0051). The call is
        audited (D-0031).
      x-quoro-scope: provider:manage
      parameters:
        - $ref: '#/components/parameters/NodeId'
      requestBody:
        required: true
        description: The tunnel provider and the node's loopback origin port.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetNodeTunnelRequest'
      responses:
        '200':
          description: The mapping was recorded; the node is returned with its `tunnel`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Node'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
    delete:
      tags: [nodes]
      operationId: deleteNodeTunnel
      summary: Remove a node's tunnel mapping
      description: |
        Removes the node's tunnel mapping. The primary deletes the node's tunnel
        at the provider and stops sending `spec.ingress.tunnel`, so the node stops
        its tunnel connector. A node that still serves `tunnel` domains is refused
        with `conflict`. Human-only like the other provider operations (D-0051).
        The call is audited (D-0031).
      x-quoro-scope: provider:manage
      parameters:
        - $ref: '#/components/parameters/NodeId'
      responses:
        '204':
          description: The mapping was removed.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/nodes/join-tokens:
    post:
      tags: [nodes]
      operationId: createNodeJoinToken
      summary: Create a one-time node join token
      description: |
        Creates a one-time enrollment token (`njt_...`, default 1 hour) and answers
        with the token secret once and the one-line join command. Only the hash is
        stored, so the secret cannot be read again. Creating join tokens is
        human-only (D-0030); an agent is refused with `reason: human_only`.
        `Quoro-Justification` is required when the caller is an agent (D-0031).
      x-quoro-scope: node:enroll
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
      requestBody:
        required: false
        description: Optional expiry of the token.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NodeJoinTokenRequest'
      responses:
        '201':
          description: The token and the join command; the secret is in this response only.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NodeJoinTokenCreated'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/nodes/enroll:
    post:
      tags: [nodes]
      operationId: enrollNode
      summary: Enroll a new node with a join token
      description: |
        Self-enrollment of a node. The node presents the join token secret, its
        name, its Ed25519 public key, its roles, capacity and KVM probe; the reply
        carries the node id, cluster id, the primary URL and a node credential that
        authenticates the node-agent session. The token is consumed and cannot be
        reused. The message protocol is in `docs/contracts/node-agent-protocol.md`.
        This call is public because a node has no credential yet; the join token is
        the proof.
      x-quoro-scope: public
      security: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: Join token, node facts and public key.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EnrollNodeRequest'
      responses:
        '201':
          description: The node is enrolled; the credential is in this response only.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnrollNodeResponse'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/nodes/session:
    get:
      tags: [nodes]
      operationId: openNodeSession
      summary: Open the node-agent session
      description: |
        The dial-out session between a node and the control plane. The request
        carries the node credential; on success the node upgrades the connection to
        a WebSocket (HTTP 101). Nodes behind NAT dial out, so the control plane
        never connects to a node. The message types (`hello`, `heartbeat`,
        `status_report`, `desired_state`, `lease_grant`, `log_entries`,
        `stream_open`, `command`, ...), reconciliation and self-fencing are fixed by
        `docs/contracts/node-agent-protocol.md` (D-0019). This endpoint is the only
        consumer of the `node` scope value.
      x-quoro-scope: node
      security:
        - nodeCredential: []
      responses:
        '101':
          description: Switching protocols; the connection is now a WebSocket.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
            Upgrade:
              description: Always `websocket`.
              schema:
                type: string
                enum: [websocket]
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/projects:
    get:
      tags: [projects]
      operationId: listProjects
      summary: List projects
      description: |
        Projects group apps and are the unit a grant's selector can name by slug
        (D-0030). Exposed as the MCP tool `list_projects` (D-0033).
      x-quoro-scope: project:read
      x-quoro-mcp-tool: list_projects
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of projects.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [projects]
      operationId: createProject
      summary: Create a project
      description: |
        Creates a project with a unique slug. `Quoro-Justification` is required when
        the caller is an agent (D-0031).
      x-quoro-scope: project:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: true
        description: Slug and name of the project.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateProjectRequest'
      responses:
        '201':
          description: The project was created.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/projects/{project_id}:
    get:
      tags: [projects]
      operationId: getProject
      summary: Read one project
      description: One project by id.
      x-quoro-scope: project:read
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      responses:
        '200':
          description: The project.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      tags: [projects]
      operationId: updateProject
      summary: Change a project
      description: |
        Changes the slug or the name. `Quoro-Justification` is required when the
        caller is an agent (D-0031).
      x-quoro-scope: project:write
      parameters:
        - $ref: '#/components/parameters/ProjectId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: true
        description: Fields to change; absent fields stay unchanged.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateProjectRequest'
      responses:
        '200':
          description: The updated project.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
    delete:
      tags: [projects]
      operationId: deleteProject
      summary: Delete an empty project
      description: |
        Deletes a project that holds no apps. A non-empty project is refused with
        `conflict`. Deletion is destructive: an agent needs just-in-time approval
        (D-0030). `Quoro-Justification` is required when the caller is an agent
        (D-0031).
      x-quoro-scope: project:delete
      parameters:
        - $ref: '#/components/parameters/ProjectId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '204':
          description: The project was deleted.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/ApprovalRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/vms:
    get:
      tags: [vms]
      operationId: listVms
      summary: List VMs
      description: |
        MicroVMs, optionally filtered by project or node. Instances of an app appear
        here too: they are VMs whose `app_id` is set.
      x-quoro-scope: vm:read
      parameters:
        - name: project_id
          in: query
          required: false
          description: Only VMs of this project.
          schema:
            type: string
            pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
        - name: node_id
          in: query
          required: false
          description: Only VMs placed on this node.
          schema:
            type: string
            pattern: '^nod_[0-9A-HJKMNP-TV-Z]{26}$'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of VMs.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VmList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [vms]
      operationId: createVm
      summary: Create a VM
      description: |
        Creates a microVM record and asks the node to bring it up. The response
        carries the record; `observed_status` follows through `creating` to
        `running`. `Quoro-Justification` is required when the caller is an agent
        (D-0031).
      x-quoro-scope: vm:create
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: true
        description: Placement, size and image of the VM.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateVmRequest'
      responses:
        '201':
          description: The VM record was created.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Vm'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/vms/{vm_id}:
    get:
      tags: [vms]
      operationId: getVm
      summary: Read one VM
      description: One VM with its desired and observed status.
      x-quoro-scope: vm:read
      parameters:
        - $ref: '#/components/parameters/VmId'
      responses:
        '200':
          description: The VM.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Vm'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags: [vms]
      operationId: deleteVm
      summary: Delete a VM and its disks
      description: |
        Asks the node to stop and delete the VM and its disks. Deletion is
        destructive: an agent needs just-in-time approval (D-0030). The response is
        the VM record with `desired_status: stopped`; `observed_status` follows to
        `deleted`. `Quoro-Justification` is required when the caller is an agent
        (D-0031).
      x-quoro-scope: vm:delete
      parameters:
        - $ref: '#/components/parameters/VmId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '202':
          description: Deletion accepted; the observed status follows.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Vm'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/ApprovalRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/vms/{vm_id}/start:
    post:
      tags: [vms]
      operationId: startVm
      summary: Start a VM
      description: |
        Sets `desired_status` to `running`. The response is the VM record; the
        observed status follows through `starting` to `running`, and `vm.started` is
        emitted. `Quoro-Justification` is required when the caller is an agent
        (D-0031).
      x-quoro-scope: vm:start
      parameters:
        - $ref: '#/components/parameters/VmId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '202':
          description: Start accepted; the desired status is `running`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Vm'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/vms/{vm_id}/stop:
    post:
      tags: [vms]
      operationId: stopVm
      summary: Stop a VM
      description: |
        Sets `desired_status` to `stopped`. The response is the VM record; the
        observed status follows through `stopping` to `stopped`, and `vm.stopped` is
        emitted. `Quoro-Justification` is required when the caller is an agent
        (D-0031).
      x-quoro-scope: vm:stop
      parameters:
        - $ref: '#/components/parameters/VmId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '202':
          description: Stop accepted; the desired status is `stopped`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Vm'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/apps:
    get:
      tags: [apps]
      operationId: listApps
      summary: List apps
      description: |
        Apps, optionally filtered by project. Exposed as the MCP tool `list_apps`
        (D-0033).
      x-quoro-scope: app:read
      x-quoro-mcp-tool: list_apps
      parameters:
        - name: project_id
          in: query
          required: false
          description: Only apps of this project.
          schema:
            type: string
            pattern: '^prj_[0-9A-HJKMNP-TV-Z]{26}$'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of apps.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [apps]
      operationId: createApp
      summary: Create an app
      description: |
        Creates an app record. The image is rolled out by a deployment, which is a
        separate call. `ha_policy` other than `none` is refused with
        `validation_failed` before M5a (D-0020). `Quoro-Justification` is required
        when the caller is an agent (D-0031).
      x-quoro-scope: app:create
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: true
        description: Project, name, image, port and optional settings.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAppRequest'
      responses:
        '201':
          description: The app was created.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/App'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/apps/{app_id}:
    get:
      tags: [apps]
      operationId: getApp
      summary: Read one app with its instances and domains
      description: |
        One app, its instances (the VMs whose `app_id` is set) and its domains.
        Exposed as the MCP tool `get_app` (D-0033).
      x-quoro-scope: app:read
      x-quoro-mcp-tool: get_app
      parameters:
        - $ref: '#/components/parameters/AppId'
      responses:
        '200':
          description: The app with its instances and domains.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppDetail'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      tags: [apps]
      operationId: updateApp
      summary: Change app settings
      description: |
        Changes settings, not the image: a new image is rolled out as a deployment.
        `ha_policy` other than `none` is refused with `validation_failed` before
        M5a (D-0020). `Quoro-Justification` is required when the caller is an agent
        (D-0031).
      x-quoro-scope: app:update
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: true
        description: Fields to change; absent fields stay unchanged.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAppRequest'
      responses:
        '200':
          description: The updated app.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/App'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
    delete:
      tags: [apps]
      operationId: deleteApp
      summary: Delete an app and its data
      description: |
        Deletes the app, its instances, its domains and its volumes. Deletion is
        destructive and off by default for agents: without an approval grant the
        call is refused with `approval_required`, and a human approves the filed
        request (D-0030). Exposed as the MCP tool `delete_app` (D-0033); the tool
        exists so the approval flow can be exercised. `Quoro-Justification` is
        required when the caller is an agent (D-0031).
      x-quoro-scope: app:delete
      x-quoro-mcp-tool: delete_app
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '202':
          description: Deletion accepted; the instances stop and are removed.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/App'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/ApprovalRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/apps/{app_id}/restart:
    post:
      tags: [apps]
      operationId: restartApp
      summary: Restart an app's instances
      description: |
        Bumps `restart_generation`; the node restarts the instances that carry the
        new generation, and `app.restarted` is emitted. No approval is needed:
        restart is within an agent's normal grant (D-0030). Exposed as the MCP tool
        `restart_app` (D-0033). `Quoro-Justification` is required when the caller is
        an agent (D-0031).
      x-quoro-scope: app:restart
      x-quoro-mcp-tool: restart_app
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '202':
          description: Restart accepted; `restart_generation` was bumped.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/App'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/apps/{app_id}/redeploy:
    post:
      tags: [apps]
      operationId: redeployApp
      summary: Roll out the current image again
      description: |
        Starts a new deployment of the app's current image with `trigger: redeploy`,
        without changing the image. The response is the new deployment; progress is
        read from its logs. Exposed as the MCP tool `redeploy_app` (D-0033).
        `Quoro-Justification` is required when the caller is an agent (D-0031).
      x-quoro-scope: app:redeploy
      x-quoro-mcp-tool: redeploy_app
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '202':
          description: The redeployment was accepted and is now `pending`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Deployment'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/apps/{app_id}/start:
    post:
      tags: [apps]
      operationId: startApp
      summary: Start a stopped app
      description: |
        Starts the instances of a stopped app. A `conflict` is returned when the app
        is already running. `Quoro-Justification` is required when the caller is an
        agent (D-0031).
      x-quoro-scope: app:start
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '202':
          description: Start accepted; the app status follows to `running`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/App'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/apps/{app_id}/stop:
    post:
      tags: [apps]
      operationId: stopApp
      summary: Stop an app
      description: |
        Stops the instances of an app. Its domains stay configured and its
        deployment stays current. `Quoro-Justification` is required when the caller
        is an agent (D-0031).
      x-quoro-scope: app:stop
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '202':
          description: Stop accepted; the app status follows to `stopped`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/App'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/apps/{app_id}/env:
    get:
      tags: [apps]
      operationId: getAppEnv
      summary: List an app's environment variable names
      description: |
        Names and change times only. Values are never returned; they are stored
        encrypted at rest with the cluster key.
      x-quoro-scope: app:read
      parameters:
        - $ref: '#/components/parameters/AppId'
      responses:
        '200':
          description: Names and change times of the app's variables.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppEnvList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    put:
      tags: [apps]
      operationId: replaceAppEnv
      summary: Replace an app's environment
      description: |
        Full replacement: variables not listed are removed, so an empty list clears
        the environment. Values are write-only and never returned by any read.
        `Quoro-Justification` is required when the caller is an agent (D-0031).
      x-quoro-scope: app:update
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: true
        description: The complete list of variables.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AppEnvReplaceRequest'
      responses:
        '200':
          description: The new set of names and change times.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppEnvList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/apps/{app_id}/deployments:
    get:
      tags: [deployments]
      operationId: listAppDeployments
      summary: List deployments of an app
      description: |
        Deployments of one app, newest first. Exposed as the MCP tool
        `list_deployments` (D-0033).
      x-quoro-scope: app:read
      x-quoro-mcp-tool: list_deployments
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of deployments.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeploymentList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [deployments]
      operationId: createDeployment
      summary: Deploy a new image to an app
      description: |
        Starts a deployment of a new image. A deployment that is still in progress
        is superseded. The response is the new deployment; its progress is read from
        `GET /api/v1/deployments/{deployment_id}/logs`. `Quoro-Justification` is
        required when the caller is an agent (D-0031). An app with a Git source
        deploys only what it builds: this call answers `conflict` (409) for it, and
        a new image comes from `POST /api/v1/apps/{app_id}/builds` (ADR 0117).
      x-quoro-scope: app:deploy
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: true
        description: The image to deploy.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateDeploymentRequest'
      responses:
        '202':
          description: The deployment was accepted and is now `pending`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Deployment'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/deployments/{deployment_id}:
    get:
      tags: [deployments]
      operationId: getDeployment
      summary: Read one deployment
      description: |
        One deployment with its status, phase, resolved digest and timestamps.
        Exposed as the MCP tool `get_deployment` (D-0033).
      x-quoro-scope: app:read
      x-quoro-mcp-tool: get_deployment
      parameters:
        - $ref: '#/components/parameters/DeploymentId'
      responses:
        '200':
          description: The deployment.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Deployment'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/deployments/{deployment_id}/logs:
    get:
      tags: [deployments]
      operationId: getDeploymentLogs
      summary: Read a deployment's progress lines
      description: |
        Progress lines of one deployment in order: image pull, rootfs preparation,
        boot, health. A failure stops at the failing phase. The lines are read from
        the node on demand, so an unreachable node answers `node_unreachable`
        (D-0014).
      x-quoro-scope: app:logs
      parameters:
        - $ref: '#/components/parameters/DeploymentId'
      responses:
        '200':
          description: The progress lines.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeploymentLogList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/apps/{app_id}/domains:
    get:
      tags: [domains]
      operationId: listAppDomains
      summary: List an app's domains
      description: Hostnames routed to the app, with their TLS mode and certificate state.
      x-quoro-scope: app:read
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of domains.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DomainList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [domains]
      operationId: createAppDomain
      summary: Route a hostname to an app
      description: |
        Adds a domain. TLS is obtained with ACME for a public-IP host or provided by
        a Cloudflare Tunnel for a host behind NAT (D-0024). The domain records the
        provider that manages its DNS records (`provider_id`, D-0095). A hostname
        already routed elsewhere is refused with `conflict`. `Quoro-Justification` is
        required when the caller is an agent (D-0031).
      x-quoro-scope: app:update
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: true
        description: Hostname and TLS mode.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateDomainRequest'
      responses:
        '201':
          description: The domain was added and its certificate is `pending`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/domains/{domain_id}:
    delete:
      tags: [domains]
      operationId: deleteDomain
      summary: Remove a domain from an app
      description: |
        Removes the hostname and stops serving it. `Quoro-Justification` is required
        when the caller is an agent (D-0031).
      x-quoro-scope: app:update
      parameters:
        - $ref: '#/components/parameters/DomainId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '204':
          description: The domain was removed.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/apps/{app_id}/logs:
    get:
      tags: [logs]
      operationId: getAppLogs
      summary: Read an app's logs
      description: |
        Log lines of the app's instances in time order. Telemetry stays on each node;
        the control plane reads it from the node on demand, so an unreachable node
        answers `node_unreachable` (D-0014). `tail` bounds the answer; `since` and
        `until` bound the window; `instance` narrows to one VM. Exposed as the MCP
        tool `get_app_logs` (D-0033).
      x-quoro-scope: app:logs
      x-quoro-mcp-tool: get_app_logs
      parameters:
        - $ref: '#/components/parameters/AppId'
        - name: since
          in: query
          required: false
          description: Only lines recorded at or after this time.
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          required: false
          description: Only lines recorded at or before this time.
          schema:
            type: string
            format: date-time
        - name: tail
          in: query
          required: false
          description: Maximum number of trailing lines to return.
          schema:
            type: integer
            minimum: 1
            maximum: 5000
            default: 200
        - name: instance
          in: query
          required: false
          description: Only lines of this VM (an instance of the app).
          schema:
            type: string
            pattern: '^vm_[0-9A-HJKMNP-TV-Z]{26}$'
      responses:
        '200':
          description: The requested log lines.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LogList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/apps/{app_id}/logs/stream:
    get:
      tags: [logs]
      operationId: streamAppLogs
      summary: Stream an app's live logs
      description: |
        Server-sent events of live app output, read from the node on demand (D-0014).
        SSE (`text/event-stream`) is the transport for one-way streams;
        WebSockets are used only for bidirectional interactive sessions (D-0057).

        Framing, once the response started with `200` and
        `Content-Type: text/event-stream`: `id:` carries the log sequence number,
        `event:` carries the entry type (`log` for a line, `error` for a node
        problem, `end` when the stream closes), and `data:` carries one JSON object,
        a `LogEntry` for `log` and a `Problem` for `error`. A client resumes with
        `Last-Event-ID` set to the last `id` it saw; the node continues after that
        sequence number. An unreachable node answers `node_unreachable` (504) before
        the stream starts, or an `error` event after it started.
      x-quoro-scope: app:logs
      parameters:
        - $ref: '#/components/parameters/AppId'
        - name: instance
          in: query
          required: false
          description: Only stream this VM (an instance of the app).
          schema:
            type: string
            pattern: '^vm_[0-9A-HJKMNP-TV-Z]{26}$'
        - name: tail
          in: query
          required: false
          description: Number of existing lines to replay before live output.
          schema:
            type: integer
            minimum: 0
            maximum: 5000
            default: 0
        - name: Last-Event-ID
          in: header
          required: false
          description: Last log sequence number the client received; the stream resumes after it.
          schema:
            type: string
      responses:
        '200':
          description: The stream started; events follow until the client disconnects.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
            Cache-Control:
              description: Always `no-cache`; the stream must not be cached.
              schema:
                type: string
                enum: [no-cache]
          content:
            text/event-stream:
              schema:
                type: string
                description: |
                  SSE frames. Each frame is `id: <seq>`, `event: <type>`,
                  `data: <json>`, separated by a blank line.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/users:
    get:
      tags: [users]
      operationId: listUsers
      summary: List human users
      description: |
        Every human identity with its role, so a reader can turn a user id
        (`owner_user_id`, `on_behalf_of`, `decided_by`, `created_by`) into a
        name. `user:read` is in every role bundle; it is human-only, so an agent
        is refused with `forbidden` and `reason: human_only` (D-0030). Changing
        users still needs `user:write`.
      x-quoro-scope: user:read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of users.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [users]
      operationId: createUser
      summary: Create a human user
      description: |
        Creates a human with one of the fixed role bundles. Human-only (D-0030).
        A duplicate email is refused with `conflict`. `Quoro-Justification` is
        required when the caller is an agent (D-0031), although agents cannot pass
        the human-only check.
      x-quoro-scope: user:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
      requestBody:
        required: true
        description: Email, name, role and initial password.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: The user was created.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/users/{user_id}:
    patch:
      tags: [users]
      operationId: updateUser
      summary: Change a human user
      description: |
        Changes name, email, role, password or the `disabled` flag. Human-only
        (D-0030). `Quoro-Justification` is required when the caller is an agent
        (D-0031).
      x-quoro-scope: user:write
      parameters:
        - $ref: '#/components/parameters/UserId'
        - $ref: '#/components/parameters/QuoroJustification'
      requestBody:
        required: true
        description: Fields to change; absent fields stay unchanged.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateUserRequest'
      responses:
        '200':
          description: The updated user.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
    delete:
      tags: [users]
      operationId: deleteUser
      summary: Delete a human user
      description: |
        Deletes a user and revokes its sessions and tokens. The last remaining
        `owner` cannot be deleted: that call is refused with `conflict`. Human-only
        (D-0030). `Quoro-Justification` is required when the caller is an agent
        (D-0031).
      x-quoro-scope: user:write
      parameters:
        - $ref: '#/components/parameters/UserId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '204':
          description: The user was deleted.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/agents:
    get:
      tags: [agents]
      operationId: listAgents
      summary: List agents
      description: Every AI identity with its model, owner and `disabled` flag (D-0029).
      x-quoro-scope: agent:read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of agents.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [agents]
      operationId: createAgent
      summary: Create an agent
      description: |
        Creates an AI identity owned by a human. It starts with no grants and no
        keys, so it can do nothing until a human issues a grant (D-0029, D-0030).
        Human-only: an agent is refused with `reason: human_only`. A duplicate name
        is refused with `conflict`. `Quoro-Justification` is required when the
        caller is an agent (D-0031).
      x-quoro-scope: agent:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
      requestBody:
        required: true
        description: Name, model and owner of the agent.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAgentRequest'
      responses:
        '201':
          description: The agent was created.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Agent'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/agents/{agent_id}:
    get:
      tags: [agents]
      operationId: getAgent
      summary: Read one agent
      description: One agent with its model, owner and `disabled` flag.
      x-quoro-scope: agent:read
      parameters:
        - $ref: '#/components/parameters/AgentId'
      responses:
        '200':
          description: The agent.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Agent'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      tags: [agents]
      operationId: updateAgent
      summary: Change an agent, including disabling it
      description: |
        Changes name, model, owner or description, and sets `disabled`. A disabled
        agent cannot mint access tokens, so disabling takes effect within one token
        lifetime of 5 minutes at most (D-0030). Human-only. `Quoro-Justification` is
        required when the caller is an agent (D-0031).
      x-quoro-scope: agent:write
      parameters:
        - $ref: '#/components/parameters/AgentId'
        - $ref: '#/components/parameters/QuoroJustification'
      requestBody:
        required: true
        description: Fields to change; absent fields stay unchanged.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAgentRequest'
      responses:
        '200':
          description: The updated agent.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Agent'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
    delete:
      tags: [agents]
      operationId: deleteAgent
      summary: Delete an agent
      description: |
        Deletes the agent, revokes its keys and access tokens, and leaves its grants
        and audit records in place: history stays readable (D-0031). Human-only.
        `Quoro-Justification` is required when the caller is an agent (D-0031).
      x-quoro-scope: agent:write
      parameters:
        - $ref: '#/components/parameters/AgentId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '204':
          description: The agent was deleted.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/agents/{agent_id}/keys:
    get:
      tags: [agents]
      operationId: listAgentKeys
      summary: List an agent's keys
      description: |
        The agent's keys, newest first, including revoked ones, with when each
        was created, last used and revoked. Secrets are never returned: a key's
        secret is shown once, by `createAgentKey`.
      x-quoro-scope: agent:read
      parameters:
        - $ref: '#/components/parameters/AgentId'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of the agent's keys.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentKeyList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [agents]
      operationId: createAgentKey
      summary: Create a key for an agent
      description: |
        Creates an agent key and returns its secret once. A key authorizes nothing
        by itself: it only mints short-lived access tokens, and those are checked
        against the agent's grants (D-0030). Human-only. `Quoro-Justification` is
        required when the caller is an agent (D-0031).
      x-quoro-scope: agent:write
      parameters:
        - $ref: '#/components/parameters/AgentId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
      responses:
        '201':
          description: The key was created; the secret is in this response only.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentKeyCreated'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/agents/{agent_id}/keys/{key_id}:
    delete:
      tags: [agents]
      operationId: revokeAgentKey
      summary: Revoke an agent key
      description: |
        Revokes one key. Access tokens already minted from it stay valid until they
        expire, at most 5 minutes (D-0030). Human-only. `Quoro-Justification` is
        required when the caller is an agent (D-0031).
      x-quoro-scope: agent:write
      parameters:
        - $ref: '#/components/parameters/AgentId'
        - $ref: '#/components/parameters/KeyId'
        - $ref: '#/components/parameters/QuoroJustification'
      responses:
        '204':
          description: The key was revoked.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/grants:
    get:
      tags: [grants]
      operationId: listGrants
      summary: List grants
      description: |
        Grants with their scopes, selector, expiry, uses and status. Filter by agent,
        status or kind to answer what an agent may do right now (D-0030).
      x-quoro-scope: grant:read
      parameters:
        - name: agent_id
          in: query
          required: false
          description: Only grants of this agent.
          schema:
            type: string
            pattern: '^agt_[0-9A-HJKMNP-TV-Z]{26}$'
        - name: status
          in: query
          required: false
          description: Only grants in this status.
          schema:
            type: string
            enum: [active, expired, revoked, exhausted]
        - name: kind
          in: query
          required: false
          description: Only grants of this kind.
          schema:
            type: string
            enum: [standard, incident, approval]
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of grants.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GrantList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [grants]
      operationId: createGrant
      summary: Create a standard grant
      description: |
        Issues a standard grant: scopes, a resource selector and a mandatory expiry
        (`expires_at` or `ttl_s`), optionally with `max_uses` (D-0030). Only
        `kind: standard` is accepted here. Human-only and destructive scopes are
        rejected with `validation_failed`, because an agent may never widen its own
        power and a destructive action needs approval. Creating grants is human-only.
        `Quoro-Justification` is required when the caller is an agent (D-0031).
      x-quoro-scope: grant:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: true
        description: Agent, scopes, selector, expiry and reason.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateGrantRequest'
      responses:
        '201':
          description: The grant was issued and is `active`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Grant'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/grants/{grant_id}:
    get:
      tags: [grants]
      operationId: getGrant
      summary: Read one grant
      description: One grant with its remaining uses and expiry.
      x-quoro-scope: grant:read
      parameters:
        - $ref: '#/components/parameters/GrantId'
      responses:
        '200':
          description: The grant.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Grant'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/grants/{grant_id}/revoke:
    post:
      tags: [grants]
      operationId: revokeGrant
      summary: Revoke a grant
      description: |
        Revokes a grant immediately: the next call of the agent is refused with
        `forbidden` and `reason: grant_revoked` (D-0030). Access tokens already
        minted stay valid, but they authorize nothing without an active grant.
        Creating and revoking grants is human-only. `Quoro-Justification` is
        required when the caller is an agent (D-0031).
      x-quoro-scope: grant:write
      parameters:
        - $ref: '#/components/parameters/GrantId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '200':
          description: The revoked grant; its status is `revoked`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Grant'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/approvals:
    get:
      tags: [approvals]
      operationId: listApprovals
      summary: List approval requests
      description: |
        Requests waiting for a human decision and the ones already decided. Filter
        by status to see the pending queue (D-0030).
      x-quoro-scope: approval:read
      parameters:
        - name: status
          in: query
          required: false
          description: Only requests in this status.
          schema:
            type: string
            enum: [pending, approved, denied, expired]
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of approval requests.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApprovalList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [approvals]
      operationId: createApproval
      summary: File an approval request
      description: |
        Agents only: a human does not need approval for a destructive action. The
        request names one destructive scope, the target, the operation it intends to
        call and the exact params; a human approves or denies it and the approval
        issues a short-lived extra grant scoped to that target with one use and a
        10 minute TTL (D-0030). A request expires 1 hour after filing if nobody
        decides. Exposed as the MCP tool `request_approval` (D-0033); the MCP tool
        carries `justification` and the optional `incident_id` as arguments.
        `Quoro-Justification` is required when the caller is an agent (D-0031).
      x-quoro-scope: approval:create
      x-quoro-mcp-tool: request_approval
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: true
        description: Scope, target, operation, params and justification.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateApprovalRequest'
      responses:
        '201':
          description: The request was filed and is `pending`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Approval'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/approvals/{approval_id}:
    get:
      tags: [approvals]
      operationId: getApproval
      summary: Read one approval request
      description: |
        One request with its status, the decision and the grant it produced. Exposed
        as the MCP tool `get_approval` (D-0033).
      x-quoro-scope: approval:read
      x-quoro-mcp-tool: get_approval
      parameters:
        - $ref: '#/components/parameters/ApprovalId'
      responses:
        '200':
          description: The approval request.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Approval'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/approvals/{approval_id}/approve:
    post:
      tags: [approvals]
      operationId: approveApproval
      summary: Approve a request and issue its grant
      description: |
        A human approves the request; the approval grant is issued with that one
        scope, a selector limited to the target, `max_uses` 1 and a TTL of 10
        minutes (D-0030). Deciding is human-only, so an agent cannot approve its own
        request. `approval.approved` is emitted. `Quoro-Justification` is required
        when the caller is an agent (D-0031).
      x-quoro-scope: approval:decide
      parameters:
        - $ref: '#/components/parameters/ApprovalId'
        - $ref: '#/components/parameters/QuoroJustification'
      requestBody:
        required: false
        description: Optional note recorded with the decision.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DecisionRequest'
      responses:
        '200':
          description: The approved request with its `grant_id`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Approval'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/approvals/{approval_id}/deny:
    post:
      tags: [approvals]
      operationId: denyApproval
      summary: Deny an approval request
      description: |
        A human denies the request; no grant is issued and `approval.denied` is
        emitted. Deciding is human-only (D-0030). `Quoro-Justification` is required
        when the caller is an agent (D-0031).
      x-quoro-scope: approval:decide
      parameters:
        - $ref: '#/components/parameters/ApprovalId'
        - $ref: '#/components/parameters/QuoroJustification'
      requestBody:
        required: false
        description: Optional note recorded with the decision.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DecisionRequest'
      responses:
        '200':
          description: The denied request; its status is `denied`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Approval'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/incidents:
    get:
      tags: [incidents]
      operationId: listIncidents
      summary: List incidents
      description: |
        Incidents opened by a detection, newest first: the subject, the triggering
        event and whether they are still open (D-0032). Pages follow the incident
        id newest first; `next_cursor` is an opaque string that continues after
        the last incident of the page. Exposed as the MCP tool
        `list_incidents` (D-0033).
      x-quoro-scope: incident:read
      x-quoro-mcp-tool: list_incidents
      parameters:
        - name: status
          in: query
          required: false
          description: Only incidents in this status.
          schema:
            type: string
            enum: [open, resolved]
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of incidents.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IncidentList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/incidents/{incident_id}:
    get:
      tags: [incidents]
      operationId: getIncident
      summary: Read one incident with its trigger event and grants
      description: |
        One incident, the event that opened it, and the incident grants issued for
        it. This is the context an agent needs before it acts (D-0030, D-0032).
        Exposed as the MCP tool `get_incident` (D-0033).
      x-quoro-scope: incident:read
      x-quoro-mcp-tool: get_incident
      parameters:
        - $ref: '#/components/parameters/IncidentId'
      responses:
        '200':
          description: The incident with its trigger event and grants.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IncidentDetail'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/incidents/{incident_id}/resolve:
    post:
      tags: [incidents]
      operationId: resolveIncident
      summary: Resolve an incident
      description: |
        Closes an incident and records who did it and why; `incident.resolved` is
        emitted and the incident grants stop being usable (D-0030, D-0032). Exposed
        as the MCP tool `resolve_incident` (D-0033); the MCP tool carries
        `justification` and the optional `incident_id` as arguments.
        `Quoro-Justification` is required when the caller is an agent (D-0031).
      x-quoro-scope: incident:resolve
      x-quoro-mcp-tool: resolve_incident
      parameters:
        - $ref: '#/components/parameters/IncidentId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: false
        description: Optional note describing the resolution.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResolveIncidentRequest'
      responses:
        '200':
          description: The resolved incident.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Incident'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/audit:
    get:
      tags: [audit]
      operationId: listAudit
      summary: List audit records
      description: |
        The append-only, hash-chained audit log in ascending `seq` order. Filter by
        actor, target, result or incident, or resume after a sequence number.
        Secrets in `params` are redacted (D-0031). Exposed as the MCP tool
        `list_audit` (D-0033).
      x-quoro-scope: audit:read
      x-quoro-mcp-tool: list_audit
      parameters:
        - name: actor_id
          in: query
          required: false
          description: Only records of this actor (user, agent or node id).
          schema:
            type: string
        - name: target_id
          in: query
          required: false
          description: Only records about this resource id.
          schema:
            type: string
        - name: result
          in: query
          required: false
          description: Only records with this result.
          schema:
            type: string
            enum: [ok, refused, error]
        - name: incident_id
          in: query
          required: false
          description: Only records that belong to this incident.
          schema:
            type: string
            pattern: '^inc_[0-9A-HJKMNP-TV-Z]{26}$'
        - $ref: '#/components/parameters/AfterSeq'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of audit records.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/audit/verify:
    post:
      tags: [audit]
      operationId: verifyAudit
      summary: Verify the audit hash chain
      description: |
        Recomputes the hash links of a range of the audit log, by default the whole
        log. A broken link sets `ok: false` with `broken_at_seq` and `reason`, and
        emits `audit.chain_broken` (D-0031). The range bounds are optional.
        `Quoro-Justification` is required when the caller is an agent (D-0031).
      x-quoro-scope: audit:read
      parameters:
        - $ref: '#/components/parameters/QuoroJustification'
      requestBody:
        required: false
        description: Optional range to verify; the whole log by default.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuditVerificationRequest'
      responses:
        '200':
          description: The verification result.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditVerificationResult'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/audit/{seq}:
    get:
      tags: [audit]
      operationId: getAuditRecord
      summary: Read one audit record
      description: One record by sequence number, with its hashes.
      x-quoro-scope: audit:read
      parameters:
        - $ref: '#/components/parameters/Seq'
      responses:
        '200':
          description: The audit record.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditRecord'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/events:
    get:
      tags: [events]
      operationId: listEvents
      summary: List events
      description: |
        The event log in ascending `seq` order. `type` may be repeated to select
        several types; `subject_id` narrows to one resource and `after_seq` resumes
        a read (D-0032). Pages follow `seq` ascending; `next_cursor` is an opaque
        string that continues after the last event of the page. Exposed as the MCP tool `list_events` (D-0033).
      x-quoro-scope: event:read
      x-quoro-mcp-tool: list_events
      parameters:
        - name: type
          in: query
          required: false
          description: Only events of this type. Repeat the parameter to select several.
          style: form
          explode: true
          schema:
            type: array
            items:
              $ref: '#/components/schemas/EventType'
        - name: subject_id
          in: query
          required: false
          description: Only events about this resource id.
          schema:
            type: string
        - $ref: '#/components/parameters/AfterSeq'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of events.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/events/stream:
    get:
      tags: [events]
      operationId: streamEvents
      summary: Stream events
      description: |
        Server-sent events of new events. SSE (`text/event-stream`) is the
        transport for one-way streams; WebSockets are used only for bidirectional
        interactive sessions (D-0057).

        Framing, once the response started with `200` and
        `Content-Type: text/event-stream`: `id:` carries the event `seq`, `event:`
        carries the event type, and `data:` carries one JSON `Event` object. A
        client resumes with `Last-Event-ID` set to the last `seq` it saw, so no
        event is lost across a reconnect. The optional `type` filter works as on
        `GET /api/v1/events`.
      x-quoro-scope: event:read
      parameters:
        - name: type
          in: query
          required: false
          description: Only stream events of this type. Repeat to select several.
          style: form
          explode: true
          schema:
            type: array
            items:
              $ref: '#/components/schemas/EventType'
        - name: subject_id
          in: query
          required: false
          description: Only stream events about this resource id.
          schema:
            type: string
        - name: Last-Event-ID
          in: header
          required: false
          description: Last event `seq` the client received; the stream resumes after it.
          schema:
            type: string
      responses:
        '200':
          description: The stream started; events follow until the client disconnects.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
            Cache-Control:
              description: Always `no-cache`; the stream must not be cached.
              schema:
                type: string
                enum: [no-cache]
          content:
            text/event-stream:
              schema:
                type: string
                description: |
                  SSE frames. Each frame is `id: <seq>`, `event: <type>`,
                  `data: <json>`, separated by a blank line.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/webhooks:
    get:
      tags: [webhooks]
      operationId: listWebhooks
      summary: List webhooks
      description: Event subscriptions of the cluster, without their signing secrets.
      x-quoro-scope: webhook:read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of webhooks.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [webhooks]
      operationId: createWebhook
      summary: Create a webhook
      description: |
        Subscribes a receiver to events. The response carries the signing secret
        once; deliveries are signed with it as
        `Quoro-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`
        and a receiver rejects a signature older than 5 minutes (D-0032). Managing
        webhooks is human-only. `Quoro-Justification` is required when the caller is
        an agent (D-0031).
      x-quoro-scope: webhook:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
      requestBody:
        required: true
        description: Name, URL, event filter and optional wake configuration.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
      responses:
        '201':
          description: The webhook was created; the secret is in this response only.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookCreated'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/webhooks/{webhook_id}:
    get:
      tags: [webhooks]
      operationId: getWebhook
      summary: Read one webhook
      description: One webhook, its event filter and selector, and its wake configuration.
      x-quoro-scope: webhook:read
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: The webhook.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      tags: [webhooks]
      operationId: updateWebhook
      summary: Change a webhook
      description: |
        Changes name, URL, event filter, selector, wake configuration or the
        `enabled` flag. The signing secret cannot be changed; delete and recreate
        the webhook to rotate it. `wake` can be changed but not removed: an
        absent `wake` leaves it unchanged and `null` is not accepted. To stop a
        webhook from waking an agent, delete it and create it again without
        `wake`; the new webhook has a new signing secret. Human-only (D-0030). `Quoro-Justification` is
        required when the caller is an agent (D-0031).
      x-quoro-scope: webhook:write
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/QuoroJustification'
      requestBody:
        required: true
        description: Fields to change; absent fields stay unchanged.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateWebhookRequest'
      responses:
        '200':
          description: The updated webhook.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
    delete:
      tags: [webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook
      description: |
        Deletes the subscription together with its delivery history: its
        delivery rows are deleted with it (db-schema.sql, ON DELETE CASCADE).
        Pending retries stop. Human-only (D-0030). `Quoro-Justification` is required when the
        caller is an agent (D-0031).
      x-quoro-scope: webhook:write
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '204':
          description: The webhook was deleted.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/webhooks/{webhook_id}/test:
    post:
      tags: [webhooks]
      operationId: testWebhook
      summary: Send a signed test delivery
      description: |
        Sends one test delivery to the receiver, signed like a real delivery, and
        records it in the delivery history. It is the fastest way to check the
        receiver verifies `Quoro-Signature` correctly. Quoro has no test event
        type: the body is the newest event of the event log, replayed as-is, and
        an empty event log answers `validation_failed` (422). A test delivery is
        attempted once, never retried, never issues an incident grant and never
        carries `data.wake`. It is recorded as its own delivery, separate from
        the real deliveries of the same event, so it never replaces, blocks or
        collides with them; its `test` field is true. Human-only (D-0030).
        `Quoro-Justification` is required when the caller is an agent (D-0031).
      x-quoro-scope: webhook:write
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/QuoroJustification'
      responses:
        '202':
          description: The test delivery was attempted and recorded.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDelivery'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/webhooks/{webhook_id}/deliveries:
    get:
      tags: [webhooks]
      operationId: listWebhookDeliveries
      summary: List a webhook's deliveries
      description: |
        Delivery attempts of one webhook, newest first, with the receiver's response
        code, any error and the next attempt time. Test deliveries are listed
        alongside real ones and carry `test: true`.
      x-quoro-scope: webhook:read
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of deliveries.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveryList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/providers:
    get:
      tags: [providers]
      operationId: listProviders
      summary: List providers
      description: |
        Every provider entry of the cluster, without their secrets. Managing
        providers is human-only: an agent's access token is refused with
        `forbidden` and `reason: human_only` (D-0029, D-0051).
      x-quoro-scope: provider:manage
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of providers.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [providers]
      operationId: createProvider
      summary: Create a provider
      description: |
        Stores credentials for an external provider. The secret is encrypted with
        the cluster data-encryption key and never returned (D-0050). Managing
        providers is human-only: an agent's access token is refused with
        `forbidden` and `reason: human_only` (D-0029, D-0051).
      x-quoro-scope: provider:manage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: Kind, name, non-secret config and the secret.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateProviderRequest'
      responses:
        '201':
          description: The provider entry was created; its secret is not in the response.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Provider'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/providers/{provider_id}:
    get:
      tags: [providers]
      operationId: getProvider
      summary: Read one provider
      description: |
        One provider entry, its non-secret config and whether a secret is stored.
        Managing providers is human-only: an agent's access token is refused with
        `forbidden` and `reason: human_only` (D-0029, D-0051).
      x-quoro-scope: provider:manage
      parameters:
        - $ref: '#/components/parameters/ProviderId'
      responses:
        '200':
          description: The provider entry.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Provider'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags: [providers]
      operationId: deleteProvider
      summary: Delete a provider
      description: |
        Deletes the provider entry and its stored secret. A provider that is still
        referenced by a domain (`provider_id`), a node's tunnel mapping
        (`PUT /api/v1/nodes/{node_id}/tunnel`) or the off-site audit copy cannot
        be deleted and is refused with `conflict` (D-0095). Managing providers is human-only: an agent's access token is
        refused with `forbidden` and `reason: human_only` (D-0029, D-0051).
      x-quoro-scope: provider:manage
      parameters:
        - $ref: '#/components/parameters/ProviderId'
      responses:
        '204':
          description: The provider entry was deleted.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/providers/{provider_id}/rotate:
    post:
      tags: [providers]
      operationId: rotateProviderSecret
      summary: Replace a provider's secret
      description: |
        Replaces the stored secret and sets `rotated_at`; the old secret stops
        being used immediately. Managing providers is human-only: an agent's
        access token is refused with `forbidden` and `reason: human_only`
        (D-0029, D-0051).
      x-quoro-scope: provider:manage
      parameters:
        - $ref: '#/components/parameters/ProviderId'
      requestBody:
        required: true
        description: The new secret.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RotateProviderSecretRequest'
      responses:
        '200':
          description: The secret was replaced; `rotated_at` is set.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Provider'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/apps/{app_id}/builds:
    get:
      tags: [builds]
      operationId: listAppBuilds
      summary: List builds of an app
      description: |
        Builds of one app, newest first (ADR 0117). Each item carries its
        status and, for a failure, the failing step, so the panel shows a
        failure while the node is down (ADR 0119).
      x-quoro-scope: app:read
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of builds.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BuildList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [builds]
      operationId: startBuild
      summary: Start a build of an app's Git source
      description: |
        Queues a build of the app's Git source on the node that runs the app,
        or on the node the scheduler picks when the app has no instance yet
        (ADR 0118). The build runs in an ephemeral builder microVM; when it
        succeeds, the control plane sets the app's `image` to the built image
        and starts a deployment with `trigger: build`, which is why this needs
        `app:deploy`. A node runs one build at a time; further builds wait in
        `queued`. Emits `build.started`; the outcome is `build.succeeded`,
        `build.failed` or `build.cancelled`. `conflict` (409) when the app has
        no Git source or already has a build that is not terminal.
        `Quoro-Justification` is required when the caller is an agent (ADR 0031).
      x-quoro-scope: app:deploy
      parameters:
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: false
        description: Optional overrides for this build.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StartBuildRequest'
      responses:
        '202':
          description: The build was accepted and is `queued`.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Build'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/builds/{build_id}:
    get:
      tags: [builds]
      operationId: getBuild
      summary: Read one build
      description: One build with its status, resolved commit, failing step and the deployment it started.
      x-quoro-scope: app:read
      parameters:
        - $ref: '#/components/parameters/BuildId'
      responses:
        '200':
          description: The build.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Build'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/builds/{build_id}/cancel:
    post:
      tags: [builds]
      operationId: cancelBuild
      summary: Cancel a build
      description: |
        Asks the node to stop the build; the node stops the builder VM and the
        build ends `cancelled`, which emits `build.cancelled`. The answer is the
        build as recorded, which may still show its running status until the
        node confirms. `conflict` (409) when the build is already terminal.
        `Quoro-Justification` is required when the caller is an agent (ADR 0031).
      x-quoro-scope: app:deploy
      parameters:
        - $ref: '#/components/parameters/BuildId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      responses:
        '202':
          description: The cancel was recorded and sent to the node.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Build'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/builds/{build_id}/logs:
    get:
      tags: [builds]
      operationId: getBuildLogs
      summary: Read a build's log
      description: |
        One page of a build's log, oldest first. The log stays on the node that
        ran the build and is read from it on demand (ADR 0014, ADR 0119), so an
        unreachable node answers `node_unreachable` (504) and a node that no
        longer keeps the log (it keeps the last 20 builds of each app) answers
        `not_found`. Pages follow `next_cursor`; on a build that is still running
        the last page grows, and live lines are better read from
        `GET /api/v1/builds/{build_id}/logs/stream`.
      x-quoro-scope: app:logs
      parameters:
        - $ref: '#/components/parameters/BuildId'
        - name: limit
          in: query
          required: false
          description: Maximum number of lines to return.
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 500
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: One page of log lines.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BuildLogList'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/builds/{build_id}/logs/stream:
    get:
      tags: [builds]
      operationId: streamBuildLogs
      summary: Stream a build's log live
      description: |
        Server-sent events of a build's log, read from the node on demand
        (ADR 0014, ADR 0057, ADR 0119). Framing, once the response started with
        `200` and `Content-Type: text/event-stream`: `id:` carries the line's
        `seq`, `event:` carries `log` (a `BuildLogLine`), `status` (the `Build`
        whenever its status changes), `error` (a `Problem`) or `end` (sent once
        the build is terminal and every line was sent; the stream then closes).
        A client resumes with `Last-Event-ID` set to the last `seq` it saw. A
        finished build replays its log and ends. An unreachable node answers
        `node_unreachable` (504) before the stream starts, or an `error` event
        after it started.
      x-quoro-scope: app:logs
      parameters:
        - $ref: '#/components/parameters/BuildId'
        - name: tail
          in: query
          required: false
          description: Start with the last `tail` lines instead of the whole log. Ignored when `Last-Event-ID` is sent.
          schema:
            type: integer
            minimum: 0
            maximum: 5000
        - name: Last-Event-ID
          in: header
          required: false
          description: Last line `seq` the client received; the stream resumes after it.
          schema:
            type: string
      responses:
        '200':
          description: The stream started; events follow until the build is terminal or the client disconnects.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
            Cache-Control:
              description: Always `no-cache`; the stream must not be cached.
              schema:
                type: string
                enum: [no-cache]
          content:
            text/event-stream:
              schema:
                type: string
                description: |
                  SSE frames. Each frame is `id: <seq>` (log lines only),
                  `event: <type>`, `data: <json>`, separated by a blank line.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/nodes/{node_id}/metrics:
    get:
      tags: [metrics]
      operationId: getNodeMetrics
      summary: Read a node's recent CPU and memory
      description: |
        The node's samples of the last 10 minutes, read from the node over its
        session on demand and never stored by the control plane (ADR 0014). An
        unreachable node answers `node_unreachable` (504).
      x-quoro-scope: node:read
      parameters:
        - $ref: '#/components/parameters/NodeId'
      responses:
        '200':
          description: The node's recent samples.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NodeMetrics'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/nodes/{node_id}/metrics/stream:
    get:
      tags: [metrics]
      operationId: streamNodeMetrics
      summary: Stream a node's CPU and memory live
      description: |
        Server-sent events with one `sample` event (a `NodeMetricSample`) per
        sampling interval (5 s), read from the node on demand (ADR 0014,
        ADR 0057), plus `error` (a `Problem`) on a node problem. Events carry no
        `id:`; a client that reconnects reads the history again from
        `GET /api/v1/nodes/{node_id}/metrics`.
      x-quoro-scope: node:read
      parameters:
        - $ref: '#/components/parameters/NodeId'
      responses:
        '200':
          description: The stream started; events follow until the client disconnects.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
            Cache-Control:
              description: Always `no-cache`; the stream must not be cached.
              schema:
                type: string
                enum: [no-cache]
          content:
            text/event-stream:
              schema:
                type: string
                description: "SSE frames `event: <type>`, `data: <json>`, separated by a blank line."
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/apps/{app_id}/metrics:
    get:
      tags: [metrics]
      operationId: getAppMetrics
      summary: Read an app's recent CPU and memory
      description: |
        The last 10 minutes of samples of each of the app's instances, read
        from their nodes on demand and never stored by the control plane
        (ADR 0014). An unreachable node answers `node_unreachable` (504).
      x-quoro-scope: app:read
      parameters:
        - $ref: '#/components/parameters/AppId'
      responses:
        '200':
          description: The instances' recent samples.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppMetrics'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/apps/{app_id}/metrics/stream:
    get:
      tags: [metrics]
      operationId: streamAppMetrics
      summary: Stream an app's CPU and memory live
      description: |
        Server-sent events with one `sample` event per instance and sampling
        interval (5 s), its `data` a `VmMetricSample` plus `vm_id` and
        `node_id`, read from the nodes on demand (ADR 0014, ADR 0057), plus
        `error` (a `Problem`) on a node problem. Events carry no `id:`; a client
        that reconnects reads the history again from
        `GET /api/v1/apps/{app_id}/metrics`.
      x-quoro-scope: app:read
      parameters:
        - $ref: '#/components/parameters/AppId'
      responses:
        '200':
          description: The stream started; events follow until the client disconnects.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
            Cache-Control:
              description: Always `no-cache`; the stream must not be cached.
              schema:
                type: string
                enum: [no-cache]
          content:
            text/event-stream:
              schema:
                type: string
                description: "SSE frames `event: <type>`, `data: <json>`, separated by a blank line."
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/apps/{app_id}/terminal-sessions:
    post:
      tags: [interactive]
      operationId: createTerminalSession
      summary: Open a web terminal in an app
      description: |
        Opens a PTY session inside the app's guest, started by Quoro's init
        over virtio-vsock and relayed by the node agent (ADR 0113). `app:terminal`
        is human-only in M4 (ADR 0114). This call is the audited open: its
        record carries the session id, VM, size, command and user
        (audit-log-format.md section 3.3). The answer is a single-use ticket
        valid for 30 seconds; the client upgrades
        `GET /api/v1/interactive-sessions/{session_id}/ws?ticket=...` with it.
        Every session ends with a `session.end` audit record, and its timed
        transcript stays on the node (ADR 0115). `conflict` (409) when the
        app has no running instance, and when its VM was created before M4
        and has no vsock device: a restart keeps a VM's devices, so only a
        redeploy, which creates a new VM, enables the terminal (ADR 0136);
        `node_unreachable` (504) when the node is not connected.
      x-quoro-scope: app:terminal
      parameters:
        - $ref: '#/components/parameters/AppId'
      requestBody:
        required: true
        description: Terminal size and, optionally, the command, user and instance.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTerminalSessionRequest'
      responses:
        '201':
          description: The session is open and waits for its WebSocket.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InteractiveSessionTicket'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/vms/{vm_id}/console-sessions:
    post:
      tags: [interactive]
      operationId: createConsoleSession
      summary: Open a read-only view of a VM's serial console
      description: |
        Opens a read-only session on the VM's serial console: the node replays
        its recent console output (the current and the previous boot, up to
        1 MiB) and then follows live output (ADR 0116). The console carries
        kernel and init diagnostics, such as the guest's boot output. Input is
        refused: nothing in the guest reads the serial port. `vm:console` is
        grantable to agents (ADR 0114). This call is the audited open; the
        session ends with a `session.end` record and has no transcript
        (audit-log-format.md section 3.3). The answer is a single-use ticket
        valid for 30 seconds. `conflict` (409) when the node cannot serve the
        session: its node does not have the VM, or the VM was created
        before M4 and its serial port is not a socket; a restart keeps
        a VM's devices, so only a redeploy, which creates a new VM, enables the
        console (ADR 0136). `node_unreachable` (504) when the node is not
        connected. `Quoro-Justification` is required when the caller is an
        agent (ADR 0031).
      x-quoro-scope: vm:console
      parameters:
        - $ref: '#/components/parameters/VmId'
        - $ref: '#/components/parameters/QuoroJustification'
        - $ref: '#/components/parameters/QuoroIncident'
      requestBody:
        required: false
        description: How much recent output to replay.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateConsoleSessionRequest'
      responses:
        '201':
          description: The session is open and waits for its WebSocket.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InteractiveSessionTicket'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
  /api/v1/interactive-sessions/{session_id}:
    delete:
      tags: [interactive]
      operationId: endInteractiveSession
      summary: End a terminal or console session
      description: |
        Closes an open session: the WebSocket gets a `closed` control message
        with reason `ended_by_request` and a normal close, and the primary
        writes the `session.end` record naming who ended it. Allowed for the
        identity that opened the session and for any human with the `owner`
        role; anyone else gets `forbidden` with reason `scope_missing`. A
        session that already ended answers `not_found`.
      x-quoro-scope: authenticated
      parameters:
        - $ref: '#/components/parameters/InteractiveSessionId'
        - $ref: '#/components/parameters/QuoroJustification'
      responses:
        '204':
          description: The session was closed.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotPrimary'
  /api/v1/interactive-sessions/{session_id}/ws:
    get:
      tags: [interactive]
      operationId: connectInteractiveSession
      summary: Attach a WebSocket to a terminal or console session
      description: |
        WebSocket upgrade (RFC 6455) for an opened session (ADR 0057, ADR 0114).
        The single-use `ticket` from the open authorizes the upgrade; the
        request needs no other credential, and a cookie or bearer token it
        carries is ignored. A spent, expired or unknown ticket, or one minted
        for another session, answers `401` before the upgrade. When the request
        carries an `Origin` header it must equal the panel's own origin, and a
        ticket minted for a panel session cookie requires one; otherwise `403`
        before the upgrade (cross-site WebSocket hijacking). A session accepts
        one WebSocket; a second upgrade answers `409`.

        Framing after the upgrade:

        - **Binary frames** carry raw terminal bytes in both directions: the
          program's output (or the console's) from the server, the user's
          input to the server. A console session refuses client binary frames
          with an `error` control message and ignores them.
        - **Text frames** carry one JSON control object each. Client to server:
          `{"type":"resize","rows":R,"cols":C}` (terminal only). Server to
          client: `{"type":"exit","code":N}` when the program exited,
          `{"type":"closed","reason":R}` with an `InteractiveSessionCloseReason`
          just before the server closes the WebSocket, and
          `{"type":"error","code":C,"message":M}` with a `Problem` code. Unknown
          control types are answered with `error` and ignored.
        - The server closes with status 1000 after `closed`. A client close
          ends the session with reason `ended_by_client`.

        The panel must render the stream as a terminal (xterm.js), never as
        HTML, and must not act on escape sequences that reach the browser
        (clipboard writes through OSC 52, links opened without a click),
        because the guest is untrusted. The session is re-checked while it is
        open (permission-model.md section 7.5): idle 15 minutes, at most 8 hours.
      x-quoro-scope: public
      security: []
      parameters:
        - $ref: '#/components/parameters/InteractiveSessionId'
        - name: ticket
          in: query
          required: true
          description: The single-use ticket from the open.
          schema:
            type: string
            minLength: 43
            maxLength: 64
      responses:
        '101':
          description: Switching protocols; the WebSocket carries the session.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/interactive-sessions/{session_id}/transcript:
    get:
      tags: [interactive]
      operationId: getSessionTranscript
      summary: Fetch a terminal session's transcript
      description: |
        The timed transcript of a terminal session, as asciicast v2
        (newline-delimited JSON: a header line, then `[seconds, "i"|"o"|"r", data]`
        events), relayed from the node that recorded it (ADR 0014, ADR 0115).
        Its SHA-256 equals `transcript_sha256` in the session's `session.end`
        audit record unless the file was altered. A transcript holds every
        secret that appeared in the session, so this needs `audit:read` and a
        human caller: an agent is refused with `human_only`, and every fetch is
        audited, also a human's (audit-log-format.md section 3.3). Console
        sessions have no transcript. `not_found` when the session, its
        transcript or the node's copy is gone (kept 30 days, at most 1 GiB per
        node); `node_unreachable` (504) when the node is not connected.
      x-quoro-scope: audit:read
      parameters:
        - $ref: '#/components/parameters/InteractiveSessionId'
      responses:
        '200':
          description: The transcript file, streamed from the node.
          headers:
            Quoro-Request-Id:
              $ref: '#/components/headers/QuoroRequestId'
          content:
            application/x-asciicast:
              schema:
                type: string
                description: asciicast v2, one JSON value per line.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '504':
          $ref: '#/components/responses/NodeUnreachable'
webhooks:
  event:
    post:
      operationId: receiveEventDelivery
      summary: Receive one event delivery
      description: |
        The request a webhook receiver gets when an event fires its subscription
        (D-0032). The body is one event envelope: `id`, `seq`, `type`, `time`,
        `source`, `subject`, `project_id`, `incident_id`, `request_id`, `actor` and
        the type-specific `data`; the full envelope and every `data` payload are
        defined by `docs/contracts/events.schema.json`. The receiver is expected to
        answer `2xx`; anything else is retried with exponential backoff from 10
        seconds doubling to 1 hour, at most 10 attempts.

        Headers: `Quoro-Event-Id` is the event id, `Quoro-Event-Type` is its type,
        `Quoro-Delivery-Id` is the delivery attempt id (`whd_...`) and
        `Quoro-Signature` is `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw
        body>">` computed with the webhook secret. A receiver rejects a signature
        older than 5 minutes and should answer `2xx` to stop retries.
      tags: [webhooks]
      parameters:
        - name: Quoro-Event-Id
          in: header
          required: true
          description: Id of the delivered event.
          schema:
            type: string
            pattern: '^evt_[0-9A-HJKMNP-TV-Z]{26}$'
        - name: Quoro-Event-Type
          in: header
          required: true
          description: Type of the delivered event.
          schema:
            $ref: '#/components/schemas/EventType'
        - name: Quoro-Delivery-Id
          in: header
          required: true
          description: Id of this delivery attempt.
          schema:
            type: string
            pattern: '^whd_[0-9A-HJKMNP-TV-Z]{26}$'
        - name: Quoro-Signature
          in: header
          required: true
          description: |
            Signature over the raw body: `t=<unix seconds>,v1=<hex HMAC-SHA256 of
            "<t>.<raw body>">`.
          schema:
            type: string
      requestBody:
        required: true
        description: The event envelope, as defined by `docs/contracts/events.schema.json`.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Event'
      responses:
        '2XX':
          description: |
            Any 2xx answer acknowledges the delivery and stops retries. The receiver
            body is ignored.
