{
  "openapi": "3.1.0",
  "info": {
    "title": "Fleetless API",
    "version": "1",
    "description": "Generated from the route manifest the cloud is tested against. Request and response shapes are the same JSON Schemas the platform validates with."
  },
  "servers": [
    {
      "url": "https://api.fleetless.dev"
    }
  ],
  "tags": [
    {
      "name": "health",
      "description": "Health"
    },
    {
      "name": "developer-auth",
      "description": "Developer auth"
    },
    {
      "name": "client-auth",
      "description": "App-user (client) auth"
    },
    {
      "name": "org",
      "description": "Org"
    },
    {
      "name": "billing",
      "description": "Billing"
    },
    {
      "name": "users",
      "description": "Team"
    },
    {
      "name": "apps",
      "description": "Apps"
    },
    {
      "name": "robots",
      "description": "Robots"
    },
    {
      "name": "config",
      "description": "Configuration (draft/publish)"
    },
    {
      "name": "alerts",
      "description": "Alerts"
    },
    {
      "name": "commands",
      "description": "Commands (jobs, publishers)"
    },
    {
      "name": "cameras",
      "description": "Cameras"
    },
    {
      "name": "assets",
      "description": "Assets (URDF, meshes)"
    },
    {
      "name": "mcp",
      "description": "MCP"
    },
    {
      "name": "transports",
      "description": "Realtime and bridge transports"
    }
  ],
  "paths": {
    "/api/auth/refresh": {
      "post": {
        "operationId": "post_api_auth_refresh",
        "summary": "Rotates a developer refresh token and mints a fresh access token.",
        "tags": [
          "developer-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/session-tokens"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The whole family is re-checked here, not just the token: an account that has been removed from the org, or whose `token_version` was bumped by an owner's two-factor reset, cannot mint a fresh console token and answers `token_revoked`. Refusing that only on the other routes would leave a session that is dead everywhere but here.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/refresh-request"
              }
            }
          }
        }
      }
    },
    "/api/auth/logout": {
      "post": {
        "operationId": "post_api_auth_logout",
        "summary": "Revokes the whole refresh family behind a developer refresh token.",
        "tags": [
          "developer-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Unauthenticated by design — the refresh token in the body is the credential. A token the server does not recognise is still a `204`: the end state a caller asked for is the end state they get, and distinguishing the two would say whether a token ever existed. Open `/realtime` sockets for the session are closed too.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/refresh-request"
              }
            }
          }
        }
      }
    },
    "/api/auth/me": {
      "get": {
        "operationId": "get_api_auth_me",
        "summary": "Answers the calling developer and the org they belong to.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/auth-me-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "patch_api_auth_me",
        "summary": "Changes the calling developer's own display name and nothing else.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/auth-me-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "No Owner tier: this can only ever touch the caller's own row, so there is nothing for a tier check to gate. Saving the name already held writes nothing and records no audit event — the org activity stream reaches every developer with the console open, and an event for a no-op would misreport that something changed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/patch-auth-me-request"
              }
            }
          }
        }
      }
    },
    "/api/auth/two-factor": {
      "get": {
        "operationId": "get_api_auth_two_factor",
        "summary": "Answers the calling developer's passkeys, authenticator, recovery codes left and the org's policy.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/developer-two-factor"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "What Settings › Profile › Security draws. No key material, secret or code travels here — the passkeys are names and dates, the authenticator is a date, the recovery codes are a count."
      }
    },
    "/api/auth/passkeys/options": {
      "post": {
        "operationId": "post_api_auth_passkeys_options",
        "summary": "Answers the WebAuthn creation options for registering a passkey.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/webauthn-options-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Hand `options` to the browser's WebAuthn API as it is. The relying party is `fleetless.dev`, so the passkey works on the auth portal and in the console alike; user verification is required and the credential is discoverable, so it can sign the person in without an address. The passkeys the caller already has are excluded. The challenge is single-use and expires with the ceremony."
      }
    },
    "/api/auth/passkeys": {
      "post": {
        "operationId": "post_api_auth_passkeys",
        "summary": "Registers a passkey from the browser's answer to the creation options.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/create-passkey-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A ceremony that does not verify — a wrong challenge, origin or relying party, no user verification — is `400 validation_error` naming `credential`. When this is the account's first second factor, ten recovery codes are issued and answered once; otherwise `recovery_codes` is `null` and the existing ones stay valid. Audited as `developer.two_factor_added` with `details.kind` `passkey`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/create-passkey-request"
              }
            }
          }
        }
      }
    },
    "/api/auth/passkeys/{id}": {
      "patch": {
        "operationId": "patch_api_auth_passkeys_id",
        "summary": "Renames one of the caller's passkeys.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The passkey's uuid, as listed by `GET /api/auth/two-factor`; another person's passkey answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/developer-passkey"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/rename-passkey-request"
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "delete_api_auth_passkeys_id",
        "summary": "Removes one of the caller's passkeys.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The passkey's uuid, as listed by `GET /api/auth/two-factor`; another person's passkey answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `target_state_conflict`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`409 target_state_conflict` names `two_factor` with rule `required_by_org` when this is the caller's last second factor and the organisation requires one. Removing the last one otherwise also voids the recovery codes. Audited as `developer.two_factor_removed` with `details.kind` `passkey`."
      }
    },
    "/api/auth/totp": {
      "post": {
        "operationId": "post_api_auth_totp",
        "summary": "Starts an authenticator setup and answers its secret and otpauth URL.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/two-factor-setup-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The secret is pending until `POST /api/auth/totp/confirm` accepts a code from it; a second call replaces a pending secret. A developer who already has an authenticator keeps it until the new one is confirmed, which is how `Replace…` works."
      },
      "delete": {
        "operationId": "delete_api_auth_totp",
        "summary": "Removes the caller's authenticator app.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `not_found`, `target_state_conflict`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`404 not_found` when there is no authenticator. `409 target_state_conflict` names `two_factor` with rule `required_by_org` when it is the caller's last second factor and the organisation requires one. Audited as `developer.two_factor_removed` with `details.kind` `authenticator`."
      }
    },
    "/api/auth/totp/confirm": {
      "post": {
        "operationId": "post_api_auth_totp_confirm",
        "summary": "Confirms the pending authenticator with a code it shows now.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/totp-confirm-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `rate_limited`, `validation_error`, `invalid_code`, `token_spent`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A code that does not match the pending secret is `400 invalid_code`; no pending setup is `410 token_spent`. On success the new authenticator replaces any earlier one. Ten recovery codes are answered when it is the account's first second factor, otherwise `null`. Audited as `developer.two_factor_added` with `details.kind` `authenticator`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/totp-confirm-request"
              }
            }
          }
        }
      }
    },
    "/api/auth/recovery-codes": {
      "post": {
        "operationId": "post_api_auth_recovery_codes",
        "summary": "Issues ten new recovery codes and voids the old ones.",
        "tags": [
          "developer-auth"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/recovery-codes-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `target_state_conflict`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The codes are shown this once. `409 target_state_conflict` names `two_factor` with rule `off` when the caller has no second factor: recovery codes only stand in for one. Audited as `developer.recovery_codes_generated`."
      }
    },
    "/api/waitlist": {
      "post": {
        "operationId": "post_api_waitlist",
        "summary": "Adds an address to the closed-beta waiting list.",
        "tags": [
          "developer-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "202": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `202` whether or not the address was already listed: the landing page's form must not be an oracle for who signed up. The operator notification is detached from the response — awaiting it made latency answer the question the status code refuses to — and is capped by its own global ceiling, above which the row is still written and the mail is skipped.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/waitlist-request"
              }
            }
          }
        }
      }
    },
    "/api/audit": {
      "get": {
        "operationId": "get_api_audit",
        "summary": "Reads the org's audit log, newest first, cursor-paged over the durable sequence number.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "before_seq",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,19}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,4}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "action",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 80
            }
          },
          {
            "name": "action_prefix",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 80
            }
          },
          {
            "name": "actor_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            }
          },
          {
            "name": "target_kind",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 40
            }
          },
          {
            "name": "target_id",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Events whose target is this id; for a robot also the events that name it in `details.robot_id` (`action.invoked`, `service.called`, …), so a robot's events include what was started on it.",
              "type": "string",
              "minLength": 1,
              "maxLength": 200
            }
          },
          {
            "name": "from_ms",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "to_ms",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/audit-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`action` and `action_prefix` are mutually exclusive, a cross-field rule no JSON Schema can express — this route is where it is enforced. Nothing redacts an event's `details`: it is returned exactly as the call site wrote it."
      }
    },
    "/api/audit/export": {
      "get": {
        "operationId": "get_api_audit_export",
        "summary": "Downloads every audit event matching the same filters as a CSV attachment.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "before_seq",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,19}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,4}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "action",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 80
            }
          },
          {
            "name": "action_prefix",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 80
            }
          },
          {
            "name": "actor_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            }
          },
          {
            "name": "target_kind",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 40
            }
          },
          {
            "name": "target_id",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Events whose target is this id; for a robot also the events that name it in `details.robot_id` (`action.invoked`, `service.called`, …), so a robot's events include what was started on it.",
              "type": "string",
              "minLength": 1,
              "maxLength": 200
            }
          },
          {
            "name": "from_ms",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "to_ms",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `text/csv; charset=utf-8` with a `Content-Disposition` attachment, not JSON — so it has no response schema. `AUDIT_CSV_COLUMNS` names the columns and their order. Takes the same filters as `GET /api/audit` but refuses `before_seq` and `limit` with `400 validation_error`: an export is not a page, it is everything the filter matches up to a fixed row ceiling."
      }
    },
    "/api/apps": {
      "post": {
        "operationId": "post_api_apps",
        "summary": "Creates an app, optionally attaching robots to it at the same time.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`, `identifier_taken`, `quota_exceeded`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Every robot id is checked before anything is created, so a bad one never leaves a robotless app to clean up. The identifier `mcp` is reserved by the central MCP server and refused as a `validation_error`. An app belongs to the org and to nothing inside it: the group an app used to be created in, and the `409 target_state_conflict` that refused the Org Admins one, are both gone with the group model.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/create-app-request"
              }
            }
          }
        }
      },
      "get": {
        "operationId": "get_api_apps",
        "summary": "Lists every app in the caller's org.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"apps\": [app, …] }` — the whole org, unpaged; an org's app count is bounded by quota."
      }
    },
    "/api/apps/{id}": {
      "get": {
        "operationId": "get_api_apps_id",
        "summary": "Reads one app of the org, with its robots and default role.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "An app belonging to another org reads exactly like one that does not exist — `404`, never a `403`."
      },
      "patch": {
        "operationId": "patch_api_apps_id",
        "summary": "Changes an app's name, its attached robots or its default role.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A `default_role_id` naming a role of another app is refused: it is the one cross-app authorization check this shape can carry. Changing the robot set closes every live subscription the app's users hold, since a grant may no longer name a reachable robot.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/update-app-request"
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "delete_api_apps_id",
        "summary": "Deletes an app and everything it produced.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier, and the gate runs **after** the org-scoped lookup: a developer-tier admin therefore sees the same `404` a stranger would for an app outside their org, rather than a tier refusal that confirms the id exists. A full cascade — its users, roles, server keys, invitations, OIDC provider configuration and mail templates all go, recorded once as `app.deleted` carrying an `appDeletionSummary`. Its robots are untouched: they belong to the org, not to the app. \n\n**No `force` parameter** — see `GET /api/apps/:id/deletion-preview`."
      }
    },
    "/api/apps/{id}/deletion-preview": {
      "get": {
        "operationId": "get_api_apps_id_deletion_preview",
        "summary": "Reports what deleting the app would destroy, without destroying it.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-deletion-summary"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The same shape the delete's own audit event carries, computed by the same function on purpose: the confirmation dialog and the eventual receipt agree by construction, and any difference between them is real drift rather than two estimates that quietly disagree. \n\n**No `force` parameter, unlike the robot pair this is modelled on.** A robot's open live session is a single nameable state whose interruption is its own hazard, which is why that route makes the caller pass `force` explicitly. An app has no equivalent state to force past, and inventing one would be a guess wearing a guard's clothes — this preview is the guard."
      }
    },
    "/api/apps/{id}/roles": {
      "post": {
        "operationId": "post_api_apps_id_roles",
        "summary": "Creates a custom role on the app.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/role"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The body is `{ \"name\": string }` — non-empty, trimmed, at most 60 characters as on `role.name` — and is deliberately not a contract shape: contracts define the `role` this answers with, not this one trivial request. **The answer is a bare `role`, not an envelope**, unlike the listing beside it."
      },
      "get": {
        "operationId": "get_api_apps_id_roles",
        "summary": "Lists the app's roles, builtin and custom.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/role-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"roles\": [role, …] }`, builtin roles included — a role a developer never created is still one a user can hold."
      }
    },
    "/api/apps/{id}/roles/{roleId}/permissions": {
      "put": {
        "operationId": "put_api_apps_id_roles_roleId_permissions",
        "summary": "Replaces a role's grants and capabilities in one write.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "roleId",
            "in": "path",
            "required": true,
            "description": "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/role-permissions"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`role_id` in the body must name the role in the path, compared case-insensitively — a uuid is a value, not a string, and a client that uppercases them consistently must not be refused for repeating what the path says. A grant naming a robot the app does not have is refused rather than stored: a permission for something the role cannot reach reads as authoritative to whoever writes the next consumer. Every user holding this role has their live subscriptions re-authorized.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/role-permissions"
              }
            }
          }
        }
      },
      "get": {
        "operationId": "get_api_apps_id_roles_roleId_permissions",
        "summary": "Reads a role's grants and capabilities.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "roleId",
            "in": "path",
            "required": true,
            "description": "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/role-permissions"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/roles/{roleId}/mcp-tools": {
      "get": {
        "operationId": "get_api_apps_id_roles_roleId_mcp_tools",
        "summary": "Previews the robot datasheets an MCP caller holding this role would be offered.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "roleId",
            "in": "path",
            "required": true,
            "description": "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/mcp-role-preview-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Built by the same builder the MCP server's own `robot_describe` uses, so the two cannot drift. It answers what the role *would* be offered and consults nothing about any user's actual MCP entitlement. A robot the role grants nothing on still appears, with an empty `exposures` — dropping it would read as \"not attached\", which is a different fact."
      }
    },
    "/api/apps/{id}/roles/{roleId}": {
      "patch": {
        "operationId": "patch_api_apps_id_roles_roleId",
        "summary": "Renames a role; its users keep it.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "roleId",
            "in": "path",
            "required": true,
            "description": "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/role"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`, `role_name_taken`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Names are unique per app, compared exactly as stored after trimming. Built-in roles can be renamed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/role-rename-request"
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "delete_api_apps_id_roles_roleId",
        "summary": "Deletes a role, moving its users, pending invitations and default-role status to another role.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "roleId",
            "in": "path",
            "required": true,
            "description": "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "move_to",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Another role of the same app that takes over the deleted role's app users, pending invitations and, when it applies, the app's default. The role itself or a role of another app answers `400 validation_error`.",
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`, `role_in_use`, `last_role`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Without `move_to`, a role that app users or pending invitations hold, or that is the app's default, answers `409 role_in_use` with `{ users, invitations, is_default }`. With `move_to` — another role of the same app, else `400 validation_error` — one transaction moves `app_users.role_id`, pending invitations and `default_role_id`, then deletes the role and its permissions. The app's only role answers `409 last_role`. Built-in roles can be deleted like any other."
      }
    },
    "/api/apps/{id}/server-keys": {
      "post": {
        "operationId": "post_api_apps_id_server_keys",
        "summary": "Mints a server key for the app and returns the raw secret once.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/create-server-key-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier only: a server key carries full app rights and outlives its creator's removal. The body is `{ \"name\": string }`, the same trivial shape role creation takes. `key` is the only moment the raw secret exists outside the caller's hands — it is never in a listing, never in an audit event, and cannot be read back."
      },
      "get": {
        "operationId": "get_api_apps_id_server_keys",
        "summary": "Lists the app's server keys as metadata, never the secrets.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/server-key-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"server_keys\": [serverKey, …] }`. `serverKey` names the five fields it carries rather than spreading the stored row — that is what keeps this listing from becoming a second place a credential leaves the cloud."
      }
    },
    "/api/apps/{id}/server-keys/{keyId}/rotate": {
      "post": {
        "operationId": "post_api_apps_id_server_keys_keyId_rotate",
        "summary": "Replaces a server key's secret in place and returns the new one once.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "keyId",
            "in": "path",
            "required": true,
            "description": "The server key's uuid, from `GET /api/apps/:id/server-keys`; a key of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/create-server-key-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier, for the reason creation is. The old secret is refused from this call on, and any `/realtime` socket that authenticated with it is closed — rotation is what a developer reaches for when a key has leaked, and the holder of that socket is exactly who they are rotating against."
      }
    },
    "/api/apps/{id}/server-keys/{keyId}": {
      "delete": {
        "operationId": "delete_api_apps_id_server_keys_keyId",
        "summary": "Revokes a server key and closes every socket holding it.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "keyId",
            "in": "path",
            "required": true,
            "description": "The server key's uuid, from `GET /api/apps/:id/server-keys`; a key of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier, like minting and rotating: all three decide who may speak for the whole app."
      }
    },
    "/api/apps/{id}/users": {
      "get": {
        "operationId": "get_api_apps_id_users",
        "summary": "Lists the app's users — the developer's own customers, not the Fleetless team.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-user-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"users\": [appUser, …] }`. **A different identity space from `GET /api/org/users`**, and nothing joins the two: an app user belongs to exactly one app, their address is unique per app rather than globally, and the same address may exist as unrelated accounts in several apps of one org. No password hash, no token and no provider secret appears here — `appUser` names the fields it carries rather than spreading the stored row."
      },
      "post": {
        "operationId": "post_api_apps_id_users",
        "summary": "Creates an app user directly, without an invitation or a self-registration.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-user"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`, `email_taken`, `target_state_conflict`, `quota_exceeded`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The developer-authenticated door into the app's user table, and the one place `409 email_taken` is an honest answer about an app user: the caller is authenticated into this app already, so telling them the address is taken discloses nothing they could not read from the listing beside it. `POST /api/client/register` answers `202` to the same fact, because there the caller is a stranger. `404 not_found` is the app, or a `role_id` that is not a role of it — a role of another app is refused rather than stored, since a user holding one would carry rights nothing in this app can resolve. **The password policy answers `400 validation_error`**, not a code of its own: the twelve-character minimum is the `password` field's schema rule, and every route that takes a password refuses a short one exactly the way it refuses any other malformed field. An account created here is `active` immediately: a developer entering somebody by hand has made the decision the verification mail automates, and its address counts as proven. `409 target_state_conflict` names `default_role_id` when `role_id` is absent and the app has no default role, or its default names a role that no longer resolves — a user with no role holds rights nothing in this app can read, so nothing is created. \n\n**`409 quota_exceeded` when the org holds as many app users as `max_end_users` allows**, counted across every app of the org — the same number `GET /api/org/quotas` reports as `usage.max_end_users`, since the same address in two apps is two accounts. `details` carries `{ quota, limit }`, as every count quota's refusal does. The check is at **creation** only: an existing user signs in, is patched and is deleted at the quota exactly as under it, because a protection limit that also froze the accounts already made would be an outage rather than a limit.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/create-app-user-request"
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/users/{userId}": {
      "get": {
        "operationId": "get_api_apps_id_users_userId",
        "summary": "Reads one user of the app.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "userId",
            "in": "path",
            "required": true,
            "description": "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-user"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A user of another app, or of another org, reads exactly like one that does not exist — `404`, never a `403`."
      },
      "patch": {
        "operationId": "patch_api_apps_id_users_userId",
        "summary": "Changes an app user's display name, role or status.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "userId",
            "in": "path",
            "required": true,
            "description": "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-user"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`, `target_state_conflict`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The address is immutable: it is half of what identifies the account within the app, and a rewrite would silently move every token and invitation addressed to the old one. Setting `status` to `blocked` ends every session the user holds and closes their live `/realtime` subscriptions — blocking somebody who keeps a working socket is not blocking them. Moving them back to `active` mints nothing; they log in again. \n\n**`active` is a way out of `blocked` and out of nothing else.** An account still `pending_verification` answers `409 target_state_conflict` naming `status` with rule `unverified`: activating it would let somebody who typed an address they do not own log in without ever spending the mailed token. Unblocking restores the status the account had — `active` for one whose address was proven, `pending_verification` for one blocked before it ever verified. A write that names the status the account already holds changes nothing and mints no event, so it does not end the sessions a re-sent form would otherwise have killed. A `role_id` naming a role of another app is `404 not_found`, the same refusal creation makes.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/patch-app-user-request"
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "delete_api_apps_id_users_userId",
        "summary": "Deletes an app user and ends every session they hold.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "userId",
            "in": "path",
            "required": true,
            "description": "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Sessions are revoked before the row goes, for the reason `DELETE /api/org/users/:id` states: a live user with a dead session is recoverable by retrying, a deleted user whose token still works is not. Outstanding invitations and unspent tokens for that address are expired with it — a link mailed before the deletion is a standing re-admission ticket. **Nothing outside this app is touched**: a Fleetless user sharing the address keeps their console account, and an account with the same address in a sibling app is a different person as far as this platform is concerned."
      }
    },
    "/api/apps/{id}/users/{userId}/reset-password": {
      "post": {
        "operationId": "post_api_apps_id_users_userId_reset_password",
        "summary": "Mails an app user a password-reset link on the developer's behalf.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "userId",
            "in": "path",
            "required": true,
            "description": "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/mail-outcome"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `target_state_conflict`, `method_not_allowed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The support door beside `POST /api/client/password/reset`, refused like it with `403 method_not_allowed` while the app has the password method off: the same one-hour token and the same link, triggered by a developer for a user who asked them rather than the form. **No enumeration discipline applies** — the caller is authenticated into the app and can read the user list — so this one answers what actually happened: `{ \"mail\": mailStatus }`, where `not_configured` is a deployment without a mailer and `failed` is the state worth somebody's attention. The link points at the app's `reset_url`, or at the hosted reset page when the app has configured none. `409 target_state_conflict` names `password` with rule `not_set` for an account that has none — an OIDC-only app user, whom a reset link would hand a second, quieter door — and `status` with rule `blocked` for a blocked one, since `POST /api/client/password/reset` mails a blocked account nothing and the two doors may not disagree. Setting the password directly is deliberately not offered; a developer who could would hold their customers' credentials."
      }
    },
    "/api/apps/{id}/users/{userId}/two-factor": {
      "delete": {
        "operationId": "delete_api_apps_id_users_userId_two_factor",
        "summary": "Removes an app user's authenticator and recovery codes and ends every session they hold.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "userId",
            "in": "path",
            "required": true,
            "description": "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The support door for a person who lost their authenticator and their recovery codes. The authenticator and every recovery code go, and so does every session of the account — whoever held one may be the reason for the reset. **A user with no second factor answers `204` too**: that is the end state being asked for. When the app requires two-factor, the person sets it up again at their next sign-in, before any session exists. Audited as `app_user.two_factor_reset`."
      }
    },
    "/api/apps/{id}/users/{userId}/mcp-grants": {
      "get": {
        "operationId": "get_api_apps_id_users_userId_mcp_grants",
        "summary": "Lists the MCP clients one app user has consented to.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "userId",
            "in": "path",
            "required": true,
            "description": "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/mcp-consent-grant-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A consent is remembered so that a later authorization can skip the app's own screen, and a client's registration lapsing does not end it — so a person who approved something once had no way back and neither did the developer supporting them. This is the reading half of that door. \n\n**Every name here is a claim the client made about itself.** Dynamic registration takes no credential, so `client_name` is attacker-chosen text, unverified on every row, and `client_name_verified` is the literal `false`; a console that renders it as an identity is rendering a string somebody picked. **Withdrawn grants are absent** rather than listed as withdrawn: the question is what is connected now. \n\nThe user is scoped to the app and the app to the org, so a user of a sibling app and one that does not exist read identically — `404`, never a `403`. **A developer sees which clients their customer connected and nothing those clients did**: this route reads the consent table alone, and no scope, token or session of the person appears in it, because the authorization server issues no scopes at all."
      }
    },
    "/api/apps/{id}/users/{userId}/mcp-grants/{clientId}": {
      "delete": {
        "operationId": "delete_api_apps_id_users_userId_mcp_grants_clientId",
        "summary": "Withdraws one app user's consent to an MCP client, on the developer's behalf.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "userId",
            "in": "path",
            "required": true,
            "description": "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "clientId",
            "in": "path",
            "required": true,
            "description": "The MCP client, as `GET /api/apps/:id/users/:userId/mcp-grants` reports its `client_id`. Not a uuid — the identifier dynamic registration issued.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The support door beside `DELETE /api/client/mcp/grants/:clientId`, which is the same act by the person themselves. Audited as `app_user.mcp_grant_revoked`, whose `details` carry the client id and the app's uuid — and nothing else, in particular no token and no name the client chose for itself. \n\n**`204` whether or not there was anything to withdraw**, so a double-clicked button and a client id no grant names both land on the end state the caller asked for. The alternative — `404` for a client this user never approved — would make the route an oracle for which clients somebody has connected, answered before the listing beside it was read; and it would turn the ordinary retry into a refusal. Only a withdrawal that actually ended a standing agreement writes an audit event, so the log counts consents ended rather than buttons pressed. **`404` is still the app and the user**, which are the two things the caller must own. \n\n**It ends a session already running, at that client's very next call.** The app's MCP endpoint reads this table on every request, beside the account checks it already makes, so a withdrawn client is answered `401` with the `WWW-Authenticate` challenge that sends it back to the consent screen. The refusal is keyed on the `client_id` the access token carries, so it bites at the next call rather than at the next token: that token is still unexpired — up to fifteen minutes are left on it — and is refused anyway. Only this client stops. The person's other clients and their own use of the app are untouched, which is the difference from blocking the account (`PATCH /api/apps/:id/users/:userId`)."
      }
    },
    "/api/apps/{id}/invitations": {
      "get": {
        "operationId": "get_api_apps_id_invitations",
        "summary": "Lists the app's outstanding invitations, without their tokens.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-invitation-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"invitations\": [pendingAppInvitation, …] }` — pending only, since an accepted invitation is history rather than something to revoke. **No `accept_url`**, the rule the team listing already keeps: this list exists so a developer can see what is outstanding and withdraw it, and neither needs the token, while a list that carried it would turn every screenshot and browser-history entry of that page into a live credential for somebody else's account. `mail` is omitted too — it described what happened at creation time, and re-serving it invites a reader to take it as current."
      },
      "post": {
        "operationId": "post_api_apps_id_invitations",
        "summary": "Invites an address into the app with a role, and optionally mails the link.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-invitation"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`, `email_taken`, `target_state_conflict`, `rate_limited`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**An app user, not a team member.** `POST /api/org/invitations` is the other space and leads to the console; this link leads into the developer's own app. The role is resolved and stored now, so a later change to `default_role_id` does not re-aim a link already sent. An invitation **always bypasses `allowed_domains`**. \n\nThe answer carries `accept_url`: the app's `invite_url` with the token in it, or the Fleetless-hosted invitation page when the app has configured none — so mailing it is never refused for a missing URL. `409 target_state_conflict` names `default_role_id` when `role_id` is absent and the app has no default role, or its default no longer resolves: an invitation that names no role has nothing to hand its acceptor, so it is refused here rather than at the acceptance a week later. `409 email_taken` is an address the app already has as a user; `404 not_found` is the app or a `role_id` that is not one of its roles. \n\nCreating shares the reissue route's ceiling of **five invitation mails a minute per app**, answering `429 rate_limited` with `retry_after_ms`: re-creating an invitation for one address replaces it and mails again, so a limit that bound only reissue would be a limit on the wrong door.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/create-app-invitation-request"
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/invitations/{invId}/reissue": {
      "post": {
        "operationId": "post_api_apps_id_invitations_invId_reissue",
        "summary": "Mints a fresh token onto the same invitation and returns the new link.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "invId",
            "in": "path",
            "required": true,
            "description": "The invitation's uuid, from `GET /api/apps/:id/invitations`; an invitation of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-invitation"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `rate_limited`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The old link stops resolving the instant this returns: the row is found by token hash and the previous hash is gone. Two live links to one invitation would reopen the door the listing's missing `accept_url` closes. \n\n**The answer carries a new `id`.** The old row is revoked and a fresh one takes its place, so a caller holding the previous `id` gets `404` from its next revoke or reissue: re-read the listing after this call rather than keeping the id you sent. The seven days start again. \n\nLimited server-side to **five reissues a minute per app** — shared with `POST /api/apps/:id/invitations`, since both mint a link and mail it — answering `429 rate_limited` with `retry_after_ms`. A disabled button is a hint, this is the limit. An invitation that has already been accepted is not pending and answers `404`."
      }
    },
    "/api/apps/{id}/invitations/{invId}": {
      "delete": {
        "operationId": "delete_api_apps_id_invitations_invId",
        "summary": "Revokes a pending invitation so its link stops resolving.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "invId",
            "in": "path",
            "required": true,
            "description": "The invitation's uuid, from `GET /api/apps/:id/invitations`; an invitation of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "An invitation that was already accepted is not pending and answers `404`, the same answer one that never existed gets — the account it created is a user now, and deleting that is `DELETE /api/apps/:id/users/:userId`. A revoked token answers `410 token_spent` at `POST /api/client/invitations/accept`, the same answer one that expired or never existed gets — the developer withdrew it deliberately, and an answer saying so would tell whoever still holds the link that it was once real."
      }
    },
    "/api/apps/{id}/oidc-providers": {
      "get": {
        "operationId": "get_api_apps_id_oidc_providers",
        "summary": "Lists every OIDC provider configured on the app, enabled or not.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-oidc-provider-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"providers\": [appOidcProvider, …] }` — **the management view, so a disabled provider is here** and is absent from the public `GET /api/client/providers`. An app may have any number: the at-most-one rule this replaces was a property of the deleted group, not of identity, and a developer serving two customers needs two. **No client secret appears in the answer**, by construction of `appOidcProvider` — a secret a response can carry is a secret in every log that captured a response, which is the rule server keys and the deleted group provider already kept."
      },
      "post": {
        "operationId": "post_api_apps_id_oidc_providers",
        "summary": "Configures an OIDC provider on the app after checking that its issuer answers.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-oidc-provider"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`, `duplicate_slug`, `provider_misconfigured`, `idp_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**Discovery runs before the row is written**, so a provider that cannot work is refused while the developer is looking at the form rather than a week later in an app user's failed sign-in. `502 idp_unavailable` is an issuer that could not be reached and may work on a retry; `422 provider_misconfigured` is one that answered with something unusable — not a discovery document, an `issuer` disagreeing with the configured one, or an `authorization_endpoint`, `token_endpoint` or `jwks_uri` that is not an http(s) URL — and will answer the same until somebody changes the configuration. That is the whole reason the two codes are separate: one says wait, the other says fix it. \n\nThe issuer is **shape-checked** by `idpIssuer` (http(s), no credentials, query or fragment) and that is not the SSRF defence: it cannot tell a loopback dev provider from a loopback database, and the real check refuses loopback, link-local and private ranges at the fetch itself. `409 duplicate_slug` is a slug this app already uses — slugs are unique per app and immutable, since linked identities are keyed by them. `404 not_found` is the app. **The client secret goes in here and comes back out of nothing**: not this answer, not the read, not an audit detail.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/create-app-oidc-provider-request"
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/oidc-providers/{providerId}": {
      "get": {
        "operationId": "get_api_apps_id_oidc_providers_providerId",
        "summary": "Reads one OIDC provider of the app, without its client secret.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "providerId",
            "in": "path",
            "required": true,
            "description": "The provider's uuid, from `GET /api/apps/:id/oidc-providers`; a provider of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-oidc-provider"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A provider of another app, or of another org, reads exactly like one that does not exist — `404`, never a `403`. The stored client secret is not in `appOidcProvider` and there is no route that reads one back; a developer who has lost theirs sends a replacement through the `PATCH`."
      },
      "patch": {
        "operationId": "patch_api_apps_id_oidc_providers_providerId",
        "summary": "Changes a provider's name, issuer, client, scopes, linking policy or enabled flag.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "providerId",
            "in": "path",
            "required": true,
            "description": "The provider's uuid, from `GET /api/apps/:id/oidc-providers`; a provider of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-oidc-provider"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`, `provider_misconfigured`, `idp_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**`slug` is not in the body and offering it is a `400 validation_error`** naming the field, because the request is strict: the slug is in the path and is what `app_user_identities` rows are keyed by, so a rename would orphan every linked account. An answer that ignored it silently is the failure `updateAppRequest` was made strict to avoid. \n\n**`client_secret` absent means keep the stored one**, so a routine scope edit need not put the secret back on the wire. Changing the issuer re-runs discovery, which is why this route carries the same `422 provider_misconfigured` and `502 idp_unavailable` the create does — and identities linked under the old issuer keep their `(provider, subject)` key rather than being re-resolved. `409 duplicate_slug` is absent because the one field that could collide cannot be written here. Turning `enabled` off keeps the row and its linked identities: the provider disappears from `GET /api/client/providers` and a start answers `provider_disabled`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/patch-app-oidc-provider-request"
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "delete_api_apps_id_oidc_providers_providerId",
        "summary": "Deletes an OIDC provider and every identity linked through it.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "providerId",
            "in": "path",
            "required": true,
            "description": "The provider's uuid, from `GET /api/apps/:id/oidc-providers`; a provider of another app answers `404`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**The linked identities go with it, and the app users do not.** An account that only ever signed in through this provider survives with no way back in until the developer mails them a reset link or re-configures the provider — deleting the accounts instead would make a mistyped click destroy the developer's customers. Setting `enabled` to `false` on the `PATCH` is the reversible door and is what a developer switching a provider off should use; this one is not reversible, because a re-created provider with the same slug resolves no old `(provider, subject)` link. Answers `204` and `404` only: a provider in use is still deleted, since the alternative is a row nothing can remove."
      }
    },
    "/api/apps/{id}/auth-config": {
      "get": {
        "operationId": "get_api_apps_id_auth_config",
        "summary": "Reads the app's auth settings: sign-in methods, two-factor, registration, pages, the hosted look and the MCP switch.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-auth-config"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "One row per app, created with the app and never absent — an app that has configured nothing reads back the defaults rather than a `404`. `oidc_callback_url` is in the answer and not in the request: it is minted by the cloud from its own public base URL, is the same for every app and every provider, and is the value a developer registers at their identity provider. It stays read-only on every slice write below for a second reason: a writable callback URL would let a caller point the return leg of an OIDC sign-in, which carries an authorization code, at a host they own. `updated_at` is read-only for a duller one: the server stamps it on every write, and a client-supplied value would be a lie about when the row last changed. `hosted_pages` and `hosted_logo_url` are read-only as well: the cloud mints both from the auth portal's base URL and the app's identifier."
      }
    },
    "/api/apps/{id}/auth-config/registration": {
      "put": {
        "operationId": "put_api_apps_id_auth_config_registration",
        "summary": "Replaces who may self-register, and from where.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-auth-config"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**A replace, not a merge, and `.strict()`**: `self_registration`, `allowed_domains` and `allowed_origins` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's — see `GET`'s notes for why. \n\n`400 validation_error` is where the two field rules land: an entry in `allowed_domains` must be lowercase, since a capitalised one can never match a lowercased address, and an entry in `allowed_origins` must be a bare scheme-host-port with no path, since a browser sends nothing longer in its `Origin` header. Each refuses at configuration time rather than failing silently later. \n\nThe merge is server-side against the stored row, so this write never disturbs another slice.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/put-app-auth-registration-request"
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/auth-config/sign-in": {
      "put": {
        "operationId": "put_api_apps_id_auth_config_sign_in",
        "summary": "Replaces how the app's users sign in and whether they give a second factor.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-auth-config"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**A replace, not a merge, and `.strict()`**: `sign_in_methods` and `two_factor` both arrive or the write is refused. Both methods off is `400 validation_error` naming `sign_in_methods.password` — an app needs at least one door besides its identity providers. \n\nTurning a method off refuses its routes with `method_not_allowed` from the next request on; a stored password stays stored. Setting `two_factor` to `required` signs nobody out: each person without an authenticator sets one up at their next sign-in, before any session exists. Audited with both old and new values. The merge is server-side against the stored row, so this write never disturbs another slice.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/put-app-auth-sign-in-request"
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/auth-config/urls": {
      "put": {
        "operationId": "put_api_apps_id_auth_config_urls",
        "summary": "Replaces the app's home page and the four pages Fleetless's mails and MCP sign-in point at.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-auth-config"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**A replace, not a merge, and `.strict()`**: `app_url`, `invite_url`, `verify_url`, `reset_url` and `mcp_login_url` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's — see `GET`'s notes for why. \n\nEach may be `null`, and then the Fleetless-hosted page in `hosted_pages` stands in for it: nothing is refused for a missing URL. `400 validation_error` is where the field rules land: a URL template must be https (or `http` on `localhost`) and carry its placeholder exactly once — a second occurrence leaves one literal in a mailed link, refused here rather than failing silently once the mail is sent — and `app_url` takes the same host rule with no placeholder. `mcp_login_url` moved here from the `mcp` slice, because one screen owns all four pages. \n\nThe merge is server-side against the stored row, so this write never disturbs another slice.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/put-app-auth-urls-request"
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/auth-config/mcp": {
      "put": {
        "operationId": "put_api_apps_id_auth_config_mcp",
        "summary": "Turns the app's MCP endpoint on or off.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-auth-config"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**A replace, not a merge, and `.strict()`**: `mcp_enabled` arrives or the write is refused. It used to take `mcp_login_url` as well, because on without a URL refused every sign-in; the hosted MCP sign-in now stands in for an unset URL, and the URL moved to the `urls` slice. A body still carrying it is `400 validation_error`. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's. The merge is server-side against the stored row, so this write never disturbs another slice.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/put-app-auth-mcp-request"
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/auth-config/look": {
      "put": {
        "operationId": "put_api_apps_id_auth_config_look",
        "summary": "Replaces the hosted pages' accent colour.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-auth-config"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**A replace, and `.strict()`**: `hosted_accent` arrives, `#rrggbb` in lowercase, or `null` for the neutral shell's own accent. The logo is its own write, `PUT /api/apps/:id/auth-config/logo`, because it is an image rather than a field. The merge is server-side against the stored row, so this write never disturbs another slice.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/put-app-auth-look-request"
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/auth-config/logo": {
      "put": {
        "operationId": "put_api_apps_id_auth_config_logo",
        "summary": "Stores the logo the hosted pages show above the app's name.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-auth-config"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`, `unsupported_media_type`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The body is the **raw image**, not JSON, so it has no request schema: `Content-Type` is one of `HOSTED_LOGO_TYPES` (`image/png`, `image/svg+xml`) and anything else is `415 unsupported_media_type`. At most `HOSTED_LOGO_MAX_BYTES` (100 KB); a larger body, or one that is not the image its type names, is `400 validation_error`. A new logo replaces the stored one. The hosted pages load it from `hosted_logo_url` as an image only, and the cloud serves an SVG sandboxed, so a script inside one never runs."
      },
      "delete": {
        "operationId": "delete_api_apps_id_auth_config_logo",
        "summary": "Removes the logo from the hosted pages.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-auth-config"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers the whole configuration, with `hosted_logo_url` now `null`; the hosted pages show the app's name alone. An app with no logo answers the same: that is the end state being asked for."
      }
    },
    "/api/apps/{id}/mail-templates": {
      "get": {
        "operationId": "get_api_apps_id_mail_templates",
        "summary": "Lists the custom mail templates the app has, which may be none.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-mail-template-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"templates\": [appMailTemplate, …] }` with **only the kinds that have a custom template** — at most four. A kind that does not appear is one using the Fleetless default text, which is an ordinary state and not a missing row. Mails to *Fleetless* users, a team invitation or a console sign-in code, are not in this list and are deliberately not customisable: they are about this platform, not about the developer's product."
      }
    },
    "/api/apps/{id}/mail-templates/{kind}": {
      "get": {
        "operationId": "get_api_apps_id_mail_templates_kind",
        "summary": "Reads one custom mail template of the app.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-mail-template"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The `kind` segment is a `mailTemplateKind`, so a fourth word is `400 validation_error` — the path names a set that is closed, and answering `404` about it would read as \"this app has no such template\" when the truth is that no app can. `404 not_found` is the app, or a kind this app has left on the Fleetless default: there is no stored row to read back, and inventing one would present the default text as something the developer wrote."
      },
      "put": {
        "operationId": "put_api_apps_id_mail_templates_kind",
        "summary": "Stores or replaces the app's template for one kind of mail, refusing one that does not render.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app-mail-template"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`, `template_invalid`, `rate_limited`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The body carries `subject`, `text` and an optional `html`, each a Liquid template; `kind` is in the path and `updated_at` is the server's, so neither may arrive. `text` is required even when `html` is given — a mail with no text part is unreadable to a client that refuses HTML. \n\n**Liquid runs in strict mode and every part is rendered here before anything is stored**, so `422 template_invalid` is an unknown variable or a syntax error rather than an empty line in a mail somebody already received. Its `details` is a `mailTemplateProblemDetails` naming which of the three parts failed and the renderer's own message, because an error that did not say which leaves the developer re-reading all three. The permitted variables are `MAIL_TEMPLATE_VARIABLES` and the set is closed. Rendering here promises nothing about send time: a template that fails for one recipient falls back to the Fleetless default and writes an audit event, and no answer on this route can say otherwise. \n\nA rendered part is capped while it is being written, so a template that would produce megabytes answers `422 template_invalid` rather than building the string first. Limited server-side to **ten calls a minute per app**, shared with the preview, answering `429 rate_limited` with `retry_after_ms`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/put-app-mail-template-request"
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "delete_api_apps_id_mail_templates_kind",
        "summary": "Drops the app's custom template for one kind, returning that mail to the Fleetless default.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The mail keeps being sent — this removes the developer's wording, not the message. A kind that already has no custom template answers `404 not_found` rather than `204`: there is nothing here to reach the end state of, and the two facts are worth telling apart to somebody who thinks they still have a template stored."
      }
    },
    "/api/apps/{id}/mail-templates/{kind}/preview": {
      "post": {
        "operationId": "post_api_apps_id_mail_templates_kind_preview",
        "summary": "Renders a template with sample data and answers the three parts, storing nothing.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/mail-template-preview-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`, `template_invalid`, `rate_limited`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Takes the same document the PUT does and writes nothing, so a developer can see the rendered subject, text and HTML before anybody receives them. The sample data fills every variable in `MAIL_TEMPLATE_VARIABLES`, including `link`, which is a plausible URL and not a live token. `422 template_invalid` carries the same `mailTemplateProblemDetails` the PUT does, which is the point of previewing: the error arrives on the screen where the template is being written. `404 not_found` is the app — a kind with no stored template previews perfectly well, since the body being rendered is the one in the request. \n\n**The sample data does not vary with the `kind`.** Every kind renders against one fixed set: an invite-shaped `link` and `expires_in_hours: 24`, where a real reset mail says 1 and a real invitation says 168. A preview shows how the template renders, not what the recipient of that kind will read. \n\nLimited server-side to **ten calls a minute per app**, shared with the PUT, answering `429 rate_limited` with `retry_after_ms`: rendering is synchronous CPU work on the shared cloud and an unbounded loop of it is a denial of service against every other org.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/mail-template-preview-request"
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/mail-templates/{kind}/test": {
      "post": {
        "operationId": "post_api_apps_id_mail_templates_kind_test",
        "summary": "Sends the rendered template as a real mail to the calling developer.",
        "tags": [
          "apps"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "description": "Which of the four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/mail-outcome"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`, `template_invalid`, `rate_limited`, `target_state_conflict`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**The recipient is the calling developer's own address and cannot be chosen.** A test send that named an arbitrary address would be a mail relay with an authentication step in front of it. The body and the sample data are the preview's, so what arrives is what the preview showed, in a real client with real HTML. \n\nThe answer is `{ \"mail\": mailStatus }` rather than an empty `202`, because the one thing a developer needs next is whether a mail actually left: `not_configured` on a deployment with no mailer looks exactly like a successful send otherwise, and they wait for a message nobody posted. `409 target_state_conflict` is that state made explicit where the deployment can already tell — there is no mailer configured at all, so nothing will be attempted. `422 template_invalid` refuses before sending, and `429 rate_limited` bounds how often this can be used to mail anybody, the developer included.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/mail-template-preview-request"
              }
            }
          }
        }
      }
    },
    "/api/org/users": {
      "get": {
        "operationId": "get_api_org_users",
        "summary": "Lists the organisation's Fleetless users — the team who reach the console.",
        "tags": [
          "users"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/fleetless-user-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**Fleetless users, not an app's users.** The two identity spaces are separate and nothing joins them, so an app's users are listed per app and never appear here. There is nothing to narrow by: the group filter this route used to take described a model with no successor, and every Fleetless user of the org is in this answer."
      }
    },
    "/api/org/users/{id}": {
      "get": {
        "operationId": "get_api_org_users_id",
        "summary": "Reads one Fleetless user of the org.",
        "tags": [
          "users"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The Fleetless user's uuid, as listed by `GET /api/org/users`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/fleetless-user"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "patch_api_org_users_id",
        "summary": "Changes a team member's display name.",
        "tags": [
          "users"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The Fleetless user's uuid, as listed by `GET /api/org/users`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/fleetless-user"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Nothing here has a consequence a PATCH body cannot carry: the tier is its own route, because it is owner-only and has a last-owner guard, and the address is immutable. The audit event records which fields were addressed, never their values.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/patch-fleetless-user-request"
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "delete_api_org_users_id",
        "summary": "Removes a team member and ends every session they hold.",
        "tags": [
          "users"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The Fleetless user's uuid, as listed by `GET /api/org/users`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`, `last_owner`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Sessions are revoked before the row is deleted: a still-existing user with a dead session is recoverable by retrying, a deleted user whose old token still works is not. Any team invitation still outstanding for that address is expired too — a link mailed before the removal is a standing re-admission ticket. **App accounts sharing the address are untouched**, in this org and in every other: they are separate identities in a separate space, and deleting a colleague must not delete a customer. Removing an Owner needs Owner tier, and removing the last one is `409 last_owner`."
      }
    },
    "/api/org/invitations": {
      "post": {
        "operationId": "post_api_org_invitations",
        "summary": "Invites an address onto the team and returns the accept link.",
        "tags": [
          "users"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/team-invite"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `validation_error`, `email_taken`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**A Fleetless user, not an app user.** Inviting somebody into an app is `POST /api/apps/:id/invitations` and is a different link into a different space. `tier` is required, because \"I did not think about it\" and \"I meant developer\" must not be the same request on the field that decides who can remove whom. **Inviting an Owner is Owner-only** — an invitation carrying `tier: \"owner\"` is a promotion with an extra step, since the response hands back the `accept_url`. `ownerTier` is `false` here because the gate is on that value, not on the route: any team member may invite a developer. **This collection sits beside `/api/org/users`, not under it**: an invitation is not a user yet, and the old spelling put a literal `invitations` where `GET /api/org/users/:id` expects a uuid — reachable only because a router ranks a static segment above a parametric one.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/create-team-invite-request"
              }
            }
          }
        }
      },
      "get": {
        "operationId": "get_api_org_invitations",
        "summary": "Lists the pending team invitations of the org, without their tokens.",
        "tags": [
          "users"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/pending-team-invite-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "No `accept_url` is in this listing, and that omission is the point: it exists so an admin can spot a backdoor invitation planted for an address they merely control, not so anyone can re-read a link."
      }
    },
    "/api/org/invitations/{id}": {
      "delete": {
        "operationId": "delete_api_org_invitations_id",
        "summary": "Revokes a pending invitation so its link stops resolving.",
        "tags": [
          "users"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The invitation's uuid, as listed by `GET /api/org/invitations`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "An invitation that was already accepted is not pending and answers `404`, the same answer one that never existed gets."
      }
    },
    "/api/org/invitations/{id}/reissue": {
      "post": {
        "operationId": "post_api_org_invitations_id_reissue",
        "summary": "Mints a fresh token onto the same invitation and returns the new accept link.",
        "tags": [
          "users"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The invitation's uuid, as listed by `GET /api/org/invitations`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/team-invite"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`, `rate_limited`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The old link stops resolving the instant this returns — the row is looked up by token hash and the previous hash is gone. Two live links to one invitation would reopen the door the listing's missing `accept_url` closes. Limited server-side to once a minute per invitation, answering `429 rate_limited` with `retry_after_ms`; a disabled button is a hint, this is the limit. Re-issuing an owner-tier invitation needs Owner tier, exactly as creating one does."
      }
    },
    "/api/org/invitations/accept": {
      "post": {
        "operationId": "post_api_org_invitations_accept",
        "summary": "Spends an invitation token and creates the account it was addressed to.",
        "tags": [
          "users"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`, `email_taken`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**`204`, not a session.** The console signs in through its own OAuth portal, so a session minted here would be a second credential door for one account — and every security property would then have to be right in two places. **No password**: the mailed link proves the address, so accepting needs no code either, and the new member signs in by emailed code from then on. When the organisation requires two-factor, the member sets one up at their first sign-in. Unknown, expired and already-accepted tokens collapse into `410 token_spent`. A browser form post gets the rendered \"you're in\" page instead.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/accept-team-invite-request"
              }
            }
          }
        }
      }
    },
    "/api/org/users/{id}/tier": {
      "put": {
        "operationId": "put_api_org_users_id_tier",
        "summary": "Promotes or demotes a team member between Owner and developer tier.",
        "tags": [
          "users"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The Fleetless user's uuid, as listed by `GET /api/org/users`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/fleetless-user"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`, `validation_error`, `last_owner`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier, unconditionally — this is the route the whole owner-exclusive list is about. A uuid that is not a Fleetless user of this org answers `404 not_found`, the same as one that does not exist anywhere: the `409 target_state_conflict` documented here until the two-space cut had exactly one producer, the Org Admins membership check, and went with it. Demoting the last Owner is `409 last_owner`, decided by a row lock inside the writing transaction rather than by a read beforehand. Setting the tier already held changes nothing and writes no audit event. No session is revoked: a tier is re-read from the row on every request, so no issued token carries a stale copy of it.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/tier-change-request"
              }
            }
          }
        }
      }
    },
    "/api/org/users/{id}/two-factor": {
      "delete": {
        "operationId": "delete_api_org_users_id_two_factor",
        "summary": "Removes a team member's passkeys, authenticator and recovery codes and ends their sessions.",
        "tags": [
          "users"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The Fleetless user's uuid, as listed by `GET /api/org/users`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`, `target_state_conflict`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier: the door for a member who lost every second factor and every recovery code. Every session of the member ends, and when the organisation requires two-factor they set one up again at their next sign-in. **An owner cannot reset their own** — `409 target_state_conflict` naming `user_id` with rule `self`; Settings › Profile is where they change it. A member with no second factor answers `204` too. Audited as `developer.two_factor_reset`, naming the owner who did it."
      }
    },
    "/api/org": {
      "patch": {
        "operationId": "patch_api_org",
        "summary": "Renames the org, requires two-factor for its members, or both.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/patch-org-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"org\": org }`. Owner tier, and the gate runs before the body is looked at, so a malformed patch and a forbidden one answer the same way — `403 tier_required` for a developer, whichever field they sent. An empty body is `400 validation_error`. Writing the values already held writes nothing and records no audit event. \n\n`require_two_factor` on signs nobody out: each member without a passkey or authenticator sets one up at their next sign-in, before any session exists, on the console and the central MCP endpoint alike. Server keys and robot bridges are not people and are not affected. Audited as `org.two_factor_required_changed`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/patch-org-request"
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource/mcp": {
      "get": {
        "operationId": "get__well_known_oauth_protected_resource_mcp",
        "summary": "Publishes what the MCP endpoint says about who may authorize for it.",
        "tags": [
          "mcp"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/protected-resource-metadata"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: none listed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "RFC 9728, for the one central MCP endpoint. `resource` and `authorization_servers` are the same URL: the MCP server is its own authorization server here. One document for the whole deployment, because there is one endpoint and it is scoped to nothing narrower: every Fleetless user of every org authorizes for the same resource, and the token names the person."
      }
    },
    "/.well-known/oauth-authorization-server/mcp": {
      "get": {
        "operationId": "get__well_known_oauth_authorization_server_mcp",
        "summary": "Publishes the authorization-server metadata an MCP client reads to sign a person in.",
        "tags": [
          "mcp"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/authorization-server-metadata"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: none listed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`registration_endpoint` being present is the whole point of the dynamic-registration work: a client that finds it registers itself and never asks a person for a `client_id`. `authorization_endpoint` is the only field that moves to the auth-portal origin when one is configured — `issuer`, `token_endpoint` and the resource identifier stay canonical, because a client checks a token's `iss` and `aud` against those strings and moving them would invalidate every token ever minted."
      }
    },
    "/mcp/oauth/register": {
      "post": {
        "operationId": "post_mcp_oauth_register",
        "summary": "Registers an MCP client dynamically, with no app identifier and no human in the loop.",
        "tags": [
          "mcp"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/dynamic-client-registration-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "RFC 7591. **The request schema is what this endpoint accepts, not what it parses**: the handler reads the body field by field, because §3.2.2 distinguishes `invalid_redirect_uri` from `invalid_client_metadata` and one `safeParse` failure cannot say which of the two a caller earned. The shape is deliberately **not** strict, which is the schema agreeing with §3.1 rather than a gap in it — a conforming client sends `client_uri`, `logo_uri` and `software_id`, and both the schema and the server ignore them. `client_name` and `redirect_uris` are the two fields read; `grant_types`, `response_types` and `scope` are accepted and ignored. What comes back is what was actually granted, which §3.2.1 allows a server to substitute — this authorization server grants `authorization_code` and `refresh_token` to every registration. The registration carries a TTL. Refusals are `oauthError`; the rate limiter answers `apiError`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/dynamic-client-registration-request"
              }
            }
          }
        }
      }
    },
    "/mcp/oauth/authorize": {
      "get": {
        "operationId": "get_mcp_oauth_authorize",
        "summary": "Starts an MCP sign-in and redirects the browser to the identify card.",
        "tags": [
          "mcp"
        ],
        "security": [],
        "parameters": [
          {
            "name": "response_type",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "const": "code",
              "description": "Always `code`. RFC 6749 §4.1.2.1 names `unsupported_response_type` for any other value, but `oauthErrorCode` has no such member — this server issues no other grant from this endpoint — so an unsupported value comes back on the callback as `invalid_request`."
            }
          },
          {
            "name": "client_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "description": "The OAuth client, self-registered or the one well-known central client — **not** the app identifier. Unknown, expired-dynamic and mismatched clients all collapse into the same `400 invalid_client`, answered without a redirect."
            }
          },
          {
            "name": "redirect_uri",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "description": "One of the client's registered redirect URIs, compared **exactly** — string equality against the registered list, never a prefix or a host match. Both the shape (`redirectUri`) and the registration are checked, and a failure of either is a `400 invalid_request` with no redirect."
            }
          },
          {
            "name": "code_challenge",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "description": "The PKCE challenge; the verifier is presented at the token endpoint. Only non-emptiness is checked here — length and alphabet are not — since the verifier is what has to match."
            }
          },
          {
            "name": "code_challenge_method",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "const": "S256",
              "description": "Only `S256`. `plain` is refused: a challenge equal to its verifier defends against nothing."
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Returned unchanged on the callback, and on the error redirect too, so a client can bind either answer to its own request.",
              "type": "string"
            }
          },
          {
            "name": "resource",
            "in": "query",
            "required": false,
            "schema": {
              "description": "RFC 8707 resource indicator: the API origin or the MCP endpoint the token is for. Checked against the resources this server issues tokens for **on behalf of this client's app**; a mismatch is `invalid_target` on the callback.",
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: none listed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**The query schema is what this endpoint accepts, not what it parses**: the handler reads it parameter by parameter because the answers differ, and one parse would collapse them. Client and `redirect_uri` are validated first and a failure there never redirects, the same open-redirect discipline the app flow applies; those refusals are `oauthError`. Exact `redirect_uri` matching for both client kinds — the loopback-port wildcard of RFC 8252 §7.3 belongs to the one central client alone, whose URIs are configured ahead of time and cannot name an ephemeral port. A client that registered itself seconds ago can name the port it bound, and widening the wildcard there would only widen where a stolen `client_id` may send a browser. Nothing about the person is decided here — the next card asks for an email address, or a passkey, and the steps after it resolve the account; this route knows only the client."
      }
    },
    "/mcp/oauth/token": {
      "post": {
        "operationId": "post_mcp_oauth_token",
        "summary": "Exchanges an MCP authorization code for an access token.",
        "tags": [
          "mcp"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/oauth-token-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: none listed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`authorization_code` mints an `mcp_session` access token bound to the central resource and a refresh token; `refresh_token` rotates that pair, and the presented refresh token is consumed — a second presentation revokes the session, as on `/api/auth/refresh`. The refresh token lives ninety days from its last use and is bound to the `client_id` it was issued to. A refresh re-reads the Fleetless user, so a removed account cannot refresh. Refusals are RFC 6749 §5.2's `oauthError`, so this route emits none of the codes in this reference. The code is single-use, PKCE-verified, and its `resource` must match the audience it was authorized for; a `resource` on a refresh must match the session's audience, and is checked before the token is consumed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/oauth-token-request"
              }
            }
          }
        }
      }
    },
    "/mcp": {
      "post": {
        "operationId": "post_mcp",
        "summary": "The central MCP endpoint: a stateless Streamable HTTP transport carrying the robot and console tool catalogs.",
        "tags": [
          "mcp"
        ],
        "security": [
          {
            "clientToken": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `forbidden`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "JSON-RPC over MCP's Streamable HTTP, so neither the request nor the response is a shape contracts describes; the tool arguments and results are the schemas in each tool definition. **Fleetless users only** — an app's users reach their own app endpoint instead. **The bearer is verified inside the handler**, not by a route guard: the identity comes from the token and the path names none, and the refusal has to carry a `WWW-Authenticate` challenge that a guard shared with the REST surface does not send. `Origin` is checked against the cloud's own, and a foreign one is the `403 forbidden` above. **Both catalogs, unconditionally**: every caller admitted here is a Fleetless user, so the tool list has nothing left to vary with and the admin-ness check on `tools/call` is gone — a console tool that is still narrower than the catalog refuses for itself (`console_robot_delete` answers `tier_required` to a non-Owner). `403 forbidden` is also what an `mcp_session` token whose subject is an **app user** gets: this endpoint serves the team only, and such a token belongs to its own app's endpoint. The code is `forbidden` rather than `mcp_disabled` because nothing is switched off — the caller is at the wrong server — and per-app sign-in mints exactly such tokens, so the two states must not share a word. `mcp_access_denied` is gone with the per-user override and the group flag it read: every Fleetless user has MCP access here. Stateless: a fresh transport per request, no session id, nothing survives the call."
      }
    },
    "/mcp/{appIdentifier}": {
      "post": {
        "operationId": "post_mcp_appIdentifier",
        "summary": "One app's MCP endpoint: the same stateless Streamable HTTP transport, carrying that app's robots.",
        "tags": [
          "mcp"
        ],
        "security": [
          {
            "clientToken": []
          }
        ],
        "parameters": [
          {
            "name": "appIdentifier",
            "in": "path",
            "required": true,
            "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `not_found`, `unauthorized`, `forbidden`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "JSON-RPC over MCP's Streamable HTTP, so neither the request nor the response is a shape contracts describes — exactly as `POST /mcp` is, and stateless for the same reason: a fresh transport per request, no session id, nothing surviving the call. **App users only.** The tools are this app's robots filtered by the caller's role, built by the same builder `GET /api/apps/:id/roles/:roleId/mcp-tools` previews, so the console's preview and the live catalog cannot drift. The console tool family belongs to the central endpoint and is offered here to nobody. \n\n**The bearer is verified inside the handler**, not by a route guard, for the two reasons the central endpoint gives — the identity comes from the token and the path names none of it, and the refusal has to carry a `WWW-Authenticate` challenge a guard shared with the REST surface does not send. The challenge names **this app's** protected-resource document (RFC 9728's `resource_metadata`), which is how an MCP client discovers the right authorization server from a bare `401`; pointing it at the central document would send every app's client to the wrong sign-in. \n\n**`404 not_found` covers an identifier no app carries AND an app whose `appAuthConfig.mcp_enabled` is off — one answer for both, the same one the two metadata documents and `register` give.** A separate `403 mcp_disabled` here would hand an anonymous caller a three-way oracle (`404` = no such app, `403` = the app exists with MCP off, `401` = the app exists and is live), which is exactly the distinction discovery collapses; there is no point collapsing it in one place and publishing it in another. The switch is re-read on every request rather than cached off the token, so a developer turning it off ends the sessions already running, and it is decided **before the bearer is looked at** — the reverse of the usual order, and deliberate: it is a fact about the path, an app identifier is public, and an absent server that answered `401` would send a client hunting a credential no credential can satisfy. `401 unauthorized` is a missing, unverifiable or expired bearer, an `aud` that is not this endpoint, or **a consent this person has since withdrawn from the client the token was minted for**: the access token names its client, and the standing consent is re-read here on every request exactly as the account is, so `DELETE /api/client/mcp/grants/:clientId` and its developer twin bite at the next call rather than when the token expires. `403 forbidden` is a token that verifies and is not this app's user: another app's session, a Fleetless user's central `mcp_session`, an account that is `blocked` or still `pending_verification`, or a foreign `Origin`."
      },
      "get": {
        "operationId": "get_mcp_appIdentifier",
        "summary": "Answers the standalone SSE stream's GET, which a stateless transport does not serve.",
        "tags": [
          "mcp"
        ],
        "security": [
          {
            "clientToken": []
          }
        ],
        "parameters": [
          {
            "name": "appIdentifier",
            "in": "path",
            "required": true,
            "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "405": {
            "description": "The only answer this route gives; see the error codes below."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `not_found`, `unauthorized`, `forbidden`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "MCP's Streamable HTTP gives this path three verbs: `POST` carries JSON-RPC, `GET` opens the server-initiated SSE stream, and `DELETE` ends a session. This server has no sessions — the argument is in `MCP_PROTOCOL_VERSION`'s own note, and a per-process session map is what breaks at the second cloud instance — so `GET` and `DELETE` answer `405`, which is what a client is built to fall back from. \n\n**The `405` is this cloud's own answer, not the SDK's**, and the two differ: MCP SDK 1.30.0 opens an SSE stream on `GET` (`handleGetRequest`) and answers `200` on `DELETE` (`handleDeleteRequest`), neither of which a stateless server has any business doing, so the cloud writes the `405` itself in the transport's own JSON-RPC error shape with `Allow: POST`. \n\n**The row exists so that the `405` is not a `404`.** An unregistered verb answers `404`, and at a path whose last segment is an app identifier a `404` already means *no such app* — so an unregistered verb and an unknown app would answer identically. Registering the verb lets the endpoint say \"this app's server is here; this verb is not part of it\". The central `/mcp` registers neither verb and does not need to: its path takes no parameter, so nothing can misread its `404`. \n\n**The `405` body is the transport's JSON-RPC error object, not the `apiError` envelope.** The three codes above are the refusals that come *first* — the app, its switch, then the bearer, in the order `POST` describes — and they are `apiError` because they are answered before the transport is reached at all. If this server ever becomes stateful, this row and the `DELETE` beside it are where that lands, and the cloud's route-manifest test is what would make both repositories notice."
      },
      "delete": {
        "operationId": "delete_mcp_appIdentifier",
        "summary": "Answers the session-termination DELETE, which a stateless transport has no session to end.",
        "tags": [
          "mcp"
        ],
        "security": [
          {
            "clientToken": []
          }
        ],
        "parameters": [
          {
            "name": "appIdentifier",
            "in": "path",
            "required": true,
            "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "405": {
            "description": "The only answer this route gives; see the error codes below."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `not_found`, `unauthorized`, `forbidden`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The other half of what the `GET` row above explains, and registered for the same reason: without a row here, a client tidying up after itself would read `404` and could not tell a stateless server from an app that does not exist. `405`, from the same transport, with the same three refusals ahead of it. A caller that wants a session to end simply stops sending requests — there is no server-side state for this verb to remove, which is the point rather than a limitation."
      }
    },
    "/.well-known/oauth-protected-resource/mcp/{appIdentifier}": {
      "get": {
        "operationId": "get__well_known_oauth_protected_resource_mcp_appIdentifier",
        "summary": "Publishes what one app's MCP endpoint says about who may authorize for it.",
        "tags": [
          "mcp"
        ],
        "security": [],
        "parameters": [
          {
            "name": "appIdentifier",
            "in": "path",
            "required": true,
            "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/protected-resource-metadata"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "RFC 9728, for the resource `<PUBLIC_API_BASE_URL>/mcp/<identifier>`. `resource` and `authorization_servers` are the same URL: each app's MCP server is its own authorization server, as the central one is, and that identity is what keeps one app's tokens out of another's — the audience a token carries is this app's endpoint URL and nothing broader. \n\n**The identifier goes last, after the document name.** §3.1 inserts `/.well-known/oauth-protected-resource` *before* the resource's path, so the document for `/mcp/<id>` is at `/.well-known/oauth-protected-resource/mcp/<id>`; a hand-written `/.well-known/oauth-protected-resource/<id>` is a path no conforming client ever fetches. `MCP_APP_PATHS` builds both, which is why this row does not spell either. \n\n**An app with MCP switched off answers `404`, the same as an identifier no app carries, and that is a decision rather than a gap.** A metadata document is present or it is absent; `403` is not a state a client's discovery code models, and one that met it would either error out or retry forever. Nothing is being hidden — the identifier is public and is in this very path — the two answers are simply the same answer: there is no MCP server here to authorize for. **Every other unauthenticated route on this surface says the same** — the authorization-server document, `register`, `authorize` and the transport itself all answer `404` for both states, so nothing an anonymous caller can reach distinguishes them. A person whose app has the switch off learns that from the console, not from a status code a stranger can also read."
      }
    },
    "/.well-known/oauth-authorization-server/mcp/{appIdentifier}": {
      "get": {
        "operationId": "get__well_known_oauth_authorization_server_mcp_appIdentifier",
        "summary": "Publishes the authorization-server metadata an MCP client reads to sign in to one app.",
        "tags": [
          "mcp"
        ],
        "security": [],
        "parameters": [
          {
            "name": "appIdentifier",
            "in": "path",
            "required": true,
            "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/authorization-server-metadata"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "RFC 8414, for the issuer `<PUBLIC_API_BASE_URL>/mcp/<identifier>` — the same path rule as the document above, and the same `404` for a switched-off app. `registration_endpoint` is present for the reason the central document states: a client that finds it registers itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request's `Host`**, because a client checks a minted token's `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an auth-portal origin**: the authorization step renders no page itself. It redirects to the app's own `mcp_login_url`, or to the hosted MCP sign-in on the auth portal when the app has configured none."
      }
    },
    "/mcp/{appIdentifier}/oauth/register": {
      "post": {
        "operationId": "post_mcp_appIdentifier_oauth_register",
        "summary": "Registers an MCP client dynamically for one app, with no human in the loop.",
        "tags": [
          "mcp"
        ],
        "security": [],
        "parameters": [
          {
            "name": "appIdentifier",
            "in": "path",
            "required": true,
            "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/dynamic-client-registration-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "RFC 7591, the same wire and the same handler as `POST /mcp/oauth/register` — one implementation, because a second answer to \"is this redirect URI acceptable\" would agree with the first only by luck. The request schema is what the endpoint accepts rather than what it parses, for the reason that row gives: §3.2.2 needs two distinguishable refusals and one `safeParse` failure offers one. `client_name` and `redirect_uris` are read; `grant_types`, `response_types` and `scope` are accepted and ignored, and what comes back is what was actually granted, which §3.2.1 allows — this authorization server grants `authorization_code` and `refresh_token` to every registration. The registration carries a TTL. \n\n**The registration is scoped to this app.** A `client_id` minted here authorizes at this app's endpoint and nowhere else, so a client registered against one app cannot walk into another's authorize with it, and a developer who switches MCP off is not left with strangers' registrations valid somewhere adjacent. \n\nRefusals are `oauthError`; the rate limiter and `404 not_found` answer `apiError`. That `404` covers an unknown identifier **and** an app with the switch off, mirroring the two metadata documents this endpoint is discovered from — a client that could not read those has no business registering here, and giving it a third distinct answer would only tell it something the documents deliberately do not.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/dynamic-client-registration-request"
              }
            }
          }
        }
      }
    },
    "/mcp/{appIdentifier}/oauth/authorize": {
      "get": {
        "operationId": "get_mcp_appIdentifier_oauth_authorize",
        "summary": "Starts an MCP sign-in and redirects the browser to the app's own login page, or to the hosted one.",
        "tags": [
          "mcp"
        ],
        "security": [],
        "parameters": [
          {
            "name": "appIdentifier",
            "in": "path",
            "required": true,
            "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "response_type",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "const": "code",
              "description": "Always `code`. RFC 6749 §4.1.2.1 names `unsupported_response_type` for any other value, but `oauthErrorCode` has no such member — this server issues no other grant from this endpoint — so an unsupported value comes back on the callback as `invalid_request`."
            }
          },
          {
            "name": "client_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "description": "The OAuth client, self-registered or the one well-known central client — **not** the app identifier. Unknown, expired-dynamic and mismatched clients all collapse into the same `400 invalid_client`, answered without a redirect."
            }
          },
          {
            "name": "redirect_uri",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "description": "One of the client's registered redirect URIs, compared **exactly** — string equality against the registered list, never a prefix or a host match. Both the shape (`redirectUri`) and the registration are checked, and a failure of either is a `400 invalid_request` with no redirect."
            }
          },
          {
            "name": "code_challenge",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "description": "The PKCE challenge; the verifier is presented at the token endpoint. Only non-emptiness is checked here — length and alphabet are not — since the verifier is what has to match."
            }
          },
          {
            "name": "code_challenge_method",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "const": "S256",
              "description": "Only `S256`. `plain` is refused: a challenge equal to its verifier defends against nothing."
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Returned unchanged on the callback, and on the error redirect too, so a client can bind either answer to its own request.",
              "type": "string"
            }
          },
          {
            "name": "resource",
            "in": "query",
            "required": false,
            "schema": {
              "description": "RFC 8707 resource indicator: the API origin or the MCP endpoint the token is for. Checked against the resources this server issues tokens for **on behalf of this client's app**; a mismatch is `invalid_target` on the callback.",
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The same query as `GET /mcp/oauth/authorize`, read the same way — parameter by parameter, because the answers differ and one parse would collapse them. \n\n**This route renders no page.** It writes an interaction — ten minutes, as the OIDC ones live — and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own UI, reads `GET /api/client/mcp/interactions/:id` to show the client's claimed name and the scopes it asked for, and calls approve or deny. **An app with no `mcp_login_url` is redirected to the hosted MCP sign-in** (`GET /app/:appIdentifier/mcp/:interaction`), which runs the same steps on the auth portal; nothing is refused for a missing URL. \n\nClient and `redirect_uri` are validated first and a failure there never redirects — the open-redirect discipline `GET /mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep — and those refusals are RFC 6749's flat `oauthError`, which is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen `client_id` may send a browser. \n\nThe code above is the `apiError` envelope because it is a refusal about the **app**, decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched off** — the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between \"turned off\" and \"mistyped\"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring route collapses. `mcp_disabled` survives only where the caller has already proved they belong to the app — the two decision routes under `/api/client/mcp/interactions/:id`."
      }
    },
    "/mcp/{appIdentifier}/oauth/token": {
      "post": {
        "operationId": "post_mcp_appIdentifier_oauth_token",
        "summary": "Exchanges one app's MCP authorization code for an access token.",
        "tags": [
          "mcp"
        ],
        "security": [],
        "parameters": [
          {
            "name": "appIdentifier",
            "in": "path",
            "required": true,
            "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/oauth-token-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: none listed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`authorization_code`, PKCE-verified and single-use, and `refresh_token`, which rotates the pair the exchange minted; the refresh token lives ninety days from its last use, is bound to its client and to this app, and a refresh re-reads the app user's status and their standing consent to the client, so a block or a withdrawn consent ends the session at its next refresh at the latest. **The `aud` is this app's endpoint URL on the canonical public base**, and the code's `resource` must match it — that is the whole of what stops a token minted for one app being spent at another's endpoint. \n\n**Every refusal is RFC 6749 §5.2's `oauthError`, so this route emits none of the codes in this reference — including the ones about the app.** An unknown identifier and a switched-off app are `invalid_client` here, not the `404` and `403` the authorize route beside it answers. The difference is who reads the answer: authorize is walked by a browser and its refusal is read by a person, while this endpoint is called by a client's own code in the middle of a flow, and handing that code an envelope its OAuth library cannot parse turns a clean refusal into an unexplained crash.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/oauth-token-request"
              }
            }
          }
        }
      }
    },
    "/api/client/login": {
      "post": {
        "operationId": "post_api_client_login",
        "summary": "Signs an app user in with an app identifier, an email address and a password.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-sign-in-result"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `invalid_credentials`, `method_not_allowed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "One refusal for every miss — unknown app, unknown address, wrong password, a `blocked` account and one still `pending_verification` — because the caller supplies the `app_identifier` unauthenticated, so \"this app knows this user\" is not a fact the answer may carry. The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either. \n\n**The answer is a `clientSignInResult`**: session tokens, or a `twoFactorChallenge` when the person has a confirmed authenticator or the app requires one — then no session exists until `POST /api/client/two-factor/verify` or the setup is done. `403 method_not_allowed` when the app has the password method off; it names the app's policy, not a person.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-login-request"
              }
            }
          }
        }
      }
    },
    "/api/client/login/code": {
      "post": {
        "operationId": "post_api_client_login_code",
        "summary": "Mails a six-digit sign-in code, and answers the same whether or not the address exists.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "202": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `not_found`, `method_not_allowed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**`202` and an empty body for every request the policy allows**, in status, body and timing, whether or not the address names an active account of this app — a decoy like `POST /api/client/resend-verification`, so this is no enumeration oracle. A mail goes out for an `active` account and for one still `pending_verification` — spending the code proves the address, as the verification link would — and never for a `blocked` one or an unknown address. The code is six digits, valid ten minutes, takes five wrong attempts, and a new request expires the previous one for the same address; a request within sixty seconds of the last sends no second mail. The address is trimmed and compared case-insensitively. `404 not_found` is the **app identifier**, never the address; `403 method_not_allowed` when the app has the email-code method off. Limited per app, address and IP, so it cannot be used to mail somebody repeatedly.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-login-code-request"
              }
            }
          }
        }
      }
    },
    "/api/client/login/code/verify": {
      "post": {
        "operationId": "post_api_client_login_code_verify",
        "summary": "Spends a mailed sign-in code and answers a session or a two-factor challenge.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-sign-in-result"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `invalid_code`, `token_spent`, `method_not_allowed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A wrong code is `400 invalid_code` with `details.attempts_left` (`invalidCodeDetails`). A code that is spent, past its ten minutes, out of attempts, or was never mailed is `410 token_spent` — one answer, because telling them apart would say whether a code was ever sent to that address; the recovery is the same, ask for a new code. The address is trimmed and compared case-insensitively, so the address typed at the request and here need not match in case. \n\n**The answer is a `clientSignInResult`**, like the password login: tokens, or a `twoFactorChallenge` when the person has an authenticator or the app requires one. A pending-verification account that spends a code is activated — reading a mail at that address is the proof verification asks for.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-login-code-verify-request"
              }
            }
          }
        }
      }
    },
    "/api/client/register": {
      "post": {
        "operationId": "post_api_client_register",
        "summary": "Creates an app user in the `pending_verification` state and mails them a verification link.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "202": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `not_found`, `registration_closed`, `domain_not_allowed`, `target_state_conflict`, `quota_exceeded`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**`202` and an empty body for every request policy allows** — a new address, one this app already knows and one it does not answer identically, in status, body and timing. An answer that depended on existence would be the account-enumeration oracle the whole client family is built to avoid. The account cannot log in until the mailed link is spent; `POST /api/client/verify-email` is what does that. \n\n**An address on an account still `pending_verification` is re-registered, not ignored.** The password and display name from this call replace what is stored, every outstanding verification link for the address stops working, and a fresh one is mailed. Otherwise whoever typed an address first would own the password of the account its real owner later verifies. An address on an `active` account changes nothing and sends nothing — that account has already been proven, and its way back in is `POST /api/client/password/reset`. Neither case is visible in the answer. \n\nThe refusals it *does* make are about policy or about what the caller typed, never about a person. `403 registration_closed` when the app has self-registration off and `403 domain_not_allowed` when the address is outside `allowed_domains`: both are the developer's own configuration, and a stranger learns the app's policy rather than who is in it. **A password under twelve characters is part of that `400 validation_error`** and not a code of its own — the minimum is the `password` field's schema rule, and the error names the field, which is what a form needs to mark it. The same `400` names `password` when one is missing while the app's password method is on, or sent while it is off: an email-code-only app registers people without one. `404 not_found` names an **app identifier no app carries**, and never an address: an app identifier is already public (it is in the MCP metadata path and in the developer's own URLs), while collapsing it into `registration_closed` sent a developer who mistyped their own identifier hunting a configuration bug that was not there. `409 target_state_conflict` when the app has no default role — there would be no role to give the person. An app with no `verify_url` is not refused: the mailed link points at the hosted confirmation page instead. \n\n**`409 quota_exceeded` when the org is at its `max_end_users` limit**, counted across every app of the org. It is the one refusal here that is answered **before the address is looked at** — and that ordering is the point rather than an implementation detail: a quota checked after the existence branch would answer `202` for an address the app already knows and `409` for one it does not, which is precisely the enumeration oracle every other line of this route exists to close. At the quota, every registration is refused identically, including one that would only have re-mailed a pending account's link.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-register-request"
              }
            }
          }
        }
      }
    },
    "/api/client/verify-email": {
      "post": {
        "operationId": "post_api_client_verify_email",
        "summary": "Spends a verification token, activates the account and answers a session.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-sign-in-result"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**The answer is a session, not a `204`** — or, as on every sign-in step, a `twoFactorChallenge` when the app requires two-factor (`clientSignInResult`). Somebody who has just proved they can read the mail should not be asked to type their password again on the next screen, and the app has an access token to carry them into it. The token is spent first and the account is activated second, as **two writes**: the spend is the atomic one, so a link opened twice cannot mint two sessions, but a process that died between them would leave a spent token on an account still `pending_verification`, whose recovery is `POST /api/client/resend-verification`. Spending the token also proves the address, so a later `PATCH` may return the account to `active` after a block. \n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, past its twenty-four hours, or already used. There is one code because distinguishing them would tell a stranger whether a token ever existed, and because the recovery is the same in all three cases: ask for a fresh link with `POST /api/client/resend-verification`. An app rendering this refusal should offer that and nothing conditional on which of the three it was.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-verify-email-request"
              }
            }
          }
        }
      }
    },
    "/api/client/resend-verification": {
      "post": {
        "operationId": "post_api_client_resend_verification",
        "summary": "Mails the verification link again, and answers the same whether or not the address exists.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "202": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**`202` in status, body and timing** for an address that names a `pending_verification` account, one that names an already-active account, and one that names nothing at all. A mail is sent only in the first case. This is the same discipline `POST /api/client/register` keeps, by the other door: an answer that varied here would undo it. `404 not_found` is the **app identifier** and nothing else, exactly as on `register` — the address is never the subject of a refusal. Limited per app, address and IP, so this cannot be used to mail somebody repeatedly.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-resend-verification-request"
              }
            }
          }
        }
      }
    },
    "/api/client/password/reset": {
      "post": {
        "operationId": "post_api_client_password_reset",
        "summary": "Mails an app user a reset link, and answers the same either way.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "202": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `not_found`, `method_not_allowed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The pair of app identifier and address is the identifier: an app user's address is unique only within their app. `403 method_not_allowed` when the app has the password method off — a reset link whose confirmation would be refused is not mailed; the code names the app's policy, not a person. Status, body and timing are identical for a known and an unknown address. An account with no password — one created through an identity provider — is mailed nothing and still answers `202`. `404 not_found` is the **app identifier**, never the address. The link points at the app's `reset_url`, or at the hosted reset page when the app has configured none.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-password-reset-request"
              }
            }
          }
        }
      }
    },
    "/api/client/password/reset/confirm": {
      "post": {
        "operationId": "post_api_client_password_reset_confirm",
        "summary": "Spends a reset token, sets the new password and answers a fresh session.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-sign-in-result"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`, `method_not_allowed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**A new password does not bypass the second factor**: a person with an authenticator, or in an app that requires one, gets a `twoFactorChallenge` instead of tokens (`clientSignInResult`), and the authenticator stays on. `403 method_not_allowed` when the app has the password method off. \n\n**Every refresh family of that account is revoked**, then a fresh pair is minted for the caller — a forgotten password is one of the two states where somebody else may be holding a live session, and the person completing the reset is the one who should keep theirs. The account is activated if it was still `pending_verification`: reading a mail at that address is the same proof verification asks for. \n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, past its hour, or already used. There is one code because distinguishing them would tell a stranger whether a token ever existed, and the recovery is identical either way: ask for a new link. A replacement password under twelve characters is a `400 validation_error` naming the `new_password` field — the twelve-character minimum is that field's schema rule, and it is refused the way any other malformed field is.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-password-reset-confirm-request"
              }
            }
          }
        }
      }
    },
    "/api/client/invitations/accept": {
      "post": {
        "operationId": "post_api_client_invitations_accept",
        "summary": "Spends an invitation token, creates or activates the app user and answers a session.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-sign-in-result"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`, `email_taken`, `target_state_conflict`, `quota_exceeded`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**An app invitation, not a team one.** `POST /api/org/invitations/accept` is the other space and answers `204`; this one answers a session, because the person is landing in the developer's app and there is no second door for them to sign in through. The role is the one the invitation fixed at creation, so a later change to the app's default role does not re-aim a link already in somebody's inbox, and the invitation **bypasses `allowed_domains`** — a developer inviting somebody by hand has already made the decision the whitelist automates. The answer is a `clientSignInResult`: a `twoFactorChallenge` instead of tokens when the app requires two-factor. `password` is required while the app's password method is on and refused while it is off, both as `400 validation_error` naming the field. \n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, expired past the seven days, revoked by the developer, or already accepted. There is one code because telling them apart would say whether a token ever existed, and because the one thing the holder of a dead link can do is ask the developer for a new one, whichever of the four it was. A chosen password under twelve characters is part of the `400 validation_error`, naming the `password` field. `409 email_taken` is an address this app has acquired since the invitation was written **as an account that is already in use** — the invitation stays outstanding rather than being spent, so the developer can revoke it or point the person at the login. An address that registered itself and is still `pending_verification` is not that state: accepting sets the password the invitee just chose, activates the account and gives it the invitation's role, because reading the invitation mail proves the address the verification link was waiting on. \n\n`409 target_state_conflict` names `role_id` with rule `not_set` when the role the invitation was fixed to has since been deleted and the app has no default role to fall back on: there is no access to hand the acceptor, and creating an account with none would be worse than saying so. \n\n**`409 quota_exceeded` when accepting would CREATE an account and the org is at its `max_end_users` limit**, counted across every app of the org. An invitation that names a row the developer already created, and one whose address is held by an unfinished self-registration, both finish an account that already counts — those are not refused, because the org is not one account larger afterwards. The token is not spent by the refusal: the developer can raise the limit, or delete somebody, and the same link still works.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-accept-invitation-request"
              }
            }
          }
        }
      }
    },
    "/api/client/refresh": {
      "post": {
        "operationId": "post_api_client_refresh",
        "summary": "Rotates an app-user refresh token and mints a fresh access token.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/session-tokens"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The account is re-proved here, not just the token: refresh is where every session eventually re-proves itself, so a user who was blocked or deleted loses the family here even if the proactive revoke had not landed. A family minted from a `resource`-carrying token exchange keeps its audience across every rotation.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-refresh-request"
              }
            }
          }
        }
      }
    },
    "/api/client/logout": {
      "post": {
        "operationId": "post_api_client_logout",
        "summary": "Revokes an app-user refresh family.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**`204`, and a token the server does not recognise gets it too** — the end state a caller asked for is the end state they get, and distinguishing the two would say whether a token ever existed. It answered a body until 2026-09-05, reporting what was left of the session at the identity provider; that belonged to the hosted login flow, where Fleetless owned the browser. The developer's app owns it now and redirects to its own provider itself, knowing which one it is. Open `/realtime` sockets for the session are closed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-logout-request"
              }
            }
          }
        }
      }
    },
    "/api/client/password/change": {
      "post": {
        "operationId": "post_api_client_password_change",
        "summary": "Changes an app user's own password and answers a fresh session.",
        "tags": [
          "client-auth"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/session-tokens"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `validation_error`, `invalid_credentials`, `target_state_conflict`, `method_not_allowed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`403 method_not_allowed` when the app has the password method off: a stored password stays stored but is not in use, so it is not changed either. The guard admits all three caller kinds, but a password belongs to an app user specifically — a developer bearer or a server key reaching this is `401 unauthorized`. Every other session of the account ends; the answer is the replacement pair, so the tab that made the change stays signed in. An app user belongs to one app, so \"every session\" is this app's. An account that has **no password** — an OIDC-only app user, which the schema admits — answers `409 target_state_conflict` naming the `password` field with rule `not_set`, not `401`: the session is live and the token is fine, it is the account that has nothing to change, and telling such a caller to sign in again sends them round a loop that ends here.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/password-change-request"
              }
            }
          }
        }
      }
    },
    "/api/client/me": {
      "get": {
        "operationId": "get_api_client_me",
        "summary": "Answers who the calling token is and what it is allowed to reach.",
        "tags": [
          "client-auth"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-identity"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The one route that answers for all three caller kinds — a developer bearer, an app-user bearer and a server key — which is why the shape names each of `developer_id`, `app_user_id` and `server_key_id` and fills exactly one."
      }
    },
    "/api/client/two-factor/verify": {
      "post": {
        "operationId": "post_api_client_two_factor_verify",
        "summary": "Answers a two-factor challenge with an authenticator or recovery code, and answers the session.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/session-tokens"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `invalid_code`, `token_spent`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The challenge is the one a sign-in step answered with `two_factor_required`; it lives five minutes and takes five wrong codes, after which it is `410 token_spent` and the sign-in starts over. A wrong code is `400 invalid_code` with `details.attempts_left`. **A code is accepted at most once**: the same authenticator code sent twice, even at the same moment, signs in exactly once. A recovery code is spent by its use and audited as `app_user.recovery_code_used`. Exactly one of `code` and `recovery_code`, or `400 validation_error`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-two-factor-verify-request"
              }
            }
          }
        }
      }
    },
    "/api/client/two-factor/setup": {
      "post": {
        "operationId": "post_api_client_two_factor_setup",
        "summary": "Starts an authenticator setup and answers its secret and otpauth URL.",
        "tags": [
          "client-auth"
        ],
        "security": [
          {
            "clientToken": []
          },
          {}
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/two-factor-setup-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`, `unauthorized`, `target_state_conflict`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**Two ways in, decided in the handler.** During sign-in the body carries the `two_factor_setup_required` challenge, and that is the credential; from the app's own account settings the app user's bearer is, with no challenge and an empty or missing body. Neither is `401 unauthorized`, and a dead challenge is `410 token_spent`. `409 target_state_conflict` names `two_factor` with rule `off` when the app's policy is `off`. The secret is not in use until `POST /api/client/two-factor/setup/confirm` accepts a code from it; a second call replaces a pending secret, and an account that already has an authenticator keeps it until the new one is confirmed.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-two-factor-setup-request"
              }
            }
          }
        }
      }
    },
    "/api/client/two-factor/setup/confirm": {
      "post": {
        "operationId": "post_api_client_two_factor_setup_confirm",
        "summary": "Confirms the new authenticator with a code and answers the recovery codes and a session.",
        "tags": [
          "client-auth"
        ],
        "security": [
          {
            "clientToken": []
          },
          {}
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-two-factor-setup-confirm-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `invalid_code`, `token_spent`, `unauthorized`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The same two ways in as `setup`. A code that does not match the pending secret is `400 invalid_code`; no pending setup, or a dead challenge, is `410 token_spent`. On success the authenticator is on, ten recovery codes are issued — shown this once, any earlier set void — and the answer carries a session: the one the sign-in was waiting for, or, from account settings, a fresh one while every other session of the account ends. Audited as `app_user.two_factor_enabled`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-two-factor-setup-confirm-request"
              }
            }
          }
        }
      }
    },
    "/api/client/two-factor": {
      "delete": {
        "operationId": "delete_api_client_two_factor",
        "summary": "Turns the signed-in app user's authenticator off.",
        "tags": [
          "client-auth"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `rate_limited`, `validation_error`, `invalid_code`, `target_state_conflict`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The app user's own door; a developer bearer or a server key is `401 unauthorized`, because the factor is the person's. A current code proves they still hold the authenticator: a stolen session alone cannot remove it. The authenticator and every recovery code go. `409 target_state_conflict` names `two_factor` with rule `required` while the app requires two-factor, and with rule `off` when there is none to remove. Audited as `app_user.two_factor_disabled`. The developer's support door is `DELETE /api/apps/:id/users/:userId/two-factor`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-two-factor-disable-request"
              }
            }
          }
        }
      }
    },
    "/api/client/providers": {
      "get": {
        "operationId": "get_api_client_providers",
        "summary": "Lists the app's enabled sign-in providers, so the app can draw its buttons.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [
          {
            "name": "app_identifier",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 2,
              "maxLength": 63,
              "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
              "description": "The app whose enabled providers to list."
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-provider-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"providers\": [{ slug, name }, …] }` and **nothing else**: the issuer, the client id, the scopes and the linking policy are management-side facts, and this route is public. An app with no provider answers an empty array, which is the state of an app that offers password login alone; a **disabled** provider is not a button that refuses, it is a button that is not there. \n\n`404 not_found` is the app identifier and can be nothing else — the answer does not vary by person, so there is no address here to be silent about. **Not rate limited**, unlike the rest of the public client family: it reads back two strings of the developer's own public configuration, an app's login page calls it on every render, and there is nothing behind it to enumerate. The limiter on `start` is where the cost of this flow actually is."
      }
    },
    "/api/client/oidc/{slug}/start": {
      "get": {
        "operationId": "get_api_client_oidc_slug_start",
        "summary": "Begins a federated sign-in and redirects the browser to the app's identity provider.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The provider to sign in with, as listed by `GET /api/client/providers`; an unknown slug answers `404`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "app_identifier",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 2,
              "maxLength": 63,
              "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
              "description": "The app being signed in to."
            }
          },
          {
            "name": "redirect_uri",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "maxLength": 2000,
              "format": "uri",
              "description": "Where to send the browser when the flow finishes, with `code` and `state` or with `error` and `state`. **Its origin must be one of the app's `allowed_origins`**; a failure here is refused flat, with no redirect, because until the target is confirmed there is nowhere trusted to bounce a browser to."
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 8,
              "maxLength": 512,
              "description": "Returned unchanged on the callback, and on the error redirect too, so the app can bind either answer to the request it started. At least eight characters: this is what ties the callback to the browser that began the flow, and a guessable value defends nothing."
            }
          },
          {
            "name": "code_challenge",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{43,128}$",
              "description": "The app's own PKCE challenge (RFC 7636, S256 base64url). The verifier is presented at `oidc/exchange`, so the one-time code is worth nothing to whoever intercepts the redirect. `plain` is not accepted: a challenge equal to its verifier defends against nothing."
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `not_found`, `provider_disabled`, `invalid_redirect_uri`, `provider_misconfigured`, `idp_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**Every refusal here is JSON, answered before any redirect** — the `apiError` envelope, not the `?error=` redirect the callback uses. The difference is the open-redirect discipline: the callback knows a `redirect_uri` this route has already confirmed, and this route does not, so sending a browser anywhere on the strength of an unvalidated parameter is the attack rather than the error report. `400 invalid_redirect_uri` is a malformed target or an origin outside the app's `allowed_origins`, and it is checked **first**. \n\n`404 not_found` is an unknown `app_identifier` or a slug this app does not carry; `403 provider_disabled` is a slug it carries with `enabled` off, which is a distinction a developer's own page can render as \"temporarily off\" rather than \"gone\". `422 provider_misconfigured` and `502 idp_unavailable` are the provider's discovery failing the two ways the create route already describes. \n\n**The app runs its own PKCE against Fleetless here**, which is a second exchange independent of the one Fleetless runs against the identity provider: `code_challenge` binds the one-time code the callback returns to a verifier only the app's page holds. `state` comes back unchanged on the success redirect and on the error redirect alike. Rate limited per ip, because this is the unauthenticated door that makes Fleetless fetch a remote system."
      }
    },
    "/api/client/oidc/callback": {
      "get": {
        "operationId": "get_api_client_oidc_callback",
        "summary": "Takes the identity provider's redirect and sends the browser back to the app.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [
          {
            "name": "state",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "description": "The opaque state Fleetless sent to the provider, which resolves the pending interaction — and with it the app's `redirect_uri`. Not the app's own `state` from `start`: that one is stored on the interaction and put back on the redirect to the app. A callback whose state resolves to nothing has no confirmed target to answer, and is the one case Fleetless renders a page for."
            }
          },
          {
            "name": "code",
            "in": "query",
            "required": false,
            "schema": {
              "description": "The provider's authorization code, present when the sign-in succeeded. Exchanged server-side by the cloud, so it never reaches the app — the app gets its own one-time code, bound to the PKCE challenge it sent at `start`.",
              "type": "string",
              "minLength": 1
            }
          },
          {
            "name": "error",
            "in": "query",
            "required": false,
            "schema": {
              "description": "The provider's own refusal, present instead of `code` when the person declined or the provider would not issue one. It is carried back to the app as a `clientOidcErrorCode`, not passed through: the provider's vocabulary is its own, and an app branching on it would be branching on a string nobody here controls.",
              "type": "string",
              "minLength": 1
            }
          },
          {
            "name": "error_description",
            "in": "query",
            "required": false,
            "schema": {
              "description": "The provider's human-readable note about `error`, when it sends one. Logged, never rendered to an app user and never put on the redirect — it is text from a system Fleetless does not run.",
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**One callback URL for every app and every provider**, and the value of `appAuthConfig.oidc_callback_url` — the string a developer registers at their IdP. `CLIENT_OIDC_CALLBACK_PATH` in `client-auth.ts` is the single spelling of this path; the URL is that path on the cloud's canonical public base, never a friendly alias, because the provider compares the redirect target against the one string it was given. \n\n**Rate limited per ip, generously.** The first draft left this route unlimited on the argument that the caller is an identity provider redirecting somebody's browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. The cost of that argument is a `state` obtained from one `start` being replayable for the interaction's full ten minutes, unbounded and unauthenticated, with every replay driving a server-side POST to the developer's token endpoint and one audit row into their org — an amplifier against a third party. The interaction is now spent on **every** terminal outcome, refusals included, which closes the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a person completing a sign-in never meets it. `429 rate_limited` is the one `apiError` this route can answer, and it is not a sign-in outcome — it is a refusal to begin the work, which is why it does not ride back to the app as an `?error=`. \n\nThe query is the **provider's** rather than a Fleetless shape, and `clientOidcCallbackQuery` describes it **without being strict**: `state` always, `code` on success, `error` and `error_description` on the provider's own refusal, and whatever else that provider adds — RFC 9207's `iss`, a `session_state`, a vendor field. Refusing those would refuse conforming providers, the trap `POST /mcp/oauth/register` documents avoiding. `state` is the required field because it is the only one Fleetless minted. \n\n**It lists `rate_limited` and no other code, because every sign-in outcome it has is a redirect.** Success and failure alike are a `302` to the app's own `redirect_uri`: `?code=…&state=…` when a session was resolved, `?error=<clientOidcErrorCode>&state=…` when it was not, so the app renders its own message and can bind either answer to the request it started. This route renders no page for an outcome. \n\n**The one exception is a `state` that resolves to no interaction** — unknown, hand-edited, or past its ten minutes. Then there is no confirmed redirect target to carry the answer to, and bouncing a browser to an unvalidated one is the hole the whole flow is arranged to avoid, so the cloud renders an HTML problem page at `400`. It is HTML rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this manifest's other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for the same reason."
      }
    },
    "/api/client/oidc/exchange": {
      "post": {
        "operationId": "post_api_client_oidc_exchange",
        "summary": "Trades the one-time code from the callback for an app-user session.",
        "tags": [
          "client-auth"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/session-tokens"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The second half of the app's own PKCE: the `code` from the callback redirect plus the `code_verifier` for the challenge `start` carried. Sixty seconds, single-use, and worth nothing to whoever intercepted the redirect without the verifier. \n\n**One refusal for every code that does not work: `410 token_spent`** — unknown, past its sixty seconds, already exchanged, or presented with a verifier that does not match. There is one code because this code **is a credential**: telling the four apart would say whether a given value ever existed, and the recovery is the same in all four — start the sign-in again. The MCP interaction routes collapse their four states the same way and for the same reason, and answer `interaction_expired` rather than this code — the difference is what the value is, not how vague the answer is: a mailed one-time code is a credential, an interaction id names a pending request, and the two deserve different advice on the app's own page.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/client-oidc-exchange-request"
              }
            }
          }
        }
      }
    },
    "/api/client/mcp/interactions/{id}": {
      "get": {
        "operationId": "get_api_client_mcp_interactions_id",
        "summary": "Reads a pending MCP authorization so the app can draw its own consent screen.",
        "tags": [
          "client-auth"
        ],
        "security": [
          {
            "clientToken": []
          },
          {}
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The interaction id, as `GET /mcp/:appIdentifier/oauth/authorize` put it into the app's `mcp_login_url`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-mcp-interaction"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `interaction_expired`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**The bearer is optional, which is why the credential is decided in the handler rather than by a guard.** An app renders this page before it knows who is at the keyboard — the client's claimed name, marked unverified, and the scopes it asked for — and reads the document again once the person has signed in. The only field that moves is `already_granted`: a grant belongs to a user, so without a token there is no user for it to be about and it is `false`. An app-user token for a **different** app is treated as absent rather than refused, for the same reason: nothing in this document is that user's, so there is nothing to refuse them, and a `401` would break the page for somebody whose browser happens to hold another app's session. \n\n**One code for every interaction that is not live: `410 interaction_expired`.** Unknown, past its ten minutes, already decided, an interaction of the central flow, or one whose authorize step never handed a browser to the app — one status and one body, so an id nobody holds cannot be told from one that ran out. **The last of those is what makes the redirect stamp a real gate rather than a note**: an id invented or replayed outside the flow names no interaction this route will describe, and a page reloading its own consent screen is a second read rather than a second redirect, so it keeps working. A `404` beside it would let a caller who did not start the flow ask whether somebody else's sign-in is in progress, which is the only question this document could be used to answer. The word is still `interaction_expired` rather than `token_spent`, because an interaction id names a pending request rather than a credential and the app's page owes the person the better advice: \"that took too long, start again\". \n\n**Not rate limited**, unlike most of the public client family and unlike the two decisions beside it: the id is unguessable and names a request the server already holds, the answer says nothing about any person, and the app's consent page fetches it on every render. There is nothing behind it to enumerate — to somebody who did not start the flow, an id that resolves and one that does not are equally uninformative."
      }
    },
    "/api/client/mcp/interactions/{id}/approve": {
      "post": {
        "operationId": "post_api_client_mcp_interactions_id_approve",
        "summary": "Approves a pending MCP authorization on behalf of the signed-in app user.",
        "tags": [
          "client-auth"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The interaction id the app read with `GET /api/client/mcp/interactions/:id`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-mcp-interaction-decision-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `rate_limited`, `interaction_expired`, `mcp_disabled`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The person is already signed in **at the app**, by whatever means that app uses, and this is the app telling Fleetless what they decided. Fleetless never sees that sign-in. \n\nThe guard admits all three caller kinds and the handler takes one: a developer bearer or a server key reaching this is `401 unauthorized`, because a consent is a person's and a server key is not a person — the same shape `POST /api/client/password/change` has. `403 mcp_disabled` is the app's switch, re-read here as it is on every request — and it is the one refusal on this surface that names the switch, because reaching it needs an app-user session of that very app. \n\n**An interaction of ANOTHER app answers `410 interaction_expired`, not `403`.** An interaction of one app cannot be decided with a session from another — that is what stops a developer running two apps from letting one speak for the other — but saying so with a distinct code would tell any bearer holder that the id names a real, live interaction somewhere else, which is the existence answer the shared `410` exists to withhold. Unknown, expired, already decided, an interaction of the central flow, and one belonging to a different app are one status and one body. Approve and deny spend an interaction alike, so the second call gets it whichever route made the first. \n\n**Rate limited per app user, unlike the read.** The read is a public document about a request the server already holds; this one spends something, and a decision is the one thing a leaked interaction id would be worth hammering for. The limit is on the signed-in account rather than on the ip, because that is what the caller has had to prove. \n\n**The answer is a redirect target, not a redirect.** `redirect_to` is the MCP client's own callback carrying the authorization code, and the app's page sends the browser there. The app is holding that browser and Fleetless is answering its JSON call, so a `302` here would be a redirect on the wrong request. Approving records the grant for this user and this client, which is what a later `already_granted` reads back."
      }
    },
    "/api/client/mcp/interactions/{id}/deny": {
      "post": {
        "operationId": "post_api_client_mcp_interactions_id_deny",
        "summary": "Denies a pending MCP authorization on behalf of the signed-in app user.",
        "tags": [
          "client-auth"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The interaction id the app read with `GET /api/client/mcp/interactions/:id`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-mcp-interaction-decision-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `rate_limited`, `interaction_expired`, `mcp_disabled`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The same route with the opposite decision, and **it answers a `redirect_to` as well** — the client's own callback carrying `error=access_denied`. A client that is refused must learn so from the place it is waiting rather than from a page nobody sent it, the discipline `POST /mcp/oauth/consent` already keeps. \n\n**Two routes rather than one with a `decision` field**, which is what the hosted consent screen has to be: there the decision arrives from a browser form, so anything that is not the Allow value must deny, and a missing field failing closed is a rule somebody has to keep getting right. Here the caller is the app's own server-side code and the path *is* the decision — there is no value to misread. The refusals are the approve route's, for the reasons stated there, including the limiter: **rate limited per app user**, on the signed-in account rather than the ip, because a denial spends the interaction exactly as an approval does and a caller holding a leaked id must not be able to burn other people's sign-ins in a loop."
      }
    },
    "/api/client/mcp/grants": {
      "get": {
        "operationId": "get_api_client_mcp_grants",
        "summary": "Lists the MCP clients the signed-in app user has consented to.",
        "tags": [
          "client-auth"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/mcp-consent-grant-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**So the developer's app can offer a \"connected apps\" screen of its own**, which is the only place an end user could ever be shown this: Fleetless renders no page for an app's users, and the console is the developer's tool rather than their customers'. \n\nThe answer is about the bearer's own account and takes no user id — there is no id to pass and therefore nothing to pass the wrong one. The guard admits all three caller kinds because it is shared, and the handler takes one: a developer bearer or a server key is `401 unauthorized`, the shape `POST /api/client/password/change` has, because a consent is a person's and a server key is not a person. \n\n**Every `client_name` is unverified**, on every row: dynamic registration takes no credential, so the name is text the client chose about itself and `client_name_verified` is the literal `false`. A screen that renders it as an identity is showing somebody a string an attacker picked, and this list is read long after the moment of approval, when nobody remembers what they clicked. **Withdrawn grants are absent**, not listed as withdrawn. \n\n**Not rate limited and not gated on the app's MCP switch.** It reads one small table for one account, and a person must be able to see and end what they agreed to even after a developer switches MCP off — a withdrawal door that closes with the feature is a door that is shut exactly when somebody wants it."
      }
    },
    "/api/client/mcp/grants/{clientId}": {
      "delete": {
        "operationId": "delete_api_client_mcp_grants_clientId",
        "summary": "Withdraws the signed-in app user's consent to one MCP client.",
        "tags": [
          "client-auth"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "clientId",
            "in": "path",
            "required": true,
            "description": "The MCP client, as `GET /api/client/mcp/grants` reports its `client_id`. Not a uuid — the identifier dynamic registration issued.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The person's own door, beside the developer's `DELETE /api/apps/:id/users/:userId/mcp-grants/:clientId`. It acts on the bearer's own account and on no other — the path carries a client and never a subject — so there is no user for a caller to name and none to confuse. The shared guard admits all three caller kinds and the handler takes one: a developer bearer or a server key is `401 unauthorized`, because withdrawing a consent is the same person's act as giving it. Audited as `app_user.mcp_grant_revoked`, with the app user themselves as the actor. \n\n**`204` whether or not there was anything to withdraw.** A client id this account never approved, and one it withdrew a minute ago, both answer the end state that was asked for: a `404` would tell the caller which clients some account has connected, and would make the ordinary double-click a failure. Only a withdrawal that ended a standing agreement is audited. \n\n**It ends a session already running, at that client's very next call.** The app's MCP endpoint reads this table on every request and keys the check on the `client_id` the access token carries, so the withdrawn client is answered `401` with a challenge and has to ask this person again. The token it holds is still unexpired — up to fifteen minutes are left on it — and is refused anyway. Nothing else stops: this ends one client, not the account, which is what blocking would end."
      }
    },
    "/api/robots": {
      "post": {
        "operationId": "post_api_robots",
        "summary": "Creates a robot and returns its bridge token once.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/create-robot-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`, `quota_exceeded`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`token` is the only moment the raw bridge token exists outside the caller's hands — the cloud stores a hash, so nothing can read it back and a caller who loses it rotates rather than recovers. Audited: this mints a credential that can speak for the org from anywhere, and the event carries no `details`, because the one interesting value here is the token. `max_robots` is checked before anything is created, which is only safe because robot deletion exists.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/create-robot-request"
              }
            }
          }
        }
      },
      "get": {
        "operationId": "get_api_robots",
        "summary": "Lists the org's robots with their connection state and exposure counts.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/robot-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}/token/rotate": {
      "post": {
        "operationId": "post_api_robots_id_token_rotate",
        "summary": "Mints a new bridge token for the robot and invalidates the old one.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/robot-token-rotate-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier, behind the org-scoped lookup, so a developer-tier admin sees the `404` a stranger would for a robot outside their org rather than a tier refusal that confirms the id exists. `token` is the only moment the new secret exists outside the caller's hands — the cloud stores a hash — so a caller who loses it rotates again. Audited as `robot.token_rotated`, with no `details`: the one interesting value here is the token. \n\n**It stops the bridge that is connected right now.** The old secret is gone the instant the hash is replaced, so the cloud closes that socket with `CLOSE_TOKEN_ROTATED` rather than leaving a bridge speaking on a credential nothing would accept again. A bridge that does not know the code reconnects and is refused at hello as `invalid_token`, which is the honest answer and ends the same way. **The robot is offline until somebody puts the new token on it** — this is a deliberate interruption, not a background rekey, and a fleet cannot be rotated without a visit to each robot."
      }
    },
    "/api/robots/{id}/urdf/joint-state": {
      "put": {
        "operationId": "put_api_robots_id_urdf_joint_state",
        "summary": "Chooses the datapoint whose joint positions move the robot's URDF, or clears it.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/joint-state-put-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**What qualifies**: a datapoint of the **published** configuration whose ROS type is `sensor_msgs/msg/JointState` and which carries no `field` — the whole message, because positions and names arrive together and a single extracted field is half of a pose. Anything else is a `validation_error` naming that rule rather than a stored mapping that renders a battery reading as a robot. `{ \"slug\": null }` clears it, which is why the field is required and nullable rather than optional. \n\n**The mapping cannot outlive what it points at.** Every successful publish re-checks it against the new document and clears it when it no longer qualifies, recording `robot.joint_state_cleared` with the version that did it; a slug rename rewrites it like every other reference the editor already rewrites; deleting the robot takes it along. Every write through this route — a slug or `null` — is on the record too, as `robot.joint_state_set` with the actor and the slug, so a clear a person made is never mistaken for one a publish made. The stored value reads back on `GET /api/robots/:id/assets` as `joint_state_slug`, so a renderer fetches the URDF, the meshes and the mapping from one place.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/joint-state-put-request"
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}": {
      "get": {
        "operationId": "get_api_robots_id",
        "summary": "Reads one robot with its published configuration state and live bridge state.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/robot-detail-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A robot belonging to another org reads exactly like one that does not exist — `404`, never a `403`."
      },
      "patch": {
        "operationId": "patch_api_robots_id",
        "summary": "Renames the robot.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/patch-robot-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"robot\": robot }`. The lookup runs before the body is parsed, so a robot outside the caller's org answers `404` whether or not the body was also malformed. Saving the name already held writes nothing and records no audit event.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/patch-robot-request"
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "delete_api_robots_id",
        "summary": "Deletes a robot and everything it produced.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "force",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Pass `true` to delete a robot that has a live session open; without it that is `409 robot_in_use`. **`true` and nothing else** — any other value is `400 validation_error`, reported against the field `force` with rule `invalid_value`, so a caller is never left believing they forced a deletion they did not. The deletion is a full cascade, which is why saying it is the whole decision.",
              "type": "string",
              "const": "true"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`, `robot_in_use`, `robot_deletion_partial`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier, and the gate runs **after** the org-scoped lookup: a developer-tier admin therefore sees the same `404` a stranger would for a robot outside their org, rather than a tier refusal that confirms the id exists. A full cascade — everything the robot produced goes, except the audit trail, which is a record of what happened and must survive the thing it happened to. An open live session is `409 robot_in_use` unless `?force=true` is passed, matched as the bare string so the caller has to actually say it. A cascade that fails partway is `500 robot_deletion_partial` with the progress, never a bare `internal_error` that would read as \"nothing happened\"."
      }
    },
    "/api/robots/{id}/deletion-preview": {
      "get": {
        "operationId": "get_api_robots_id_deletion_preview",
        "summary": "Reports what deleting the robot would destroy, without destroying it.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/robot-deletion-summary"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The same shape the delete's own audit event carries, computed by the same function on purpose: the confirmation dialog and the eventual receipt agree by construction, and any difference between them is real drift — a robot that kept recording in between — rather than two estimates that quietly disagree."
      }
    },
    "/api/robots/{id}/details": {
      "put": {
        "operationId": "put_api_robots_id_details",
        "summary": "Replaces the developer-maintained details document shown alongside the robot.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/put-robot-details-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Answers `{ \"details\": robotDetailsDoc }` — the stored document, which is the one that was sent. The update is fanned out to every `/realtime` subscriber of the `robot_details` built-in, so a client watching the robot sees the new document without polling.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/put-robot-details-request"
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}/datapoints": {
      "get": {
        "operationId": "get_api_robots_id_datapoints",
        "summary": "Lists the datapoints of a robot, filtered to what the caller's role grants.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/datapoint-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A developer bearer sees the robot's whole list unfiltered; an end user or a server key sees only the slugs their role grants, and a robot their app does not attach answers `404` exactly as one that does not exist."
      }
    },
    "/api/robots/{id}/exposures": {
      "get": {
        "operationId": "get_api_robots_id_exposures",
        "summary": "Lists every grantable slug of a robot with its kind — the material the roles matrix is built from.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/exposure-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Developer-only: this is what a role *could* be granted, which is a configuration fact rather than something an end user is entitled to enumerate."
      }
    },
    "/api/robots/{id}/datapoints/{slug}": {
      "get": {
        "operationId": "get_api_robots_id_datapoints_slug",
        "summary": "Reads the latest value of one datapoint.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The datapoint's slug from the published configuration, as listed by `GET /api/robots/:id/datapoints`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/datapoint-value"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `unknown_datapoint`, `no_data`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "For a client caller the grant check runs **before** any existence lookup, with no extra query on either path to time: a denied slug and a nonexistent one must be one answer. That is why an ungranted slug is `403 forbidden` while a granted-but-unconfigured one is `404 unknown_datapoint` and a configured one with no sample yet is `404 no_data` — three facts a caller who is entitled to them needs told apart. The plane built-ins (`bridge_state`, `robot_details`) answer here too, without appearing in any document."
      }
    },
    "/api/client/robots": {
      "get": {
        "operationId": "get_api_client_robots",
        "summary": "Lists the robots the caller reaches, with bridge state and the published configuration version.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/client-robot-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**The REST twin of the MCP tool `robots_list`**, and the one robot question no robot-scoped route can answer: which robots may I name at all. An app user sees the robots their app attaches on which their role grants at least one slug or capability; a server key sees every robot its app attaches; a developer bearer sees the organisation's robots. Name order, id as the tiebreak. A robot on which the role grants nothing is absent rather than listed empty — the same answer `robots_list` gives, for the same reason: reach is a grant, not an attachment. Under `/api/client/` because it names no robot; every robot-scoped read stays under `/api/robots/:id/…`."
      }
    },
    "/api/robots/{id}/datasheet": {
      "get": {
        "operationId": "get_api_robots_id_datasheet",
        "summary": "Describes everything the caller's role lets them do on one robot, with parameter schemas.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as `GET /api/client/robots` lists it.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/mcp-robot-datasheet"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**The REST twin of the MCP tool `robot_describe`**: one answer per robot — every datapoint, action, service, publisher and camera the role grants, each with its `input_schema` where it takes parameters, plus the two capabilities that gate whole features, `action_history` and `assets`. A robot with nothing published answers an empty `exposures` list, never a refusal. A robot the caller does not reach — not attached to their app, or attached with a role that grants nothing on it — answers `404` exactly as one that does not exist. The app-user datapoint and camera listings under this prefix stay; this is the one read that also names actions, services, publishers and capabilities, which is what an app needs before it can draw a screen."
      }
    },
    "/api/robots/{id}/config/draft": {
      "get": {
        "operationId": "get_api_robots_id_config_draft",
        "summary": "Reads the robot's configuration draft, its author text and its current issues.",
        "tags": [
          "config"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/config-draft-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Issues are recomputed on every read and every write, so an editor never has to guess whether it may publish. `doc` is `null` for a draft that is valid YAML but not a Fleetless configuration — a state the format admits and the publish route refuses."
      },
      "put": {
        "operationId": "put_api_robots_id_config_draft",
        "summary": "Replaces the draft with the author's text and answers the parsed document with its issues.",
        "tags": [
          "config"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/config-draft-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`, `invalid_yaml`, `unstorable_yaml`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The request carries the **text**, not a document: the author's comments and layout are what a restore has to give back, so the source is what is stored and the document is derived from it. Text that is not YAML at all is `422 invalid_yaml`, and text that parses but cannot be stored — an anchor cycle, say — is `422 unstorable_yaml`. A document with schema errors is still stored, because the draft is where a developer works; publishing is where the errors block.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/put-config-draft-request"
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}/config/publish": {
      "post": {
        "operationId": "post_api_robots_id_config_publish",
        "summary": "Publishes the draft as an immutable version and sends it to the robot.",
        "tags": [
          "config"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/publish-config-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`, `draft_not_a_document`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A draft that is valid YAML but not a Fleetless configuration is `422 draft_not_a_document`, carrying every issue rather than the blocking subset — nothing about that text is publishable, so there is no subset to pick, and the warning naming the checks that could not run is part of reading the list correctly. A document with `severity: \"error\"` issues is `422 validation_error` with just those. The draft's own text travels into the version, so a restore later returns what the author wrote rather than a re-rendering of it."
      }
    },
    "/api/robots/{id}/config/versions": {
      "get": {
        "operationId": "get_api_robots_id_config_versions",
        "summary": "Lists the published configuration versions of a robot with their publish times.",
        "tags": [
          "config"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/config-versions-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}/config/versions/{v}": {
      "get": {
        "operationId": "get_api_robots_id_config_versions_v",
        "summary": "Reads one published version: its document and the author text it was published from.",
        "tags": [
          "config"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "v",
            "in": "path",
            "required": true,
            "description": "The version number, as listed by `GET /api/robots/:id/config/versions`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/config-version-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A `:v` that is not a version number and one that names no version of this robot are the same `404`; the refusal quotes what the caller actually sent."
      }
    },
    "/api/robots/{id}/config/versions/{v}/restore": {
      "post": {
        "operationId": "post_api_robots_id_config_versions_v_restore",
        "summary": "Copies a published version back into the draft, text and document both.",
        "tags": [
          "config"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "v",
            "in": "path",
            "required": true,
            "description": "The version number, as listed by `GET /api/robots/:id/config/versions`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/config-draft-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**Both halves, not just the document** — a restore that put back the document alone would hand the author a configuration stripped of every comment they wrote, which is the loss this format exists to prevent. The answer is read off the row that was written, not off the version that was meant to be written. Nothing is published: the restored draft still has to be published to reach the robot."
      }
    },
    "/api/robots/{id}/config/rename-slug": {
      "post": {
        "operationId": "post_api_robots_id_config_rename_slug",
        "summary": "Renames a slug in the draft and moves every role grant and the recorded history with the name.",
        "tags": [
          "config"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/rename-slug-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`, `draft_not_a_document`, `unknown_slug`, `reserved_slug`, `duplicate_slug`, `history_migrating`, `internal_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "One transaction: the draft document and every app-role grant carrying the slug are rewritten, and the recorded history moves with the name without rewriting any stored sample, so a rename takes the same time however much history there is. Samples the robot still sends under the old slug until the next publish join the renamed history. The published configuration is immutable, so `requires_publish` says the rename is not live on the robot yet. A draft that is not a document is `409 draft_not_a_document` — the same word the usage preview uses for the same state. While the recorded history is being migrated the rename is refused with `409 history_migrating`; nothing is written, and the same request succeeds once the migration has finished.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/rename-slug-request"
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}/config/slug-usage/{slug}": {
      "get": {
        "operationId": "get_api_robots_id_config_slug_usage_slug",
        "summary": "Reports what a rename of one slug would touch, before a developer confirms it.",
        "tags": [
          "config"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The slug in the **draft** whose blast radius is being previewed.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/slug-usage-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `draft_not_a_document`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A draft that is valid YAML but not a Fleetless document is refused rather than answered with `alert_count: 0`: the two states are *this slug has no alerts* and *there is no document to ask*, and a zero cannot tell them apart — it would show a smaller blast radius than the rename actually has. The other three counts are real whatever the draft holds, and a partial answer to a preview whose whole purpose is to be complete is not worth the ambiguity."
      }
    },
    "/api/robots/{id}/alerts": {
      "get": {
        "operationId": "get_api_robots_id_alerts",
        "summary": "Lists a robot's alerts as defined in its published configuration, joined with their runtime state.",
        "tags": [
          "alerts"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/alert-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**Read-only, and that is the design.** An alert used to be created, edited and deleted through this file; it is now a key in the published document, which is what makes every change to one versioned, comparable and revertible. The **published** version is read, never the draft: an alert typed but not published is evaluated by nothing, and reporting its state would claim a reading no machine has taken."
      }
    },
    "/api/org/alerts": {
      "get": {
        "operationId": "get_api_org_alerts",
        "summary": "Lists every firing alert across the org, with the robot each belongs to.",
        "tags": [
          "alerts"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "state",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "const": "firing",
              "description": "Required, and the only accepted value — this endpoint lists the alerts that are firing now. Anything else, the parameter's absence included, is `400 validation_error`: a door with one answer must not advertise a dial."
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/org-firing-alerts-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`?state=firing` is required and is the only value accepted — refused rather than silently ignored, because a door with one answer must not advertise a dial. A firing row whose definition has left the document, or has been disabled, is skipped: it can never be evaluated again, so it can never resolve, and it would otherwise sit in the overview's open-issues tile forever."
      }
    },
    "/api/robots/{id}/introspection": {
      "get": {
        "operationId": "get_api_robots_id_introspection",
        "summary": "Reads the cached ROS graph of a robot and whether it is stale.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/introspection-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A robot that has never been introspected answers `200` with a **`null` body**, not a `404`: an enrichment that has not happened yet is not a missing resource. The response schema describes the non-null case. `stale` is true whenever the bridge is offline — the snapshot survives a disconnect, since a robot that has never connected is still configurable."
      }
    },
    "/api/robots/{id}/introspection/refresh": {
      "post": {
        "operationId": "post_api_robots_id_introspection_refresh",
        "summary": "Asks the robot for a fresh ROS graph, stores it and answers it.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/introspection-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `robot_offline`, `bridge_timeout`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`stale` is `false` by construction here: the graph came from the robot just now. `409 robot_offline` means nothing is connected; `504 bridge_timeout` means something was and did not answer. Anything else is rethrown rather than turned into a tidy status."
      }
    },
    "/api/robots/{id}/types": {
      "get": {
        "operationId": "get_api_robots_id_types",
        "summary": "Lists every ROS message type definition stored for the robot.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/types-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A plain read with no bridge involved — the robot need not be online."
      }
    },
    "/api/robots/{id}/types/fetch": {
      "post": {
        "operationId": "post_api_robots_id_types_fetch",
        "summary": "Fetches named message type definitions from the robot and stores them.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/fetch-types-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`, `robot_offline`, `bridge_timeout`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`unresolved` names the types the robot could not produce; it is an answer, not a failure, because a graph often references a type whose package is not installed. The robot lookup runs before the body is parsed, so a robot outside the caller's org answers `404` whether or not the body was also malformed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/fetch-types-request"
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}/jobs": {
      "get": {
        "operationId": "get_api_robots_id_jobs",
        "summary": "Reads the current job on every slug of the robot the caller is granted.",
        "tags": [
          "commands"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/robot-jobs-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**At most one entry per slug, and not a history endpoint.** The first version answered every job the registry still held — six rows and four full result payloads after a few minutes of traffic on one robot, unbounded for a robot that has run all day. This reads the one-current-job-per-slug map instead. It exists because the per-slug route alone cannot cover it: a reconciled-but-unminted job, or one left on a slug a republish removed, has no slug-shaped door to be found through."
      }
    },
    "/api/robots/{id}/jobs/history": {
      "get": {
        "operationId": "get_api_robots_id_jobs_history",
        "summary": "Reads what has run on the robot, newest first, cursor-paged.",
        "tags": [
          "commands"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "before_seq",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Return only runs with a `seq` below this value — the next, older page. Send back the `next_cursor` of the previous response rather than computing one.",
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,19}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "description": "How many runs to return, from `1` to `200`. Absent means `100`. It arrives on the query string, so a numeric string and a number are both accepted.",
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,4}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "robot_id",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only runs on this robot. Absent means every robot in the organisation.",
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            }
          },
          {
            "name": "slug",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only runs of this action or service.",
              "type": "string",
              "minLength": 2,
              "maxLength": 63,
              "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only runs in this state — `running`, `unknown`, `succeeded`, `failed`, `cancelled` or `lost`.",
              "type": "string",
              "enum": [
                "running",
                "unknown",
                "succeeded",
                "failed",
                "cancelled",
                "lost"
              ]
            }
          },
          {
            "name": "kind",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only `action` runs, or only `service` runs.",
              "type": "string",
              "enum": [
                "action",
                "service"
              ]
            }
          },
          {
            "name": "from_ms",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only runs that started at or after this unix timestamp in milliseconds. Together with `to_ms` the window is half-open, `[from, to)`, so adjacent windows tile without counting a run twice.",
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "to_ms",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only runs that started **before** this unix timestamp in milliseconds. The window is half-open, so a run starting exactly on `to_ms` belongs to the next one.",
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/job-run-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `capability_required`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Needs the `action_history` capability, and **this route is what makes that switch mean something** — it was unkeepable while nothing durable recorded what had run. Two residuals worth stating rather than implying away. `history` is a syntactically valid slug, and a static segment matches before a parameter, so a robot with a service literally slugged `history` can no longer be **read** through `GET /api/robots/:id/jobs/:slug`; invoking, cancelling and the listing are unaffected. And a run row names its actor by email address, so an end user holding this capability learns which other people have been driving the machine. `robot_id` in the query is shared with the org-wide read; a *different* one here is refused rather than quietly answered about the robot in the path."
      }
    },
    "/api/robots/{id}/jobs/{slug}": {
      "post": {
        "operationId": "post_api_robots_id_jobs_slug",
        "summary": "Invokes an action or calls a service on the robot.",
        "tags": [
          "commands"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The action or service slug from the published configuration — the cloud already knows which kind.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/invoke-or-service-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `validation_error`, `parameter_invalid`, `robot_offline`, `busy`, `bridge_timeout`, `internal_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**One route for both kinds**, because a path segment naming the kind would demand a fact a role grant does not carry. An action answers `202` with an `invokeResponse` the moment the job exists; a service answers `200` with a `serviceCallResponse` once the result is in — two shapes, carried by one union (`invokeOrServiceResponse`) and told apart by whether `kind` or a bare `result` arrives. Parameters are checked **before** anything about the world (offline, busy): the same request must get the same verdict whether or not the robot happens to be reachable, or a developer testing against an offline robot never learns their parameters were wrong. A slug is `409 busy` while it holds a `running` job, an `unknown` one the robot has not accounted for yet, or an `external` goal someone else started; the refusal's `details.running` names that job, `state` and `origin` included. A service the robot reports as failed answers `502` carrying **the job's own error code**, which is an open set and not one of the codes above.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/invoke-request"
              }
            }
          }
        }
      },
      "get": {
        "operationId": "get_api_robots_id_jobs_slug",
        "summary": "Reads the most recent job on one slug.",
        "tags": [
          "commands"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The action or service slug from the published configuration.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/job-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`job` is `null` when nothing has ever run on that slug — an answer, not a `404`."
      }
    },
    "/api/robots/{id}/jobs/{slug}/cancel": {
      "post": {
        "operationId": "post_api_robots_id_jobs_slug_cancel",
        "summary": "Cancels the job running on one slug.",
        "tags": [
          "commands"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The action slug from the published configuration; a service slug is refused.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/job-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `validation_error`, `not_cancellable`, `robot_offline`, `cancel_rejected`, `bridge_timeout`, `unknown_slug`, `action_server_lost`, `internal_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The body is optional: a bodyless `POST` was every caller's shape before `job_id` existed, and absent or `job_id: null` both mean \"cancel whatever is running\". A named `job_id` that is **not** what is running cancels nothing and answers `404` — the caller named an id and thereby ruled the other one out. An `external` job is cancelled the same way, through its goal id. Cancelling an `unknown` job also cancels every external goal on its action, since one of them may be that job. A service is `422 not_cancellable`: a service call has no goal to cancel. Nothing running is a `200` with `job: null`. The answer waits for the bridge's `cancel_result`: a `200` means the action server accepted the cancel request, not that the goal has ended — the job's end arrives as its own update. Any goal answered `ERROR_REJECTED` makes it `409 cancel_rejected`, with every goal and its `return_code` in `details.goals`; no answer within `JOB_HEARTBEAT_TIMEOUT_MS` is `504 bridge_timeout`; a cancel the bridge could not send at all is `502` carrying the bridge's own code (`unknown_slug`, `action_server_lost`, `internal_error`).",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/cancel-request"
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}/publishers/{slug}": {
      "post": {
        "operationId": "post_api_robots_id_publishers_slug",
        "summary": "Publishes one message onto a configured publisher.",
        "tags": [
          "commands"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The publisher slug from the published configuration.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `validation_error`, `parameter_invalid`, `robot_offline`, `publisher_busy`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Fire and forget — not a job, so there is nothing to poll and nothing to cancel. A publisher is held exclusively by one caller until it has been quiet long enough, and another caller meanwhile is `409 publisher_busy` with the timeout and a retry hint. Parameters are checked before offline and before exclusivity, the same order the invoke path uses and for the same reason. The **acquisition** is audited, not every message: auditing only takeovers left the single-operator case with no record of who was driving at all.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/publish-request"
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}/cameras": {
      "get": {
        "operationId": "get_api_robots_id_cameras",
        "summary": "Lists the cameras of a robot, filtered to what the caller's role grants.",
        "tags": [
          "cameras"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/camera-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}/cameras/{slug}/snapshot": {
      "get": {
        "operationId": "get_api_robots_id_cameras_slug_snapshot",
        "summary": "Returns the most recent snapshot frame as image bytes.",
        "tags": [
          "cameras"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "image/*": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `no_snapshot_yet`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Image bytes, not JSON, so it has no response schema. `contentType` is the family rather than a type: the frame is served in **the mime the producer sent it as**, so which image format arrives is the camera configuration's answer, not this route's. The age, capture time and dimensions ride in the `x-fleetless-*` headers `SNAPSHOT_HEADERS` names — which a browser can only read because CORS exposes them. **Never checks whether the bridge is online**: a snapshot read is a pure cache read, which is what makes \"the last frame, with its real age\" true for free across a disconnect. There is nothing here to refuse, and `age_ms` carries the whole honesty story. `cache-control: no-store`, because a picture of someone's premises does not belong on disk longer than the request that fetched it."
      }
    },
    "/api/robots/{id}/cameras/{slug}/snapshot/meta": {
      "get": {
        "operationId": "get_api_robots_id_cameras_slug_snapshot_meta",
        "summary": "Reports the age and dimensions of the latest snapshot without downloading it.",
        "tags": [
          "cameras"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/snapshot-meta-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Exists so a client polling at the camera's own interval does not re-fetch a whole frame merely to learn whether a newer one arrived. Nothing captured yet is **nulls, not a `404`**: \"nothing yet\" is an answer."
      }
    },
    "/api/robots/{id}/cameras/{slug}/live": {
      "post": {
        "operationId": "post_api_robots_id_cameras_slug_live",
        "summary": "Takes a hold on a live camera stream and returns a room token.",
        "tags": [
          "cameras"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/live-session-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `robot_offline`, `camera_offline`, `live_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Refcounted: the first viewer starts the robot publishing and the last release stops it. No token is ever minted for an ungranted or offline camera — both refusals return before the hold is taken. `409 camera_offline` means the **robot itself** reported the failure; `502 live_unavailable` means this cloud could not start the stream. The difference matters, and it is why a failure the robot named is never dressed up as one this side invented."
      },
      "delete": {
        "operationId": "delete_api_robots_id_cameras_slug_live",
        "summary": "Releases a live hold, one session or all of this caller's.",
        "tags": [
          "cameras"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "session_id",
            "in": "query",
            "required": false,
            "schema": {
              "description": "The one hold to release, as the live session returned it. Absent releases **all** of this identity's holds on this camera — the blunt form, still needed by a client that has lost its id or is going away, and the one that strands the identity's other tabs.",
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`?session_id=` releases that one hold; omitting it releases every hold this caller's identity has on this camera, which a client that lost its id — or a tab that is already closing — still needs. A malformed `session_id` is `400 invalid_uuid`, never a silent fallback to the blunt form, which would strand this identity's other tabs over a typo. A named-and-unknown id is `404`; a stale one, real and already ended, is idempotently `204`."
      }
    },
    "/api/robots/{id}/datapoints/{slug}/history": {
      "get": {
        "operationId": "get_api_robots_id_datapoints_slug_history",
        "summary": "Reads recorded samples of one datapoint, or aggregated buckets over a window.",
        "tags": [
          "robots"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The datapoint's slug from the published configuration.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 32,
              "description": "The start of the window: either a relative expression — `now-30s`, `now-5m`, `now-1h` — or absolute unix milliseconds. A chart asks the first way and a report asks the second, and making a client convert would be making it guess our clock."
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "schema": {
              "description": "The end of the window, in the same two spellings as `from`; absent means now. The window is half-open, `[from, to)`, so a sample landing exactly on `to` belongs to the next window and adjacent windows tile without double-counting.",
              "type": "string",
              "minLength": 1,
              "maxLength": 32
            }
          },
          {
            "name": "window",
            "in": "query",
            "required": false,
            "schema": {
              "description": "The bucket width, such as `10s` or `1m`. Absent means raw samples. It is meaningless without `agg`, and the pair is refused apart rather than defaulted — a silently chosen aggregation is a chart that lies quietly.",
              "type": "string",
              "minLength": 2,
              "maxLength": 16
            }
          },
          {
            "name": "agg",
            "in": "query",
            "required": false,
            "schema": {
              "description": "How each bucket reduces the samples inside it. Valid only together with `window`.",
              "type": "string",
              "enum": [
                "min",
                "max",
                "avg"
              ]
            }
          },
          {
            "name": "field",
            "in": "query",
            "required": false,
            "schema": {
              "description": "A dotted path to a numeric field inside an object value, such as `pose.x`. Without it the datapoint's value is used whole, which only works when it is already a number.",
              "type": "string",
              "minLength": 1,
              "maxLength": 128
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "description": "The most samples or buckets to return, from `1` to `10000`. It arrives as text on the query string, so both a numeric string and a number are accepted; the ceiling is enforced after parsing rather than by the published shape.",
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,5}$",
                  "description": "Positive integer, 1-10000. The pattern only bounds digit count; the real ceiling is enforced after parsing."
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991,
                  "description": "Positive integer, 1-10000. The ceiling is enforced after parsing, not by this type."
                }
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/history-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `validation_error`, `invalid_range`, `not_recorded`, `not_aggregatable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Two answers, carried by one union (`historyResponse`): without `window` it is a `historySamplesResponse`, with one it is a `historyBucketsResponse`, told apart by `kind`. `window` and `agg` must be given together or not at all — one without the other is refused rather than defaulted, since a silently chosen aggregation is a chart that lies quietly. A range and window that would produce more buckets than `limit` is `400 invalid_range` computed **before** the query runs: the bucket response carries no `truncated` field, so a refusal is the only honest answer. `409 not_recorded` says retention is off for this slug **right now** and deliberately does not claim the table is empty — rows written before the switch was flipped still exist, unreadable through any route and still counting against the quota."
      }
    },
    "/api/robots/{id}/assets": {
      "get": {
        "operationId": "get_api_robots_id_assets",
        "summary": "Lists the robot's synced assets and how complete its URDF is.",
        "tags": [
          "assets"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/asset-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `capability_required`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Needs the `assets` capability, refused as `403 capability_required` rather than a bare `forbidden`: the code says a capability is missing and the message says which, so a developer who switched the wrong toggle on is told what to switch. The capability is checked before existence, so a denied robot and an absent one read alike to a caller with no right to tell them apart. `urdf` reports whether a URDF is present and which of its mesh references have no stored asset."
      },
      "delete": {
        "operationId": "delete_api_robots_id_assets",
        "summary": "Empties a robot's asset store: every URDF, mesh and texture, gone at once.",
        "tags": [
          "assets"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/assets-clear-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `tier_required`, `invalid_uuid`, `not_found`, `busy`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The store's escape hatch: a full store is never a dead end, and this is the blunt third of the three answers to it — the URDF upload is exempt from the gate, reconcile after a sync already frees what the new URDF stopped referencing, and this route lets an Owner clear the robot outright. Owner tier, unconditionally, like starting a sync. Removes every asset of the robot and resets its store to `0`; the next sync fills it again. It does not touch the bridge's availability report — `urdf_available` still answers from the connected robot, unrelated to what this cloud happens to have stored. A clear while a sync is running is `409 busy` naming that sync's details, the same refusal starting a second sync gets, because deleting under a running upload would leave the store counter wrong."
      }
    },
    "/api/robots/{id}/assets/{assetId}": {
      "get": {
        "operationId": "get_api_robots_id_assets_assetId",
        "summary": "Returns one stored asset as bytes.",
        "tags": [
          "assets"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "assetId",
            "in": "path",
            "required": true,
            "description": "The asset's uuid, as listed by `GET /api/robots/:id/assets`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `capability_required`, `internal_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Bytes, so it has no response schema. **`contentType` here is the floor, not the answer**: the header carries the asset's own stored media type when that type is on the cloud's allow-list, and `application/octet-stream` only when it is not — an allow-list rather than a pass-through, because a stored type is developer-supplied and a browser will act on it. `X-Content-Type-Options: nosniff` rides along for the same reason. A row whose blob has vanished from object storage is a logged `500 internal_error`, not a `404`: the asset exists and this cloud could not read it, which is a different fact from \"there is no such asset\"."
      }
    },
    "/api/robots/{id}/urdf": {
      "get": {
        "operationId": "get_api_robots_id_urdf",
        "summary": "Returns the robot's URDF with every mesh reference rewritten to a Fleetless URL.",
        "tags": [
          "assets"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/xml": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `capability_required`, `internal_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "XML, so no response schema. **Every `filename` is rewritten, not only a resolvable `package://` one** — an absolute URL that arrived in a URDF from ROS graph input must never be served through untouched, because a mesh loader attaches the caller's bearer token to whatever absolute URL it is handed. Anything with no stored asset points at `GET /api/robots/:id/assets/missing` instead. A robot with no synced URDF is `404`."
      }
    },
    "/api/robots/{id}/assets/missing": {
      "get": {
        "operationId": "get_api_robots_id_assets_missing",
        "summary": "The placeholder a rewritten URDF points at for a mesh Fleetless does not hold.",
        "tags": [
          "assets"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid; an end user reaches it through an app that attaches it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "name",
            "in": "query",
            "required": false,
            "schema": {
              "description": "The unresolved reference, as the URDF spelled it, echoed into the `404 asset_missing` message. Omitted, the message names `unknown` instead. It never changes the status.",
              "type": "string"
            }
          }
        ],
        "responses": {
          "404": {
            "description": "The only answer this route gives; see the error codes below."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`, `capability_required`, `asset_missing`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "**This route has no success answer** — `404 asset_missing` naming the unresolved reference is what it exists to give, and `status` says so rather than declaring a `200` no caller can ever receive. `?name=` is echoed into the message and changes the sentence, never the outcome; it discloses nothing, since it is what the caller sent. It carries the same `assets` capability gate as the real bytes would: a missing-asset placeholder is not an exemption from the authorization the thing it stands in for needs."
      }
    },
    "/api/asset-links/missing": {
      "get": {
        "operationId": "get_api_asset_links_missing",
        "summary": "The bearer-free placeholder a *linked* URDF points at for an unresolvable mesh.",
        "tags": [
          "assets"
        ],
        "security": [],
        "parameters": [],
        "responses": {
          "404": {
            "description": "The only answer this route gives; see the error codes below."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `asset_missing`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The signed token in the path is the credential; no other authentication applies. **No success answer either**, for the reason its authenticated twin has none. Unauthenticated by design and unauthenticated in fact: it reads nothing and reveals nothing the caller did not put in the query string itself, so there is no credential for the handler to verify and none is required. It sits under the signed-link prefix because that is where a linked URDF's references have to point."
      }
    },
    "/api/asset-links/{token}": {
      "get": {
        "operationId": "get_api_asset_links_token",
        "summary": "Serves one asset, or a rendered URDF, to whoever holds a signed link.",
        "tags": [
          "assets"
        ],
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "description": "The signed, time-limited link an MCP tool minted; it is the whole credential.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `not_found`, `internal_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The signed token in the path is the credential; no other authentication applies. **The token is the authorization** — there is no route guard on purpose, and verifying it is the whole gate. An MCP session token is refused on REST by design, so the asset tools mint a fifteen-minute signed link instead and this spends it. The capability was checked at mint against the minting caller's own access; the residual — whoever holds the URL reads that asset until it expires — is named rather than closed by a second gate, which would be a different policy for one decision. Every refusal collapses into one `404` with one message, including a malformed id inside a validly signed token, because a link holder has no business learning which of them it was. A URDF served this way has **its own references minted as links**, back-dated so they expire with the parent — otherwise spending a link in its last second would hand out another fifteen minutes, and each of those another. **`contentType` is the floor, not the answer**: an asset is served in its own stored media type where that type is allow-listed and `application/octet-stream` otherwise, and a linked URDF is `application/xml`. `cache-control: no-store`, since the URL itself is the credential."
      }
    },
    "/api/robots/{id}/assets/sync": {
      "post": {
        "operationId": "post_api_robots_id_assets_sync",
        "summary": "Asks the robot to upload its URDF and meshes, and returns the sync id.",
        "tags": [
          "assets"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/asset-sync-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `tier_required`, `invalid_uuid`, `validation_error`, `not_found`, `robot_offline`, `busy`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier, unconditionally. The guard admits an end user or a server key, but only a developer session gets past the handler — and the body is parsed **before** that `401`, because this route has always answered a malformed body first and the order has to survive. The request is strict: a caller naming a source that does not exist learns so, instead of silently getting a bridge sync they did not ask for. A robot that has reported nothing available to sync is `404`. A second sync is `409 busy` naming the `sync_id` that is actually running, so the caller who pressed the button twice can pick it straight up.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/asset-sync-request"
              }
            }
          }
        }
      }
    },
    "/api/robots/{id}/assets/sync/{syncId}": {
      "get": {
        "operationId": "get_api_robots_id_assets_sync_syncId",
        "summary": "Reports how far an asset sync has got.",
        "tags": [
          "assets"
        ],
        "security": [
          {
            "developerSession": []
          },
          {
            "clientToken": []
          },
          {
            "serverKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "syncId",
            "in": "path",
            "required": true,
            "description": "The sync id from `POST /api/robots/:id/assets/sync`, or from its `409 busy` refusal.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/asset-sync-status"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Developer sessions only, like starting a sync: the guard admits three caller kinds and the handler answers `401 unauthorized` to the other two. A sync belonging to another robot reads exactly like one that never existed, which is why the robot is resolved first."
      }
    },
    "/api/org/quotas": {
      "get": {
        "operationId": "get_api_org_quotas",
        "summary": "Reports every quota's limit next to what the org is currently using.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/org-quota-usage"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Every dial is read at the moment of the call and nothing is cached, so an exhausted quota is self-evident from this one answer rather than something a developer needs audit access to discover. `max_end_users` counts app users only — an org admin is not an app user, and counting the whole pool would report the Owner an org has by construction as consumption. It is summed **across the org's apps**, because the same address in two apps is two accounts, and that sum is the number the four routes that create an app user refuse `409 quota_exceeded` against: `POST /api/apps/:id/users`, `POST /api/client/register`, `POST /api/client/invitations/accept`, and a federated sign-in that would create an account, which carries `quota_exceeded` back to the app as its error redirect. A gauge nothing enforces is a number that reads as a limit and is not one."
      }
    },
    "/api/org/health": {
      "get": {
        "operationId": "get_api_org_health",
        "summary": "Reports the health of every camera and streaming resource across the org.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "robot_id",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Narrows the report to one robot. Omit it for every robot in the org. Malformed is `400 invalid_uuid` and a robot of another org is `404 not_found` — the same two answers an MCP caller gets, because the check lives in the shared service rather than on the route.",
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/resource-health-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`?robot_id=` narrows it to one robot; omitted, the answer is the whole org. Org-wide rather than per-robot because the console shows health on the robot list too, and a per-robot path would make that N requests to render one screen. This is the snapshot half of the channel; the live half is the `/realtime` socket."
      }
    },
    "/api/org/jobs": {
      "get": {
        "operationId": "get_api_org_jobs",
        "summary": "Reads durable job-run history across the org, newest first, cursor-paged.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "before_seq",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Return only runs with a `seq` below this value — the next, older page. Send back the `next_cursor` of the previous response rather than computing one.",
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,19}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "description": "How many runs to return, from `1` to `200`. Absent means `100`. It arrives on the query string, so a numeric string and a number are both accepted.",
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,4}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "robot_id",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only runs on this robot. Absent means every robot in the organisation.",
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            }
          },
          {
            "name": "slug",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only runs of this action or service.",
              "type": "string",
              "minLength": 2,
              "maxLength": 63,
              "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only runs in this state — `running`, `unknown`, `succeeded`, `failed`, `cancelled` or `lost`.",
              "type": "string",
              "enum": [
                "running",
                "unknown",
                "succeeded",
                "failed",
                "cancelled",
                "lost"
              ]
            }
          },
          {
            "name": "kind",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only `action` runs, or only `service` runs.",
              "type": "string",
              "enum": [
                "action",
                "service"
              ]
            }
          },
          {
            "name": "from_ms",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only runs that started at or after this unix timestamp in milliseconds. Together with `to_ms` the window is half-open, `[from, to)`, so adjacent windows tile without counting a run twice.",
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "to_ms",
            "in": "query",
            "required": false,
            "schema": {
              "description": "Only runs that started **before** this unix timestamp in milliseconds. The window is half-open, so a run starting exactly on `to_ms` belongs to the next one.",
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/job-run-list-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Developer-only, and that is a property of the scope: a run row names the actor who invoked it, so a client-facing version would tell one end user which others have been driving the machine. Page until the cursor is null, not until a page looks short. A malformed `robot_id` is refused by the query schema as a `validation_error`; an unknown but well-formed one is an empty list, never a `404` — it is a filter."
      }
    },
    "/api/org/jobs/summary": {
      "get": {
        "operationId": "get_api_org_jobs_summary",
        "summary": "Counts the running, started and failed job runs since a moment the caller names.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "since_ms",
            "in": "query",
            "required": true,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/job-run-summary"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`since_ms` is required and has no default: which day \"today\" is, only the browser knows, and a cloud that chose its own boundary would show a developer in another timezone a number they cannot reproduce. The window is echoed back so a rendered tile can say what it is describing."
      }
    },
    "/api/org/latency": {
      "get": {
        "operationId": "get_api_org_latency",
        "summary": "Reads one-minute bridge latency buckets per robot over a window the caller names.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "from_ms",
            "in": "query",
            "required": true,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "to_ms",
            "in": "query",
            "required": true,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{1,15}$"
                },
                {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                }
              ]
            }
          },
          {
            "name": "robot_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/org-latency-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Both bounds are required: the table holds a bucket per robot per minute, so \"everything\" is thousands of rows per robot and a default window would be a query size chosen by whoever forgot to pass one. `from_ms < to_ms` is a cross-field rule the published JSON Schema cannot express, so this route is the only place it is enforced. `truncated` costs whole robots off the end of the id order, not the tail of every series — narrow the window or name a `robot_id`."
      }
    },
    "/api/org/usage": {
      "get": {
        "operationId": "get_api_org_usage",
        "summary": "Reads what the org consumed per day, per app and per metric.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "from_day",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
            }
          },
          {
            "name": "to_day",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/org-usage-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A window longer than `USAGE_WINDOW_MAX_DAYS` is refused naming the field, not silently capped: a caller who asked for more than the platform will answer is owed a refusal, not a shorter answer they will mistake for the whole picture. `from_day <= to_day` is a cross-field rule no JSON Schema can express and is enforced here. The window is echoed back."
      }
    },
    "/api/feedback": {
      "post": {
        "operationId": "post_api_feedback",
        "summary": "Sends a message from a developer to the people who build Fleetless.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "202": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/feedback-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`, `rate_limited`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The message is stored before any mail is tried, so `202` means it is kept whatever `mail` says: `sent`, `failed`, or `not_configured` when this cloud has no feedback address. At most 10 messages per developer per hour; the 11th answers `429 rate_limited` with `retry_after_ms`. Replies come by mail, to the sender's address.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/feedback-request"
              }
            }
          }
        }
      }
    },
    "/api/org/plan": {
      "get": {
        "operationId": "get_api_org_plan",
        "summary": "Reads the org's plan: its limits, its usage against them, its add-ons and any change already queued.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/org-plan"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The one read the console's Plan & billing page, its usage and limit gauges, and every upgrade prompt and feature gate draw from — nothing else computes `limits` or `usage` on its own. `limits` is already the effective ceiling, the catalogue row raised by `addons` or replaced by an operator's override, so a consumer never recomputes it from the catalogue. `usage` is counted fresh on every call, never cached. `switch` is present only for an organization still on the beta that has not yet landed on a priced plan."
      }
    },
    "/api/org/plan/change": {
      "put": {
        "operationId": "put_api_org_plan_change",
        "summary": "Moves the org's plan down — a lower plan or a cancellation to Basic — queuing the change rather than applying it at once.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/org-plan"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `validation_error`, `plan_limit`, `target_state_conflict`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier, and **downward only**: this route moves the org to a lower plan or cancels it outright to Basic. It never moves the org up — an upgrade or an add-on goes through the billing routes instead (`POST /api/billing/checkout`, `POST /api/billing/change`). `409 target_state_conflict` names `target_plan` with rule `not_lower` when the chosen plan is not below the org's current one; with rule `locked_basic_only` when the org is locked (`orgLock`) and the chosen plan is anything but Basic; and with rule `migration_basic_only` when the org is still on the beta, awaiting the switch to priced plans, and the chosen plan is anything but Basic — that choice is exactly what the org lands on at the switch. An owner is never named in `keep` and always stays, but still counts against the target plan's `seats`; `409 plan_limit` names `seats` when the owners alone already exceed it, and names whichever other limit `keep` still exceeds otherwise. `keep` is `null` when the org's current usage already fits the target plan outright and nothing is deleted; named, it lists exactly the robots, apps, app users and developers that stay. The choice is stored as `pending_change` and takes effect at `period_ends_at` — **everything of the chosen kind not named in `keep` is deleted at that instant, never before** — except a choice made while the org is locked, which takes effect at once, and a beta org's choice, which takes effect at the switch date instead. Anything created while the choice is pending is checked against the target plan too and, when it passes, is folded into `keep`, so exactly what the confirmation counted is what is actually deleted. A later `PUT` replaces a still-pending choice outright.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/plan-change-request"
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "delete_api_org_plan_change",
        "summary": "Withdraws a plan change that was queued but has not taken effect yet.",
        "tags": [
          "org"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "204": {
            "description": "Success."
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Owner tier. `404 not_found` when the org has no `pending_change` to withdraw. The org stays on its current plan, unchanged, as if the choice had never been made; a developer who wants a different one sends a new `PUT`, which would have replaced this one outright anyway."
      }
    },
    "/api/billing": {
      "get": {
        "operationId": "get_api_billing",
        "summary": "Reads the org's billing account, payment method and invoices.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/billing-view"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`available: false` when `MOLLIE_API_KEY` is not configured — this cloud takes no payments, and every billing route that charges or opens a Mollie checkout answers `503 billing_unavailable` instead of acting; cancel, resume, the details and the VAT-ID check need no Mollie and answer normally. `account` is `null` before the org has ever checked out; the plan and its limits still come from `GET /api/org/plan` (fleetless/fleetless issue 103) and are not repeated here."
      }
    },
    "/api/billing/checkout": {
      "post": {
        "operationId": "post_api_billing_checkout",
        "summary": "Starts a Mollie checkout for a plan, or an upgrade paid at once.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/checkout-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `validation_error`, `plan_limit`, `target_state_conflict`, `rate_limited`, `billing_unavailable`, `payment_provider_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Rate limited on the `billing.checkout` bucket, same as `POST /api/billing/payment-method` and `POST /api/billing/invoices/:id/pay` — the three routes that mint a Mollie checkout. `400 validation_error` names who may not pay with these rules: `{ field: 'billing.address.country', rule: 'eu_person' }` for a person in another EU country, `{ field: 'billing.vat_id', rule: 'vat_id_required' }` for a company there with no VAT ID, `{ field: 'billing.vat_id', rule: 'vat_id_invalid' }` once VIES has said so, and `{ field: 'accept_withdrawal', rule: 'required' }` for a person who did not confirm it. `409 target_state_conflict` names `plan` with rule `already_billed` when the org already has an `active` or `past_due` billing account — checkout is for the first payment only, every later change is `POST /api/billing/change`. `checkout_url` is Mollie's hosted page, where the payer chooses card, PayPal or Apple Pay; the return lands on `<console>/settings/billing?checkout=<checkout_id>`, which polls `GET /api/billing/checkout/:id` until the webhook — or the poll itself — has reconciled the payment. `409 plan_limit` is the org's own usage against the plan being bought.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/checkout-request"
              }
            }
          }
        }
      }
    },
    "/api/billing/checkout/{id}": {
      "get": {
        "operationId": "get_api_billing_checkout_id",
        "summary": "Reads a checkout's status, for the return page's poll.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The checkout id from `checkoutResponse.checkout_id`, carried on the return URL.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/checkout-status"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Calls the same `reconcilePayment(deps, molliePaymentId)` the webhook calls, so a return page that lands before the webhook does still sees the payment applied — this route, not the webhook, is what the dev stack and the test-mode suite rely on, since Mollie refuses an unreachable webhook URL. `plan` is the org's plan after applying, unchanged unless `purpose` is `upgrade` and `status` is `paid`."
      }
    },
    "/api/billing/vat-id/check": {
      "post": {
        "operationId": "post_api_billing_vat_id_check",
        "summary": "Checks a VAT ID against VIES, for the checkout form and the details page.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/vat-id-check-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `validation_error`, `rate_limited`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Rate limited on its own `billing.vat_check` bucket, sized for a form checked on blur rather than for a checkout. Never refuses for an `unverified` VIES answer — the checkout itself accepts `unverified` and the hourly sweep re-checks it — this route only reports what VIES currently says, in a different place for the same ID, entered either at checkout or on `PATCH /api/billing/details`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/vat-id-check-request"
              }
            }
          }
        }
      }
    },
    "/api/billing/details": {
      "patch": {
        "operationId": "patch_api_billing_details",
        "summary": "Edits the billing account's invoice email or VAT ID.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/billing-view"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `validation_error`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`404 not_found` when the org has no billing account yet — there is nothing here to edit before the first checkout. A new `vat_id` is re-checked through VIES the same way `POST /api/billing/vat-id/check` does; a valid ID entered here lifts the block a definitive `invalid` answer placed on the next renewal.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/billing-details-update"
              }
            }
          }
        }
      }
    },
    "/api/billing/change": {
      "post": {
        "operationId": "post_api_billing_change",
        "summary": "Moves the org's plan, cycle or add-ons, charging increases at once.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/billing-change-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `validation_error`, `plan_limit`, `target_state_conflict`, `billing_unavailable`, `payment_provider_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "The body names the **absolute** target — plan, cycle and add-ons — never a delta: the route compares it with the org's current state and splits the difference. The increasing part is charged now, through the same proration `changeNetCents` computes, and takes effect the moment Mollie accepts the `recurring` payment with anything but `failed`, `canceled` or `expired` — card mandates, Apple Pay's included, usually answer `paid` within seconds, a PayPal one may stay `pending` for a while; if it fails later, its open invoice enters dunning like a failed renewal. If Mollie refuses or does not answer, nothing is applied and this answers `502 payment_provider_unavailable` instead. The decreasing part — a lower plan, yearly → monthly, fewer add-ons — is stored as a pending change and applied at the period's end, same as `PUT /api/org/plan/change`; a lower plan over the target's limits answers `409 plan_limit` and the console opens its choose-what-stays page. Any increase first withdraws a pending downgrade or cancel, exactly as the admin route does. `409 target_state_conflict` names `billing` with rule `no_account` (no checkout yet — use `POST /api/billing/checkout`), `past_due` (an open invoice has to be paid first) or `no_valid_mandate` (the payment method needs renewing first, `POST /api/billing/payment-method`). `charged` in the response is the invoice from the part charged now, or `null` when the whole request was a decrease.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/billing-change-request"
              }
            }
          }
        }
      }
    },
    "/api/billing/payment-method": {
      "post": {
        "operationId": "post_api_billing_payment_method",
        "summary": "Starts a Mollie checkout for a new payment method.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/checkout-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `validation_error`, `not_found`, `rate_limited`, `billing_unavailable`, `payment_provider_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Rate limited on the `billing.checkout` bucket, same as `POST /api/billing/checkout`. Creates a hosted `first` payment on the existing Mollie customer, restricted to the chosen method (`card` → Mollie's `creditcard`, `paypal`, `applepay`). For `card` and `paypal` it is a payment of `0.00` in the org's currency that pays no open invoice: the invoice stays open, the next dunning retry charges the new mandate — except for an invoice that was charged back, which is never charged again automatically; pay it with `POST /api/billing/invoices/:id/pay` — and that route still pays any open invoice at once. `applepay` behaves the same where Mollie accepts a zero-amount Apple Pay payment; where it does not, a change to Apple Pay is only offered together with paying an open invoice — the payment is then that invoice's amount and pays it — and without one this answers `400 validation_error` with `{ field: 'method', rule: 'applepay_needs_open_invoice' }`. A client offers what `payment_method_options` in `GET /api/billing` lists. `404 not_found` when the org has no billing account yet. When the new mandate turns `valid` it becomes the account's — an Apple Pay one is a card mandate, shown as `applepay` — and every other mandate of the customer is revoked.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/payment-method-change-request"
              }
            }
          }
        }
      }
    },
    "/api/billing/cancel": {
      "post": {
        "operationId": "post_api_billing_cancel",
        "summary": "Schedules the org's plan to cancel to Basic at the period's end.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/billing-view"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `validation_error`, `not_found`, `plan_limit`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "A body is optional — `reason` alone, and nobody but Fleetless reads it. `404 not_found` when the org has no billing account to cancel. Takes effect at the period's end, nothing credited or refunded; over Basic's limits this is refused `409 plan_limit` and the console sends the owner to its choose-what-stays page instead, the same as a plan downgrade. \"Cancel at period end\" is not a flag here — it reads as `pending_change.target_plan === 'basic'` on `GET /api/org/plan`.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/billing-cancel-request"
              }
            }
          }
        }
      }
    },
    "/api/billing/resume": {
      "post": {
        "operationId": "post_api_billing_resume",
        "summary": "Withdraws a scheduled cancel, keeping the current plan.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/billing-view"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "`404 not_found` when nothing is pending — there is no scheduled cancel to withdraw. The org stays on its current plan, unchanged."
      }
    },
    "/api/billing/invoices/{id}/pdf": {
      "get": {
        "operationId": "get_api_billing_invoices_id_pdf",
        "summary": "Downloads one invoice's rendered PDF.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The invoice id, from `billingInvoice.id` in `GET /api/billing`'s `invoices`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Rendered once, with `pdfkit`, when the invoice is issued, and stored as bytes — an issued invoice never changes, so this always answers the same PDF for the same id. `404 not_found` for an unknown id or one from another org."
      }
    },
    "/api/billing/invoices/{id}/pay": {
      "post": {
        "operationId": "post_api_billing_invoices_id_pay",
        "summary": "Pays one open invoice, through a new Mollie payment.",
        "tags": [
          "billing"
        ],
        "security": [
          {
            "developerSession": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The invoice id, from `billingInvoice.id`, of the open invoice to pay — or a due charge's id, from `dunning.invoice_id`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/checkout-response"
                }
              }
            }
          },
          "default": {
            "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `not_found`, `target_state_conflict`, `rate_limited`, `billing_unavailable`, `payment_provider_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/api-error"
                }
              }
            }
          }
        },
        "description": "Rate limited on the `billing.checkout` bucket, same as `POST /api/billing/checkout`. `409 target_state_conflict` names `invoice` with rule `not_open` when the invoice is already `paid` or `uncollectible` — there is nothing left to pay. Paying the open invoice of a locked org unlocks it and keeps the plan running to the period's end, the same as a renewal that succeeds on a retry. `:id` may also be a due charge's id from `dunning.invoice_id` in `GET /api/billing`: while the payer's VAT ID is unsettled a held renewal is a due charge that has no number yet, and it is numbered and issued as an invoice once this payment is paid. The payment charges the amount owed (`dunning.gross_cents`). While that due charge is held by the VAT ID this answers `409 target_state_conflict` with `{ field: 'billing', rule: 'vat_id_invalid' }`. `404 not_found` for an unknown id or one from another org."
      }
    }
  },
  "components": {
    "securitySchemes": {
      "developerSession": {
        "type": "http",
        "scheme": "bearer",
        "description": "A developer session token from the console login."
      },
      "clientToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "An end-user token from the client login or the hosted login."
      },
      "serverKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "An app server key (`flk_…`)."
      }
    },
    "schemas": {
      "accept-team-invite-request": {
        "type": "object",
        "properties": {
          "token": {
            "type": "string",
            "minLength": 1,
            "description": "The opaque invitation token from the link. Unknown, expired and already-accepted all collapse into `410 token_spent` — telling them apart would say whether a token ever existed."
          },
          "display_name": {
            "description": "An optional name, overriding whatever the invitation pre-filled. Absent keeps it.",
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "token"
        ],
        "additionalProperties": false
      },
      "alert-list-response": {
        "type": "object",
        "properties": {
          "alerts": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                },
                "robot_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                },
                "slug": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 120
                },
                "enabled": {
                  "type": "boolean"
                },
                "severity": {
                  "type": "string",
                  "enum": [
                    "warning",
                    "error"
                  ]
                },
                "condition": {
                  "oneOf": [
                    {
                      "type": "object",
                      "properties": {
                        "kind": {
                          "type": "string",
                          "const": "above"
                        },
                        "threshold": {
                          "type": "number"
                        },
                        "resolve_hysteresis": {
                          "default": 0,
                          "type": "number",
                          "minimum": 0
                        }
                      },
                      "required": [
                        "kind",
                        "threshold",
                        "resolve_hysteresis"
                      ],
                      "additionalProperties": false
                    },
                    {
                      "type": "object",
                      "properties": {
                        "kind": {
                          "type": "string",
                          "const": "below"
                        },
                        "threshold": {
                          "type": "number"
                        },
                        "resolve_hysteresis": {
                          "default": 0,
                          "type": "number",
                          "minimum": 0
                        }
                      },
                      "required": [
                        "kind",
                        "threshold",
                        "resolve_hysteresis"
                      ],
                      "additionalProperties": false
                    },
                    {
                      "type": "object",
                      "properties": {
                        "kind": {
                          "type": "string",
                          "const": "equals"
                        },
                        "value": {
                          "anyOf": [
                            {
                              "type": "number"
                            },
                            {
                              "type": "string"
                            },
                            {
                              "type": "boolean"
                            }
                          ]
                        }
                      },
                      "required": [
                        "kind",
                        "value"
                      ],
                      "additionalProperties": false
                    }
                  ]
                },
                "state": {
                  "type": "string",
                  "enum": [
                    "ok",
                    "firing"
                  ]
                },
                "state_since": {
                  "anyOf": [
                    {
                      "type": "string",
                      "format": "date-time",
                      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "last_value": {
                  "anyOf": [
                    {},
                    {
                      "type": "null"
                    }
                  ]
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                }
              },
              "required": [
                "id",
                "robot_id",
                "slug",
                "name",
                "enabled",
                "severity",
                "condition",
                "state",
                "state_since",
                "last_value",
                "created_at"
              ],
              "additionalProperties": false
            }
          }
        },
        "required": [
          "alerts"
        ],
        "additionalProperties": false
      },
      "api-error": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "minLength": 1
          },
          "message": {
            "type": "string",
            "minLength": 1
          },
          "details": {}
        },
        "required": [
          "code",
          "message"
        ],
        "additionalProperties": false
      },
      "app": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The app in the API, assigned by the cloud and stable for the life of the app. Everything app-scoped takes this as its `:id`."
          },
          "org_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The organisation that owns this app. Every developer route is already scoped to the caller's org, so this confirms what a client is looking at rather than being a filter it applies."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "description": "The display name, shown in the console and available to the developer's own pages through the `app.name` mail-template variable. Free text, changed through `PATCH /api/apps/:id`."
          },
          "identifier": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
            "description": "The stable handle a client sends at login, lowercase and underscore-separated. **Globally unique, not per organisation** — `clientLoginRequest` carries no org context, so a collision is refused with `identifier_taken`."
          },
          "robot_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            },
            "description": "The robots this app may reach, each referenced individually. Tags never grant rights, and a robot absent from this list is invisible to the app whatever a role grants."
          },
          "default_role_id": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The role an app user gets when created or invited without an explicit one. `null` means this app has not chosen a default, the normal state of an app created before its roles were configured — and then a create or invite that omits `role_id` gets `validation_error`, not a user with no role. An invitation resolves the role when issued, so changing this never re-aims an outstanding one. The role must belong to this app, which `PATCH /api/apps/:id` checks and the schema cannot."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the app was created, as an ISO 8601 timestamp. `GET /api/apps` orders by this field."
          }
        },
        "required": [
          "id",
          "org_id",
          "name",
          "identifier",
          "robot_ids",
          "default_role_id",
          "created_at"
        ],
        "additionalProperties": false
      },
      "app-auth-config": {
        "type": "object",
        "properties": {
          "self_registration": {
            "type": "boolean",
            "description": "Whether a stranger may create an account in this app. Off refuses `POST /api/client/register` with `403 registration_closed`, and refuses an unknown identity at an OIDC callback with the same reasoning — one switch for one decision, whichever door the person arrives at."
          },
          "allowed_domains": {
            "maxItems": 50,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 253,
              "pattern": "^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$"
            },
            "description": "The email domains self-registration accepts, lowercase. An empty list means no domain restriction, not \"nobody\" — the switch above is what closes the door. **An invitation always bypasses this**, by password and through a provider alike."
          },
          "allowed_origins": {
            "maxItems": 20,
            "type": "array",
            "items": {
              "type": "string",
              "maxLength": 200
            },
            "description": "The origins the client auth API answers CORS for, and the only origins an OIDC `redirect_uri` may name. Bare origins: scheme, host and port, with no path — a browser sends nothing longer, so an entry carrying one could never match."
          },
          "mcp_enabled": {
            "type": "boolean",
            "description": "Whether this app serves an MCP endpoint at `/mcp/<identifier>`. Off refuses the whole OAuth surface for the app, not merely the tool calls, and is re-read on every request rather than cached off a token."
          },
          "invite_url": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 500
              },
              {
                "type": "null"
              }
            ],
            "description": "The page in the developer's app that accepts an invitation, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
          },
          "verify_url": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 500
              },
              {
                "type": "null"
              }
            ],
            "description": "The page that confirms a new address, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
          },
          "reset_url": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 500
              },
              {
                "type": "null"
              }
            ],
            "description": "The page that takes a new password, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
          },
          "mcp_login_url": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 500
              },
              {
                "type": "null"
              }
            ],
            "description": "The page an MCP authorization redirects to, with `{interaction}` where the interaction id goes. Not a token: the id names a pending request the server already holds, and the app authenticates the user itself before approving it. `null` means the hosted MCP sign-in in `hosted_pages` is used."
          },
          "app_url": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 500,
                "format": "uri"
              },
              {
                "type": "null"
              }
            ],
            "description": "The app's own home page, linked as `Open <app>` when a hosted flow is done. `null` makes the hosted done page say `You can close this tab`."
          },
          "sign_in_methods": {
            "type": "object",
            "properties": {
              "password": {
                "type": "boolean",
                "description": "Whether app users may sign in with a password. Off refuses `POST /api/client/login` with `method_not_allowed`, and registration and invitations then take no password."
              },
              "email_code": {
                "type": "boolean",
                "description": "Whether app users may sign in with a six-digit code mailed to them, valid ten minutes. A code needs no URL, so it works in local development and in an app with no web UI."
              }
            },
            "required": [
              "password",
              "email_code"
            ],
            "additionalProperties": false,
            "description": "Which sign-in methods the app offers: password, emailed code, or both — at least one. Identity providers stay on top of either. The default is password only."
          },
          "two_factor": {
            "type": "string",
            "enum": [
              "off",
              "optional",
              "required"
            ],
            "description": "Whether the app asks for an authenticator code: `off` (the default), `optional` or `required`. A person with a confirmed authenticator is asked at every sign-in whatever the policy; a sign-in through an identity provider is never asked."
          },
          "hosted_logo_url": {
            "anyOf": [
              {
                "type": "string",
                "format": "uri"
              },
              {
                "type": "null"
              }
            ],
            "description": "Where the hosted pages load the app's logo from, `<portal>/app/<identifier>/logo`, or `null` when no logo is stored. **Read-only** — the logo is written through `PUT /api/apps/:id/auth-config/logo`."
          },
          "hosted_accent": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^#[0-9a-f]{6}$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The accent colour of the hosted pages, `#rrggbb` in lowercase, or `null` for the neutral shell's own."
          },
          "hosted_pages": {
            "type": "object",
            "properties": {
              "invite_url": {
                "type": "string",
                "format": "uri",
                "description": "The hosted invitation page, `<portal>/app/<identifier>/invite/{token}`."
              },
              "verify_url": {
                "type": "string",
                "format": "uri",
                "description": "The hosted email-confirmation page, `<portal>/app/<identifier>/verify/{token}`."
              },
              "reset_url": {
                "type": "string",
                "format": "uri",
                "description": "The hosted new-password page, `<portal>/app/<identifier>/reset/{token}`."
              },
              "mcp_login_url": {
                "type": "string",
                "format": "uri",
                "description": "The hosted MCP sign-in, `<portal>/app/<identifier>/mcp/{interaction}`."
              }
            },
            "required": [
              "invite_url",
              "verify_url",
              "reset_url",
              "mcp_login_url"
            ],
            "additionalProperties": false,
            "description": "The Fleetless-hosted pages an unset URL falls back to, as templates. **Read-only**: minted by the cloud from the auth portal and the app's identifier."
          },
          "oidc_callback_url": {
            "type": "string",
            "format": "uri",
            "description": "The one callback URL to register at every identity provider, the same for every app and every provider. **Read-only** — it is minted by the cloud from its own public base URL, and a writable version of this field would let a caller point the return leg, which carries an authorization code, at a host they own."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the configuration was last written, as an ISO 8601 timestamp."
          }
        },
        "required": [
          "self_registration",
          "allowed_domains",
          "allowed_origins",
          "mcp_enabled",
          "invite_url",
          "verify_url",
          "reset_url",
          "mcp_login_url",
          "app_url",
          "sign_in_methods",
          "two_factor",
          "hosted_logo_url",
          "hosted_accent",
          "hosted_pages",
          "oidc_callback_url",
          "updated_at"
        ],
        "additionalProperties": false
      },
      "app-deletion-summary": {
        "type": "object",
        "properties": {
          "user_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "App users deleted with the app. They are the developer's own customers, not Fleetless users, and exist in no other app."
          },
          "role_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "Roles deleted with the app, each with its per-robot slug grants."
          },
          "server_key_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "Server keys deleted with the app. A client still holding one is refused at its next request."
          },
          "invitation_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "Outstanding invitations — unspent and unexpired — that will never be accepted."
          },
          "oidc_provider_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "Identity providers configured for this app. The providers themselves are somebody else's; only this app's configuration of them goes."
          },
          "mail_template_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "Custom mail templates, of at most three. A kind using the Fleetless default text is not counted — there is no row to lose."
          }
        },
        "required": [
          "user_count",
          "role_count",
          "server_key_count",
          "invitation_count",
          "oidc_provider_count",
          "mail_template_count"
        ],
        "additionalProperties": false
      },
      "app-invitation": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The invitation, as listed and revoked by the developer."
          },
          "app_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The app the invitee will belong to."
          },
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address the invitation was addressed to."
          },
          "role_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The role the invitee holds once they accept. Resolved at creation, so a later change to the app's default role does not silently re-aim an outstanding invitation."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the token stops working. Seven days from issue; an expired token answers exactly as an unknown one does."
          },
          "accept_url": {
            "type": "string",
            "maxLength": 500,
            "format": "uri",
            "description": "The link to give the invitee: the app's `invite_url` with the token substituted for `{token}`, or the Fleetless-hosted invitation page when the app has configured none. Bounded like every other URL that gets mailed, logged and rendered."
          },
          "mail": {
            "type": "string",
            "enum": [
              "sent",
              "not_requested",
              "not_configured",
              "failed"
            ],
            "description": "What happened to the mail: `sent` means the SMTP server accepted it, not that it was delivered; `not_requested` means none was attempted because the caller asked for none; `not_configured` is an expected state and not a failure; `failed` is the one worth somebody's attention."
          }
        },
        "required": [
          "id",
          "app_id",
          "email",
          "role_id",
          "expires_at",
          "accept_url",
          "mail"
        ],
        "additionalProperties": false
      },
      "app-invitation-list-response": {
        "type": "object",
        "properties": {
          "invitations": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The invitation, as listed and revoked by the developer."
                },
                "app_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The app the invitee will belong to."
                },
                "email": {
                  "type": "string",
                  "format": "email",
                  "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                  "description": "The address the invitation was addressed to."
                },
                "role_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The role the invitee holds once they accept. Resolved at creation, so a later change to the app's default role does not silently re-aim an outstanding invitation."
                },
                "expires_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the token stops working. Seven days from issue; an expired token answers exactly as an unknown one does."
                }
              },
              "required": [
                "id",
                "app_id",
                "email",
                "role_id",
                "expires_at"
              ],
              "additionalProperties": false
            },
            "description": "The app's outstanding invitations, without their tokens. An accepted one is history and does not appear."
          }
        },
        "required": [
          "invitations"
        ],
        "additionalProperties": false
      },
      "app-list-response": {
        "type": "object",
        "properties": {
          "apps": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The app in the API, assigned by the cloud and stable for the life of the app. Everything app-scoped takes this as its `:id`."
                },
                "org_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The organisation that owns this app. Every developer route is already scoped to the caller's org, so this confirms what a client is looking at rather than being a filter it applies."
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 120,
                  "description": "The display name, shown in the console and available to the developer's own pages through the `app.name` mail-template variable. Free text, changed through `PATCH /api/apps/:id`."
                },
                "identifier": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
                  "description": "The stable handle a client sends at login, lowercase and underscore-separated. **Globally unique, not per organisation** — `clientLoginRequest` carries no org context, so a collision is refused with `identifier_taken`."
                },
                "robot_ids": {
                  "type": "array",
                  "items": {
                    "type": "string",
                    "format": "uuid",
                    "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                  },
                  "description": "The robots this app may reach, each referenced individually. Tags never grant rights, and a robot absent from this list is invisible to the app whatever a role grants."
                },
                "default_role_id": {
                  "anyOf": [
                    {
                      "type": "string",
                      "format": "uuid",
                      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "The role an app user gets when created or invited without an explicit one. `null` means this app has not chosen a default, the normal state of an app created before its roles were configured — and then a create or invite that omits `role_id` gets `validation_error`, not a user with no role. An invitation resolves the role when issued, so changing this never re-aims an outstanding one. The role must belong to this app, which `PATCH /api/apps/:id` checks and the schema cannot."
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the app was created, as an ISO 8601 timestamp. `GET /api/apps` orders by this field."
                }
              },
              "required": [
                "id",
                "org_id",
                "name",
                "identifier",
                "robot_ids",
                "default_role_id",
                "created_at"
              ],
              "additionalProperties": false
            },
            "description": "Every app of the caller's organisation, oldest first by `created_at`. The org scope is the whole filter — there is no id to narrow by and nothing to refuse."
          }
        },
        "required": [
          "apps"
        ],
        "additionalProperties": false
      },
      "app-mail-template": {
        "type": "object",
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "invite",
              "verify",
              "reset",
              "login_code"
            ],
            "description": "Which of the four mails this template replaces."
          },
          "subject": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "The subject line, a Liquid template. Bounded because a subject is rendered into a header."
          },
          "text": {
            "type": "string",
            "minLength": 1,
            "maxLength": 20000,
            "description": "The plain-text body, a Liquid template. Required even when an HTML part is given: a mail with no text part is unreadable to a client that refuses HTML."
          },
          "html": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 100000
              },
              {
                "type": "null"
              }
            ],
            "description": "The optional HTML body, a Liquid template. `null` means this template is text-only, which is a complete mail and not a half-configured one."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the template was last written, as an ISO 8601 timestamp."
          }
        },
        "required": [
          "kind",
          "subject",
          "text",
          "html",
          "updated_at"
        ],
        "additionalProperties": false
      },
      "app-mail-template-list-response": {
        "type": "object",
        "properties": {
          "templates": {
            "maxItems": 4,
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "kind": {
                  "type": "string",
                  "enum": [
                    "invite",
                    "verify",
                    "reset",
                    "login_code"
                  ],
                  "description": "Which of the four mails this template replaces."
                },
                "subject": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 200,
                  "description": "The subject line, a Liquid template. Bounded because a subject is rendered into a header."
                },
                "text": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 20000,
                  "description": "The plain-text body, a Liquid template. Required even when an HTML part is given: a mail with no text part is unreadable to a client that refuses HTML."
                },
                "html": {
                  "anyOf": [
                    {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 100000
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "The optional HTML body, a Liquid template. `null` means this template is text-only, which is a complete mail and not a half-configured one."
                },
                "updated_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the template was last written, as an ISO 8601 timestamp."
                }
              },
              "required": [
                "kind",
                "subject",
                "text",
                "html",
                "updated_at"
              ],
              "additionalProperties": false
            },
            "description": "The app's custom templates. A kind that does not appear is one using the Fleetless default text — an ordinary state, not a missing row."
          }
        },
        "required": [
          "templates"
        ],
        "additionalProperties": false
      },
      "app-oidc-provider": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The provider row, as listed, patched and deleted by the developer."
          },
          "app_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The app this provider signs users in to."
          },
          "slug": {
            "type": "string",
            "maxLength": 40,
            "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$",
            "description": "The stable handle in the sign-in URL (`/api/client/oidc/:slug/start`) and on the developer's own button. Unique per app, lowercase and hyphen-separated — **not** the underscore-separated grammar `identifier` uses. Immutable: linked identities are keyed by it."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "What the developer's sign-in page calls this provider, e.g. \"Sign in with Azure AD\". Free text; the only field of this shape the public `GET /api/client/providers` reveals besides the slug."
          },
          "issuer": {
            "type": "string",
            "maxLength": 500,
            "format": "uri",
            "description": "The provider's issuer URL, from which discovery and the JWKS are read. http(s) only, with no credentials, query or fragment — RFC 8414 §3 builds the discovery URL from the issuer's path, so a query there is meaningless and an `@` is a redirect trick. **This is not the SSRF defence**: it cannot tell a loopback dev provider from a loopback database, and the real check refuses loopback, link-local and private ranges at the fetch."
          },
          "client_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "The OAuth client the developer registered at their provider for Fleetless."
          },
          "scopes": {
            "minItems": 1,
            "maxItems": 20,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 60
            },
            "description": "The scopes requested at the provider. At least one — a request that asks for nothing learns nothing — and bounded, because an unbounded array on a stored, logged and rendered shape is a size nobody chose."
          },
          "link_verified_emails": {
            "type": "boolean",
            "description": "Whether a federated login may join an **existing** app user with the same address. It needs the provider to assert `email_verified` as well: either condition alone is account takeover, since a provider that lets anyone type any address into a profile would otherwise hand over every matching account, and a developer who connects a provider for a subset of their users would otherwise silently merge strangers."
          },
          "enabled": {
            "type": "boolean",
            "description": "Whether this provider is offered. A disabled provider disappears from `GET /api/client/providers` and refuses a start with `provider_disabled`, without the row and its linked identities being deleted."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the provider was configured, as an ISO 8601 timestamp."
          }
        },
        "required": [
          "id",
          "app_id",
          "slug",
          "name",
          "issuer",
          "client_id",
          "scopes",
          "link_verified_emails",
          "enabled",
          "created_at"
        ],
        "additionalProperties": false
      },
      "app-oidc-provider-list-response": {
        "type": "object",
        "properties": {
          "providers": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The provider row, as listed, patched and deleted by the developer."
                },
                "app_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The app this provider signs users in to."
                },
                "slug": {
                  "type": "string",
                  "maxLength": 40,
                  "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$",
                  "description": "The stable handle in the sign-in URL (`/api/client/oidc/:slug/start`) and on the developer's own button. Unique per app, lowercase and hyphen-separated — **not** the underscore-separated grammar `identifier` uses. Immutable: linked identities are keyed by it."
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 80,
                  "description": "What the developer's sign-in page calls this provider, e.g. \"Sign in with Azure AD\". Free text; the only field of this shape the public `GET /api/client/providers` reveals besides the slug."
                },
                "issuer": {
                  "type": "string",
                  "maxLength": 500,
                  "format": "uri",
                  "description": "The provider's issuer URL, from which discovery and the JWKS are read. http(s) only, with no credentials, query or fragment — RFC 8414 §3 builds the discovery URL from the issuer's path, so a query there is meaningless and an `@` is a redirect trick. **This is not the SSRF defence**: it cannot tell a loopback dev provider from a loopback database, and the real check refuses loopback, link-local and private ranges at the fetch."
                },
                "client_id": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 200,
                  "description": "The OAuth client the developer registered at their provider for Fleetless."
                },
                "scopes": {
                  "minItems": 1,
                  "maxItems": 20,
                  "type": "array",
                  "items": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 60
                  },
                  "description": "The scopes requested at the provider. At least one — a request that asks for nothing learns nothing — and bounded, because an unbounded array on a stored, logged and rendered shape is a size nobody chose."
                },
                "link_verified_emails": {
                  "type": "boolean",
                  "description": "Whether a federated login may join an **existing** app user with the same address. It needs the provider to assert `email_verified` as well: either condition alone is account takeover, since a provider that lets anyone type any address into a profile would otherwise hand over every matching account, and a developer who connects a provider for a subset of their users would otherwise silently merge strangers."
                },
                "enabled": {
                  "type": "boolean",
                  "description": "Whether this provider is offered. A disabled provider disappears from `GET /api/client/providers` and refuses a start with `provider_disabled`, without the row and its linked identities being deleted."
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the provider was configured, as an ISO 8601 timestamp."
                }
              },
              "required": [
                "id",
                "app_id",
                "slug",
                "name",
                "issuer",
                "client_id",
                "scopes",
                "link_verified_emails",
                "enabled",
                "created_at"
              ],
              "additionalProperties": false
            },
            "description": "Every provider configured on this app, disabled ones included — this is the developer's management view, unlike the public `GET /api/client/providers`, which lists only what a user can actually press."
          }
        },
        "required": [
          "providers"
        ],
        "additionalProperties": false
      },
      "app-user": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The app user in the API, assigned by the cloud and stable for the life of the account."
          },
          "app_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The app this user belongs to, and the whole of their scope. An app user of one app is nobody at another, even inside the same organisation."
          },
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address the account is identified by. Unique **per app**, case-insensitively — the same address may exist as an unrelated account in another app of the same organisation."
          },
          "display_name": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ],
            "description": "Optional human name, shown by the developer's own UI instead of the address where present. `null` when the user never supplied one; never used for authentication."
          },
          "role_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The role that decides what this user may reach. Roles are the only visibility filter: what a role does not grant does not exist for that user."
          },
          "status": {
            "type": "string",
            "enum": [
              "pending_verification",
              "active",
              "blocked"
            ],
            "description": "Where the account is in its lifecycle. Only `active` may log in; `pending_verification` and `blocked` are both refused with the same `invalid_credentials` a wrong password gets, so a failed login is not an account-enumeration oracle."
          },
          "has_password": {
            "type": "boolean",
            "description": "Whether this account has a Fleetless-held password. `false` is an identity-provider-only account, or an invitation not yet accepted — it does not mean blocked and it does not mean without access. No hash, no algorithm and no \"last changed\" travels here, and nothing on the wire can say whether a password is strong or already known to somebody else."
          },
          "providers": {
            "maxItems": 20,
            "type": "array",
            "items": {
              "type": "string",
              "maxLength": 40,
              "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
            },
            "description": "The slugs of the identity providers this account is linked to, empty for a password-only user. Lets a developer's user list say where an account came from without a second request."
          },
          "last_login_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time",
                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
              },
              {
                "type": "null"
              }
            ],
            "description": "When this user last signed in, or `null` if they never have. Required and nullable rather than optional, so *never logged in* stays distinguishable from *this field was not loaded*."
          },
          "two_factor": {
            "type": "object",
            "properties": {
              "enabled": {
                "type": "boolean",
                "description": "Whether the account has a confirmed authenticator app (TOTP). When it has, every sign-in that yields a session asks for a code as well — whatever the app's policy — except a sign-in through an identity provider, which owns that sign-in."
              },
              "enabled_at": {
                "anyOf": [
                  {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "When the authenticator was confirmed, or `null` while `enabled` is `false`."
              },
              "recovery_codes_left": {
                "type": "integer",
                "minimum": 0,
                "maximum": 10,
                "description": "How many of the ten single-use recovery codes are still unspent. `0` while `enabled` is `false`."
              }
            },
            "required": [
              "enabled",
              "enabled_at",
              "recovery_codes_left"
            ],
            "additionalProperties": false,
            "description": "The account's second factor, as a developer's user list shows it. No secret and no code travels here; resetting it is `DELETE /api/apps/:id/users/:userId/two-factor`."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the account was created, as an ISO 8601 timestamp."
          }
        },
        "required": [
          "id",
          "app_id",
          "email",
          "display_name",
          "role_id",
          "status",
          "has_password",
          "providers",
          "last_login_at",
          "two_factor",
          "created_at"
        ],
        "additionalProperties": false
      },
      "app-user-list-response": {
        "type": "object",
        "properties": {
          "users": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The app user in the API, assigned by the cloud and stable for the life of the account."
                },
                "app_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The app this user belongs to, and the whole of their scope. An app user of one app is nobody at another, even inside the same organisation."
                },
                "email": {
                  "type": "string",
                  "format": "email",
                  "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                  "description": "The address the account is identified by. Unique **per app**, case-insensitively — the same address may exist as an unrelated account in another app of the same organisation."
                },
                "display_name": {
                  "anyOf": [
                    {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 120
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "Optional human name, shown by the developer's own UI instead of the address where present. `null` when the user never supplied one; never used for authentication."
                },
                "role_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The role that decides what this user may reach. Roles are the only visibility filter: what a role does not grant does not exist for that user."
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "pending_verification",
                    "active",
                    "blocked"
                  ],
                  "description": "Where the account is in its lifecycle. Only `active` may log in; `pending_verification` and `blocked` are both refused with the same `invalid_credentials` a wrong password gets, so a failed login is not an account-enumeration oracle."
                },
                "has_password": {
                  "type": "boolean",
                  "description": "Whether this account has a Fleetless-held password. `false` is an identity-provider-only account, or an invitation not yet accepted — it does not mean blocked and it does not mean without access. No hash, no algorithm and no \"last changed\" travels here, and nothing on the wire can say whether a password is strong or already known to somebody else."
                },
                "providers": {
                  "maxItems": 20,
                  "type": "array",
                  "items": {
                    "type": "string",
                    "maxLength": 40,
                    "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
                  },
                  "description": "The slugs of the identity providers this account is linked to, empty for a password-only user. Lets a developer's user list say where an account came from without a second request."
                },
                "last_login_at": {
                  "anyOf": [
                    {
                      "type": "string",
                      "format": "date-time",
                      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "When this user last signed in, or `null` if they never have. Required and nullable rather than optional, so *never logged in* stays distinguishable from *this field was not loaded*."
                },
                "two_factor": {
                  "type": "object",
                  "properties": {
                    "enabled": {
                      "type": "boolean",
                      "description": "Whether the account has a confirmed authenticator app (TOTP). When it has, every sign-in that yields a session asks for a code as well — whatever the app's policy — except a sign-in through an identity provider, which owns that sign-in."
                    },
                    "enabled_at": {
                      "anyOf": [
                        {
                          "type": "string",
                          "format": "date-time",
                          "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                        },
                        {
                          "type": "null"
                        }
                      ],
                      "description": "When the authenticator was confirmed, or `null` while `enabled` is `false`."
                    },
                    "recovery_codes_left": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 10,
                      "description": "How many of the ten single-use recovery codes are still unspent. `0` while `enabled` is `false`."
                    }
                  },
                  "required": [
                    "enabled",
                    "enabled_at",
                    "recovery_codes_left"
                  ],
                  "additionalProperties": false,
                  "description": "The account's second factor, as a developer's user list shows it. No secret and no code travels here; resetting it is `DELETE /api/apps/:id/users/:userId/two-factor`."
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the account was created, as an ISO 8601 timestamp."
                }
              },
              "required": [
                "id",
                "app_id",
                "email",
                "display_name",
                "role_id",
                "status",
                "has_password",
                "providers",
                "last_login_at",
                "two_factor",
                "created_at"
              ],
              "additionalProperties": false
            },
            "description": "Every user of this app. An app with no users answers an empty array, not an absent key."
          }
        },
        "required": [
          "users"
        ],
        "additionalProperties": false
      },
      "asset-list-response": {
        "type": "object",
        "properties": {
          "assets": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The asset's id in the store."
                },
                "robot_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The robot this asset belongs to."
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "urdf",
                    "mesh",
                    "texture"
                  ],
                  "description": "What the file is: the `urdf` itself, a `mesh` it references, or a `texture` a mesh or the URDF paints with. A renderer decides from this alone, before fetching anything, what to pre-fetch."
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 500,
                  "description": "What the robot called it — for a mesh, the `package://` URI the URDF references, verbatim, the only string a developer can match against their own workspace. A file the URDF never names (an image a `.dae` loads for itself) is named by joining the mesh's own directory with that internal reference."
                },
                "media_type": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 120,
                  "description": "The media type of the stored bytes, as the producer reported it."
                },
                "size_bytes": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 9007199254740991,
                  "description": "How large the stored file is, in bytes."
                },
                "sha256": {
                  "type": "string",
                  "pattern": "^[a-f0-9]{64}$",
                  "description": "The content hash, lowercase hex. Exposed because it is the only way a client can tell \"this is the same mesh I already have\" across robots — the reason two robots sharing a mesh cost one copy."
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the asset was first stored, as an ISO 8601 timestamp."
                }
              },
              "required": [
                "id",
                "robot_id",
                "kind",
                "name",
                "media_type",
                "size_bytes",
                "sha256",
                "created_at"
              ],
              "additionalProperties": false
            },
            "description": "Every asset stored for this robot: the URDF, the meshes it references, and the textures those paint with."
          },
          "active_sync": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "sync_id": {
                    "type": "string",
                    "format": "uuid",
                    "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                    "description": "The sync this status describes."
                  },
                  "robot_id": {
                    "type": "string",
                    "format": "uuid",
                    "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                    "description": "The robot whose assets are being synced."
                  },
                  "state": {
                    "type": "string",
                    "enum": [
                      "running",
                      "succeeded",
                      "failed"
                    ],
                    "description": "Whether the sync is still `running`, or ended `succeeded` or `failed`. It ends `succeeded` only when nothing was left behind: a single entry in `failed` makes the whole sync `failed`."
                  },
                  "done": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991,
                    "description": "How many files have been transferred so far."
                  },
                  "total": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991,
                    "description": "How many files this sync set out to transfer. It is `0` until the producer has finished working out what there is."
                  },
                  "failed": {
                    "maxItems": 1000,
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "reference": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 500,
                          "description": "What could not be provided, verbatim — the same string the asset would have been stored under, so a developer can match it against their own workspace by eye. For a failed URDF upload it is `robot_description`, which is **not** a mesh URI: a consumer must not assume every entry is one."
                        },
                        "kind": {
                          "type": "string",
                          "enum": [
                            "unresolvable",
                            "upload_failed",
                            "refused"
                          ],
                          "description": "Why it failed. `unresolvable` means the reference names nothing the producer can find or may read, and is **permanent** — the only kind reconciliation may treat as gone. `upload_failed` means the bytes exist and the transfer did not succeed, and `refused` means it was never attempted, either because the robot's asset store had no room — then `details` carries the three numbers — or because a producer-side ceiling was hit."
                        },
                        "details": {
                          "description": "The three numbers behind a `refused` entry the robot's store had no room for, and absent for every other kind — a forced `null` on every `unresolvable` entry buys nothing. A `refused` entry may also carry no details: the producer's own ceiling is the other half of that kind, and no store number describes it.",
                          "anyOf": [
                            {
                              "type": "object",
                              "properties": {
                                "store_bytes": {
                                  "type": "integer",
                                  "exclusiveMinimum": 0,
                                  "maximum": 9007199254740991,
                                  "description": "The robot's store, in bytes."
                                },
                                "used_bytes": {
                                  "type": "integer",
                                  "minimum": 0,
                                  "maximum": 9007199254740991,
                                  "description": "Bytes the robot's assets occupy before this upload."
                                },
                                "size_bytes": {
                                  "type": "integer",
                                  "exclusiveMinimum": 0,
                                  "maximum": 9007199254740991,
                                  "description": "The refused upload, in bytes."
                                }
                              },
                              "required": [
                                "store_bytes",
                                "used_bytes",
                                "size_bytes"
                              ],
                              "additionalProperties": false
                            },
                            {
                              "type": "null"
                            }
                          ]
                        }
                      },
                      "required": [
                        "reference",
                        "kind"
                      ],
                      "additionalProperties": false
                    },
                    "description": "What could not be provided, one entry per reference, each saying why. Required rather than optional: a sync that quietly drops three meshes and reports success moves the failure into somebody's renderer, where it shows up as a robot with missing limbs and no cause. At most `1000` entries — a producer at its own ceiling reports one entry saying so rather than growing the list."
                  },
                  "reason": {
                    "anyOf": [
                      {
                        "type": "string",
                        "minLength": 1
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "Why the sync ended as it did, when that is not a per-reference fact. `null` when `failed` already says everything there is to say."
                  },
                  "stored": {
                    "anyOf": [
                      {
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 9007199254740991
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "How many of the announced files the cloud's store actually holds. Counted once, after the robot reports the sync done, and `null` until then — nobody has looked yet. Read it against `announced`: `state` is what the robot reported, this is what arrived."
                  },
                  "announced": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991,
                    "description": "How many files the robot announced for this sync — the URDF, if it has one, plus every mesh URI its description references. `0` when the robot announced nothing, and also `0` until it has answered at all: read it beside `stored`, which stays `null` until the terminal frame."
                  },
                  "started_at": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                    "description": "When the sync started, as an ISO 8601 timestamp."
                  },
                  "updated_at": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                    "description": "When this status last changed, as an ISO 8601 timestamp. A sync that stops moving is visible here rather than only in `state`."
                  }
                },
                "required": [
                  "sync_id",
                  "robot_id",
                  "state",
                  "done",
                  "total",
                  "failed",
                  "reason",
                  "stored",
                  "announced",
                  "started_at",
                  "updated_at"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "description": "The sync running right now, or `null`. It is on this list so a page that reloads and has lost the sync id can still show progress — a freshly loaded page presses no button, it asks this list."
          },
          "urdf": {
            "type": "object",
            "properties": {
              "present": {
                "type": "boolean",
                "description": "Whether a URDF has been synced at all. Whether one *could* be synced is a different question, answered by `urdf_available`."
              },
              "mesh_count": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "How many distinct meshes the URDF references."
              },
              "missing": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "uri": {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 500,
                      "description": "The reference, verbatim, that no stored asset answers — a `package://` URI the workspace does not hold, or an absolute or bare relative path nothing will ever fetch."
                    },
                    "element": {
                      "type": "string",
                      "enum": [
                        "mesh",
                        "texture"
                      ],
                      "description": "Which kind of reference it was: geometry the URDF names as a `mesh`, or a `texture` a surface paints with. Without it a client reports a missing texture as a missing mesh, contradicting `mesh_count` beside it."
                    }
                  },
                  "required": [
                    "uri",
                    "element"
                  ],
                  "additionalProperties": false
                },
                "description": "The references nothing in the store answers, each with the element that asked for it. A bare count would send a developer hunting through the workspace by hand; the references are what they can act on."
              }
            },
            "required": [
              "present",
              "mesh_count",
              "missing"
            ],
            "additionalProperties": false,
            "description": "Whether the stored URDF can actually be rendered, and what it is still missing. Not the same question as whether one was uploaded."
          },
          "urdf_available": {
            "anyOf": [
              {
                "type": "boolean"
              },
              {
                "type": "null"
              }
            ],
            "description": "What the connected bridge says it *could* transfer — deliberately separate from what has been transferred. `null` when no bridge is connected, distinct from `false`: \"no robot is online to ask\" and \"the robot has no URDF\" send a developer to different places. After a publisher is killed rather than shut down this can read `true` for some seconds, on the underlying DDS liveliness timeout rather than on any check made here."
          },
          "store": {
            "type": "object",
            "properties": {
              "bytes": {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991,
                "description": "The robot's asset store, `ROBOT_ASSET_STORE_BYTES`."
              },
              "used_bytes": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Bytes its assets occupy."
              }
            },
            "required": [
              "bytes",
              "used_bytes"
            ],
            "additionalProperties": false,
            "description": "How full this robot's store is."
          },
          "joint_state_slug": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 2,
                "maxLength": 63,
                "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The whole-message `sensor_msgs/msg/JointState` datapoint that drives the console's URDF viewer; null when none is chosen or a publish removed it. Set through `PUT /api/robots/:id/urdf/joint-state`."
          }
        },
        "required": [
          "assets",
          "active_sync",
          "urdf",
          "urdf_available",
          "store",
          "joint_state_slug"
        ],
        "additionalProperties": false
      },
      "asset-sync-request": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string",
            "enum": [
              "bridge"
            ],
            "description": "Where the bytes come from. `bridge` is the only value today: the connected bridge reads them from the robot's own workspace. Validated rather than ignored, so a caller naming an unknown source is told so instead of silently getting a bridge sync."
          }
        },
        "required": [
          "source"
        ],
        "additionalProperties": false
      },
      "asset-sync-response": {
        "type": "object",
        "properties": {
          "sync_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The sync that has just started. A sync is long-running, so the answer is something to watch rather than a status that was true at the moment of asking."
          }
        },
        "required": [
          "sync_id"
        ],
        "additionalProperties": false
      },
      "asset-sync-status": {
        "type": "object",
        "properties": {
          "sync_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The sync this status describes."
          },
          "robot_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The robot whose assets are being synced."
          },
          "state": {
            "type": "string",
            "enum": [
              "running",
              "succeeded",
              "failed"
            ],
            "description": "Whether the sync is still `running`, or ended `succeeded` or `failed`. It ends `succeeded` only when nothing was left behind: a single entry in `failed` makes the whole sync `failed`."
          },
          "done": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "How many files have been transferred so far."
          },
          "total": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "How many files this sync set out to transfer. It is `0` until the producer has finished working out what there is."
          },
          "failed": {
            "maxItems": 1000,
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "reference": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 500,
                  "description": "What could not be provided, verbatim — the same string the asset would have been stored under, so a developer can match it against their own workspace by eye. For a failed URDF upload it is `robot_description`, which is **not** a mesh URI: a consumer must not assume every entry is one."
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "unresolvable",
                    "upload_failed",
                    "refused"
                  ],
                  "description": "Why it failed. `unresolvable` means the reference names nothing the producer can find or may read, and is **permanent** — the only kind reconciliation may treat as gone. `upload_failed` means the bytes exist and the transfer did not succeed, and `refused` means it was never attempted, either because the robot's asset store had no room — then `details` carries the three numbers — or because a producer-side ceiling was hit."
                },
                "details": {
                  "description": "The three numbers behind a `refused` entry the robot's store had no room for, and absent for every other kind — a forced `null` on every `unresolvable` entry buys nothing. A `refused` entry may also carry no details: the producer's own ceiling is the other half of that kind, and no store number describes it.",
                  "anyOf": [
                    {
                      "type": "object",
                      "properties": {
                        "store_bytes": {
                          "type": "integer",
                          "exclusiveMinimum": 0,
                          "maximum": 9007199254740991,
                          "description": "The robot's store, in bytes."
                        },
                        "used_bytes": {
                          "type": "integer",
                          "minimum": 0,
                          "maximum": 9007199254740991,
                          "description": "Bytes the robot's assets occupy before this upload."
                        },
                        "size_bytes": {
                          "type": "integer",
                          "exclusiveMinimum": 0,
                          "maximum": 9007199254740991,
                          "description": "The refused upload, in bytes."
                        }
                      },
                      "required": [
                        "store_bytes",
                        "used_bytes",
                        "size_bytes"
                      ],
                      "additionalProperties": false
                    },
                    {
                      "type": "null"
                    }
                  ]
                }
              },
              "required": [
                "reference",
                "kind"
              ],
              "additionalProperties": false
            },
            "description": "What could not be provided, one entry per reference, each saying why. Required rather than optional: a sync that quietly drops three meshes and reports success moves the failure into somebody's renderer, where it shows up as a robot with missing limbs and no cause. At most `1000` entries — a producer at its own ceiling reports one entry saying so rather than growing the list."
          },
          "reason": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1
              },
              {
                "type": "null"
              }
            ],
            "description": "Why the sync ended as it did, when that is not a per-reference fact. `null` when `failed` already says everything there is to say."
          },
          "stored": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ],
            "description": "How many of the announced files the cloud's store actually holds. Counted once, after the robot reports the sync done, and `null` until then — nobody has looked yet. Read it against `announced`: `state` is what the robot reported, this is what arrived."
          },
          "announced": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "How many files the robot announced for this sync — the URDF, if it has one, plus every mesh URI its description references. `0` when the robot announced nothing, and also `0` until it has answered at all: read it beside `stored`, which stays `null` until the terminal frame."
          },
          "started_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the sync started, as an ISO 8601 timestamp."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When this status last changed, as an ISO 8601 timestamp. A sync that stops moving is visible here rather than only in `state`."
          }
        },
        "required": [
          "sync_id",
          "robot_id",
          "state",
          "done",
          "total",
          "failed",
          "reason",
          "stored",
          "announced",
          "started_at",
          "updated_at"
        ],
        "additionalProperties": false
      },
      "assets-clear-response": {
        "type": "object",
        "properties": {
          "deleted": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "How many assets — URDF, meshes and textures together — were removed."
          },
          "bytes_freed": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "The bytes the robot's store got back."
          }
        },
        "required": [
          "deleted",
          "bytes_freed"
        ],
        "additionalProperties": false
      },
      "audit-list-response": {
        "type": "object",
        "properties": {
          "events": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                },
                "org_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                },
                "at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                },
                "seq": {
                  "type": "integer",
                  "exclusiveMinimum": 0,
                  "maximum": 9007199254740991
                },
                "actor": {
                  "type": "object",
                  "properties": {
                    "kind": {
                      "type": "string",
                      "enum": [
                        "developer",
                        "end_user",
                        "app_user",
                        "server_key",
                        "bridge",
                        "fleetless"
                      ]
                    },
                    "id": {
                      "type": "string",
                      "format": "uuid",
                      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                    },
                    "label": {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 200
                    }
                  },
                  "required": [
                    "kind",
                    "id",
                    "label"
                  ],
                  "additionalProperties": false
                },
                "action": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 80
                },
                "target": {
                  "anyOf": [
                    {
                      "type": "object",
                      "properties": {
                        "kind": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 40
                        },
                        "id": {
                          "type": "string",
                          "minLength": 1
                        },
                        "label": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 200
                        }
                      },
                      "required": [
                        "kind",
                        "id",
                        "label"
                      ],
                      "additionalProperties": false
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "details": {
                  "anyOf": [
                    {
                      "type": "object",
                      "propertyNames": {
                        "type": "string"
                      },
                      "additionalProperties": {}
                    },
                    {
                      "type": "null"
                    }
                  ]
                }
              },
              "required": [
                "id",
                "org_id",
                "at",
                "seq",
                "actor",
                "action",
                "target",
                "details"
              ],
              "additionalProperties": false
            }
          },
          "next_cursor": {
            "anyOf": [
              {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "events",
          "next_cursor"
        ],
        "additionalProperties": false
      },
      "auth-me-response": {
        "type": "object",
        "properties": {
          "org": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                "description": "The organisation. Every developer route is scoped to the caller's org already, so a client rarely has to send this anywhere."
              },
              "name": {
                "type": "string",
                "minLength": 1,
                "maxLength": 120,
                "description": "The organisation's display name. Free text, changed through `PATCH /api/org`."
              },
              "require_two_factor": {
                "type": "boolean",
                "description": "Whether every member must have a second factor — a passkey or an authenticator app. A member without one sets it up at their next sign-in, before any session exists; nobody is signed out when it is switched on. It covers the console and the central MCP endpoint; server keys and robot bridges are not people and are not affected. Owners change it through `PATCH /api/org`."
              },
              "created_at": {
                "type": "string",
                "format": "date-time",
                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                "description": "When the organisation was created, as an ISO 8601 timestamp."
              }
            },
            "required": [
              "id",
              "name",
              "require_two_factor",
              "created_at"
            ],
            "additionalProperties": false
          },
          "user": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                "description": "The Fleetless user in the API, assigned by the cloud and stable for the life of the account."
              },
              "org_id": {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                "description": "The organisation this person belongs to. Every developer route is already scoped to the caller's org, so this confirms what a client is looking at, not a filter it applies."
              },
              "email": {
                "type": "string",
                "format": "email",
                "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                "description": "The address the account is identified by, **globally unique** across every organisation. Immutable after creation: it is what every invitation, reset link and audit line names."
              },
              "display_name": {
                "anyOf": [
                  {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 120
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Optional human name, shown by the console instead of the address where present. Self-service through `PATCH /api/auth/me`; never used for authentication. `null` when the person never supplied one."
              },
              "tier": {
                "type": "string",
                "enum": [
                  "owner",
                  "developer"
                ],
                "description": "The console powers this person holds. **Required** — every Fleetless user has a tier; it was optional only while the org also held people with no console powers to grade, and that pool is gone."
              },
              "two_factor": {
                "type": "object",
                "properties": {
                  "passkeys": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991,
                    "description": "How many passkeys the person has registered."
                  },
                  "authenticator": {
                    "type": "boolean",
                    "description": "Whether the person has a confirmed authenticator app."
                  }
                },
                "required": [
                  "passkeys",
                  "authenticator"
                ],
                "additionalProperties": false,
                "description": "The person's second factors, as the team list shows them: none, passkeys, an authenticator, or both. No credential travels here. An owner resets them through `DELETE /api/org/users/:id/two-factor`."
              },
              "created_at": {
                "type": "string",
                "format": "date-time",
                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                "description": "When the account was created, as an ISO 8601 timestamp."
              }
            },
            "required": [
              "id",
              "org_id",
              "email",
              "display_name",
              "tier",
              "two_factor",
              "created_at"
            ],
            "additionalProperties": false
          }
        },
        "required": [
          "org",
          "user"
        ],
        "additionalProperties": false
      },
      "authorization-server-metadata": {
        "type": "object",
        "properties": {
          "issuer": {
            "type": "string",
            "format": "uri",
            "description": "The issuer identifier of this authorization server, per RFC 8414 §2. It is what a client checks a token's `iss` against."
          },
          "authorization_endpoint": {
            "type": "string",
            "format": "uri",
            "description": "Where a client sends the user to authorize."
          },
          "token_endpoint": {
            "type": "string",
            "format": "uri",
            "description": "The URL where a client exchanges an authorization code, or a refresh token, for tokens."
          },
          "registration_endpoint": {
            "description": "The URL where a client may register itself, per RFC 7591. Absent when the app does not accept dynamic clients.",
            "type": "string",
            "format": "uri"
          },
          "response_types_supported": {
            "type": "array",
            "items": {
              "type": "string",
              "const": "code"
            },
            "description": "The response types this server offers: `code` only, the implicit grant being gone with OAuth 2.1."
          },
          "grant_types_supported": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "authorization_code",
                "refresh_token"
              ]
            },
            "description": "The grants this server offers: `authorization_code` and `refresh_token`. OAuth 2.1 removes the implicit and password grants, so neither appears here."
          },
          "code_challenge_methods_supported": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "S256"
              ]
            },
            "description": "The PKCE challenge methods accepted: `S256` only. `plain` is not offered — a challenge equal to its verifier defends against nothing, and offering it would make a downgrade negotiable."
          },
          "token_endpoint_auth_methods_supported": {
            "type": "array",
            "items": {
              "type": "string",
              "const": "none"
            },
            "description": "How a client authenticates at the token endpoint: `none`, the public-client method, with PKCE protecting the exchange."
          },
          "scopes_supported": {
            "description": "The scopes this server knows about, where it publishes a list.",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "issuer",
          "authorization_endpoint",
          "token_endpoint",
          "response_types_supported",
          "grant_types_supported",
          "code_challenge_methods_supported",
          "token_endpoint_auth_methods_supported"
        ],
        "additionalProperties": false
      },
      "billing-cancel-request": {
        "type": "object",
        "properties": {
          "reason": {
            "description": "An optional free-text reason. Shown to nobody but Fleetless.",
            "type": "string",
            "maxLength": 500
          }
        },
        "additionalProperties": false
      },
      "billing-change-request": {
        "type": "object",
        "properties": {
          "plan": {
            "description": "The target plan. Omitted leaves the plan as it is.",
            "type": "string",
            "enum": [
              "plus",
              "pro"
            ]
          },
          "cycle": {
            "description": "The target cycle. Omitted leaves the cycle as it is.",
            "type": "string",
            "enum": [
              "monthly",
              "yearly"
            ]
          },
          "addons": {
            "description": "Absolute add-on counts to end up with, not a delta. Omitted leaves add-ons as they are.",
            "type": "object",
            "properties": {
              "seats": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Extra developer seats, one each."
              },
              "robots": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Extra robots, one each."
              },
              "apps": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Extra apps, one each."
              },
              "app_user_packs": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Packs of five extra app users."
              },
              "live_video_packs": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Packs of 250 extra hours of app-user live video per month."
              }
            },
            "additionalProperties": false
          }
        },
        "additionalProperties": false
      },
      "billing-change-response": {
        "type": "object",
        "properties": {
          "billing": {
            "type": "object",
            "properties": {
              "available": {
                "type": "boolean",
                "description": "Whether this cloud takes payments at all — `false` when no Mollie key is configured."
              },
              "account": {
                "anyOf": [
                  {
                    "type": "object",
                    "properties": {
                      "payer": {
                        "oneOf": [
                          {
                            "type": "object",
                            "properties": {
                              "kind": {
                                "type": "string",
                                "const": "company",
                                "description": "Billed as a company."
                              },
                              "company_name": {
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 200,
                                "description": "The company's legal name, printed on the invoice."
                              },
                              "vat_id": {
                                "anyOf": [
                                  {
                                    "type": "string",
                                    "maxLength": 20
                                  },
                                  {
                                    "type": "null"
                                  }
                                ],
                                "description": "The company's VAT ID, or `null` for none. Required, and must check out through VIES, for a company outside Germany."
                              },
                              "address": {
                                "type": "object",
                                "properties": {
                                  "line1": {
                                    "type": "string",
                                    "minLength": 1,
                                    "maxLength": 200,
                                    "description": "Street and number, or the first address line."
                                  },
                                  "line2": {
                                    "anyOf": [
                                      {
                                        "type": "string",
                                        "maxLength": 200
                                      },
                                      {
                                        "type": "null"
                                      }
                                    ],
                                    "description": "A second address line, or `null` when there is none."
                                  },
                                  "postal_code": {
                                    "type": "string",
                                    "minLength": 1,
                                    "maxLength": 20,
                                    "description": "Postal or ZIP code."
                                  },
                                  "city": {
                                    "type": "string",
                                    "minLength": 1,
                                    "maxLength": 100,
                                    "description": "City or town."
                                  },
                                  "country": {
                                    "type": "string",
                                    "pattern": "^[A-Z]{2}$",
                                    "description": "The billing country. Decides VAT (`vatFor`) and currency (`currencyForCountry`)."
                                  }
                                },
                                "required": [
                                  "line1",
                                  "line2",
                                  "postal_code",
                                  "city",
                                  "country"
                                ],
                                "additionalProperties": false,
                                "description": "The billing address."
                              },
                              "invoice_email": {
                                "type": "string",
                                "format": "email",
                                "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                                "description": "Where invoices and billing mail are sent."
                              }
                            },
                            "required": [
                              "kind",
                              "company_name",
                              "vat_id",
                              "address",
                              "invoice_email"
                            ],
                            "additionalProperties": false,
                            "description": "A company: name and an optional VAT ID, never 'full name'."
                          },
                          {
                            "type": "object",
                            "properties": {
                              "kind": {
                                "type": "string",
                                "const": "person",
                                "description": "Billed as a person."
                              },
                              "full_name": {
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 200,
                                "description": "The person's full name, printed on the invoice."
                              },
                              "address": {
                                "type": "object",
                                "properties": {
                                  "line1": {
                                    "type": "string",
                                    "minLength": 1,
                                    "maxLength": 200,
                                    "description": "Street and number, or the first address line."
                                  },
                                  "line2": {
                                    "anyOf": [
                                      {
                                        "type": "string",
                                        "maxLength": 200
                                      },
                                      {
                                        "type": "null"
                                      }
                                    ],
                                    "description": "A second address line, or `null` when there is none."
                                  },
                                  "postal_code": {
                                    "type": "string",
                                    "minLength": 1,
                                    "maxLength": 20,
                                    "description": "Postal or ZIP code."
                                  },
                                  "city": {
                                    "type": "string",
                                    "minLength": 1,
                                    "maxLength": 100,
                                    "description": "City or town."
                                  },
                                  "country": {
                                    "type": "string",
                                    "pattern": "^[A-Z]{2}$",
                                    "description": "The billing country. Decides VAT (`vatFor`) and currency (`currencyForCountry`)."
                                  }
                                },
                                "required": [
                                  "line1",
                                  "line2",
                                  "postal_code",
                                  "city",
                                  "country"
                                ],
                                "additionalProperties": false,
                                "description": "The billing address."
                              },
                              "invoice_email": {
                                "type": "string",
                                "format": "email",
                                "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                                "description": "Where invoices and billing mail are sent."
                              }
                            },
                            "required": [
                              "kind",
                              "full_name",
                              "address",
                              "invoice_email"
                            ],
                            "additionalProperties": false,
                            "description": "A person: a full name, never a VAT ID — only a company can be VAT-registered."
                          }
                        ],
                        "description": "Who is paying, and the invoice address and email."
                      },
                      "vat_id_status": {
                        "anyOf": [
                          {
                            "type": "string",
                            "enum": [
                              "valid",
                              "unverified",
                              "invalid"
                            ]
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "description": "The payer's VAT ID check, or `null` when no VAT ID was given."
                      },
                      "vat": {
                        "type": "object",
                        "properties": {
                          "treatment": {
                            "type": "string",
                            "enum": [
                              "de_standard",
                              "reverse_charge",
                              "outside_eu"
                            ],
                            "description": "How the account is taxed."
                          },
                          "rate_percent": {
                            "type": "integer",
                            "minimum": -9007199254740991,
                            "maximum": 9007199254740991,
                            "description": "The VAT rate, in whole percent."
                          }
                        },
                        "required": [
                          "treatment",
                          "rate_percent"
                        ],
                        "additionalProperties": false,
                        "description": "The VAT treatment and rate this account charges at (`vatFor`)."
                      },
                      "currency": {
                        "type": "string",
                        "enum": [
                          "eur",
                          "usd"
                        ],
                        "description": "The org's billing currency, fixed at the first payment."
                      },
                      "cycle": {
                        "type": "string",
                        "enum": [
                          "monthly",
                          "yearly"
                        ],
                        "description": "The current billing cycle."
                      },
                      "status": {
                        "type": "string",
                        "enum": [
                          "pending",
                          "active",
                          "past_due",
                          "canceled"
                        ],
                        "description": "The account's own status."
                      },
                      "period_starts_at": {
                        "anyOf": [
                          {
                            "type": "string",
                            "format": "date-time",
                            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "description": "The current period's start. `null` before the first payment."
                      },
                      "period_ends_at": {
                        "anyOf": [
                          {
                            "type": "string",
                            "format": "date-time",
                            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "description": "The current period's end. `null` before the first payment."
                      },
                      "next_charge": {
                        "anyOf": [
                          {
                            "type": "object",
                            "properties": {
                              "at": {
                                "type": "string",
                                "format": "date-time",
                                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                                "description": "When the next charge is due."
                              },
                              "net_cents": {
                                "type": "integer",
                                "minimum": -9007199254740991,
                                "maximum": 9007199254740991,
                                "description": "The next charge, excluding VAT, in integer cents."
                              },
                              "vat_cents": {
                                "type": "integer",
                                "minimum": -9007199254740991,
                                "maximum": 9007199254740991,
                                "description": "VAT on the next charge, in integer cents."
                              },
                              "gross_cents": {
                                "type": "integer",
                                "minimum": -9007199254740991,
                                "maximum": 9007199254740991,
                                "description": "The next charge including VAT, in integer cents."
                              }
                            },
                            "required": [
                              "at",
                              "net_cents",
                              "vat_cents",
                              "gross_cents"
                            ],
                            "additionalProperties": false
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "description": "`null` while a cancel is pending or nothing else renews."
                      },
                      "scheduled": {
                        "type": "object",
                        "properties": {
                          "cycle": {
                            "anyOf": [
                              {
                                "type": "string",
                                "enum": [
                                  "monthly",
                                  "yearly"
                                ]
                              },
                              {
                                "type": "null"
                              }
                            ],
                            "description": "A cycle change queued for the period's end, or `null`."
                          },
                          "addons": {
                            "anyOf": [
                              {
                                "type": "object",
                                "properties": {
                                  "seats": {
                                    "type": "integer",
                                    "minimum": 0,
                                    "maximum": 9007199254740991,
                                    "description": "Extra developer seats, one each."
                                  },
                                  "robots": {
                                    "type": "integer",
                                    "minimum": 0,
                                    "maximum": 9007199254740991,
                                    "description": "Extra robots, one each."
                                  },
                                  "apps": {
                                    "type": "integer",
                                    "minimum": 0,
                                    "maximum": 9007199254740991,
                                    "description": "Extra apps, one each."
                                  },
                                  "app_user_packs": {
                                    "type": "integer",
                                    "minimum": 0,
                                    "maximum": 9007199254740991,
                                    "description": "Packs of five extra app users."
                                  },
                                  "live_video_packs": {
                                    "type": "integer",
                                    "minimum": 0,
                                    "maximum": 9007199254740991,
                                    "description": "Packs of 250 extra hours of app-user live video per month."
                                  }
                                },
                                "required": [
                                  "seats",
                                  "robots",
                                  "apps",
                                  "app_user_packs",
                                  "live_video_packs"
                                ],
                                "additionalProperties": false
                              },
                              {
                                "type": "null"
                              }
                            ],
                            "description": "Add-on counts queued for the period's end, or `null`."
                          }
                        },
                        "required": [
                          "cycle",
                          "addons"
                        ],
                        "additionalProperties": false,
                        "description": "What takes effect at the period's end."
                      },
                      "dunning": {
                        "anyOf": [
                          {
                            "type": "object",
                            "properties": {
                              "invoice_id": {
                                "type": "string",
                                "format": "uuid",
                                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                                "description": "The open invoice dunning is chasing — or, while the payer's VAT ID is unsettled, the due charge that becomes one: it has no number yet and is not in `invoices`. `POST /api/billing/invoices/:id/pay` takes either."
                              },
                              "gross_cents": {
                                "type": "integer",
                                "minimum": -9007199254740991,
                                "maximum": 9007199254740991,
                                "description": "The amount owed, in integer cents — after a partial chargeback only the charged-back part."
                              },
                              "due_at": {
                                "type": "string",
                                "format": "date-time",
                                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                                "description": "The charge's due date; retries and the lock count from here."
                              },
                              "next_retry_at": {
                                "anyOf": [
                                  {
                                    "type": "string",
                                    "format": "date-time",
                                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                                  },
                                  {
                                    "type": "null"
                                  }
                                ],
                                "description": "The next automatic retry, or `null` once retries are exhausted. Always `null` after a chargeback: a charged-back payment is never charged again automatically."
                              },
                              "lock_at": {
                                "type": "string",
                                "format": "date-time",
                                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                                "description": "When the org is locked if still unpaid (`BILLING_LOCK_DAY`)."
                              },
                              "failure": {
                                "anyOf": [
                                  {
                                    "type": "string"
                                  },
                                  {
                                    "type": "null"
                                  }
                                ],
                                "description": "Mollie's own reason, e.g. 'card_expired'; 'vat_id_invalid' while a renewal is held because VIES does not confirm the VAT ID; 'charged_back' after a chargeback or a lost PayPal dispute."
                              }
                            },
                            "required": [
                              "invoice_id",
                              "gross_cents",
                              "due_at",
                              "next_retry_at",
                              "lock_at",
                              "failure"
                            ],
                            "additionalProperties": false
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "description": "`null` while nothing is overdue. During a VAT-ID hold the charge is due but not yet invoiced."
                      }
                    },
                    "required": [
                      "payer",
                      "vat_id_status",
                      "vat",
                      "currency",
                      "cycle",
                      "status",
                      "period_starts_at",
                      "period_ends_at",
                      "next_charge",
                      "scheduled",
                      "dunning"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "`null` before the org has ever checked out."
              },
              "payment_method": {
                "anyOf": [
                  {
                    "oneOf": [
                      {
                        "type": "object",
                        "properties": {
                          "kind": {
                            "type": "string",
                            "const": "card",
                            "description": "A card mandate."
                          },
                          "brand": {
                            "type": "string",
                            "description": "The card network, as Mollie's mandate reports it, e.g. 'Visa'."
                          },
                          "last4": {
                            "type": "string",
                            "pattern": "^\\d{4}$",
                            "description": "The last four digits of the card."
                          },
                          "expires": {
                            "type": "string",
                            "pattern": "^\\d{2}\\/\\d{2}$",
                            "description": "Expiry as Mollie's mandate shows it, 'MM/YY'."
                          }
                        },
                        "required": [
                          "kind",
                          "brand",
                          "last4",
                          "expires"
                        ],
                        "additionalProperties": false
                      },
                      {
                        "type": "object",
                        "properties": {
                          "kind": {
                            "type": "string",
                            "const": "applepay",
                            "description": "A card mandate from an Apple Pay first payment — Mollie stores it as `creditcard`; Fleetless labels it Apple Pay."
                          },
                          "brand": {
                            "type": "string",
                            "description": "The card network, as Mollie's mandate reports it, e.g. 'Mastercard'."
                          },
                          "last4": {
                            "type": "string",
                            "pattern": "^\\d{4}$",
                            "description": "The last four digits of the card behind Apple Pay."
                          },
                          "expires": {
                            "type": "string",
                            "pattern": "^\\d{2}\\/\\d{2}$",
                            "description": "Expiry as Mollie's mandate shows it, 'MM/YY'."
                          }
                        },
                        "required": [
                          "kind",
                          "brand",
                          "last4",
                          "expires"
                        ],
                        "additionalProperties": false
                      },
                      {
                        "type": "object",
                        "properties": {
                          "kind": {
                            "type": "string",
                            "const": "paypal",
                            "description": "A PayPal mandate."
                          },
                          "account": {
                            "type": "string",
                            "description": "The PayPal account Mollie's mandate names."
                          }
                        },
                        "required": [
                          "kind",
                          "account"
                        ],
                        "additionalProperties": false
                      }
                    ]
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "The payment method on file, or `null`."
              },
              "payment_method_options": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "method": {
                      "type": "string",
                      "enum": [
                        "card",
                        "paypal",
                        "applepay"
                      ],
                      "description": "A method `POST /api/billing/payment-method` accepts now."
                    },
                    "pays_invoice": {
                      "type": "boolean",
                      "description": "Whether changing to it also pays the open invoice — `true` only for `applepay` when a zero-amount Apple Pay payment is not available."
                    }
                  },
                  "required": [
                    "method",
                    "pays_invoice"
                  ],
                  "additionalProperties": false
                },
                "description": "What a payment-method change may offer right now, so a client offers exactly what the cloud accepts. `applepay` is absent when it needs an open invoice and none is open; `[]` when the org has no billing account."
              },
              "invoices": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid",
                      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                      "description": "The invoice's id."
                    },
                    "number": {
                      "type": "string",
                      "pattern": "^FL-\\d{4}-\\d{4,}$",
                      "description": "The invoice number: `FL-<year>-<seq>`, `seq` zero-padded to four digits, gapless per calendar year in `Europe/Berlin`."
                    },
                    "issued_at": {
                      "type": "string",
                      "format": "date-time",
                      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                      "description": "When the invoice was issued."
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "open",
                        "paid",
                        "uncollectible"
                      ],
                      "description": "This invoice's own status."
                    },
                    "currency": {
                      "type": "string",
                      "enum": [
                        "eur",
                        "usd"
                      ],
                      "description": "The org's billing currency."
                    },
                    "net_cents": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991,
                      "description": "The charge, excluding VAT, in integer cents."
                    },
                    "vat_rate_percent": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 100,
                      "description": "The VAT rate applied, in whole percent."
                    },
                    "vat_cents": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991,
                      "description": "VAT, in integer cents (`vatCents`)."
                    },
                    "gross_cents": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991,
                      "description": "Net plus VAT, in integer cents."
                    }
                  },
                  "required": [
                    "id",
                    "number",
                    "issued_at",
                    "status",
                    "currency",
                    "net_cents",
                    "vat_rate_percent",
                    "vat_cents",
                    "gross_cents"
                  ],
                  "additionalProperties": false
                },
                "description": "Newest first, at most 24."
              }
            },
            "required": [
              "available",
              "account",
              "payment_method",
              "payment_method_options",
              "invoices"
            ],
            "additionalProperties": false,
            "description": "The billing view after the change."
          },
          "charged": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid",
                    "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                    "description": "The invoice's id."
                  },
                  "number": {
                    "type": "string",
                    "pattern": "^FL-\\d{4}-\\d{4,}$",
                    "description": "The invoice number: `FL-<year>-<seq>`, `seq` zero-padded to four digits, gapless per calendar year in `Europe/Berlin`."
                  },
                  "issued_at": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                    "description": "When the invoice was issued."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "open",
                      "paid",
                      "uncollectible"
                    ],
                    "description": "This invoice's own status."
                  },
                  "currency": {
                    "type": "string",
                    "enum": [
                      "eur",
                      "usd"
                    ],
                    "description": "The org's billing currency."
                  },
                  "net_cents": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991,
                    "description": "The charge, excluding VAT, in integer cents."
                  },
                  "vat_rate_percent": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 100,
                    "description": "The VAT rate applied, in whole percent."
                  },
                  "vat_cents": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991,
                    "description": "VAT, in integer cents (`vatCents`)."
                  },
                  "gross_cents": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991,
                    "description": "Net plus VAT, in integer cents."
                  }
                },
                "required": [
                  "id",
                  "number",
                  "issued_at",
                  "status",
                  "currency",
                  "net_cents",
                  "vat_rate_percent",
                  "vat_cents",
                  "gross_cents"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "description": "The invoice charged now, or `null` when the change was only scheduled for the period's end."
          }
        },
        "required": [
          "billing",
          "charged"
        ],
        "additionalProperties": false
      },
      "billing-details-update": {
        "type": "object",
        "properties": {
          "invoice_email": {
            "description": "Replaces the invoice email. Omitted leaves it as it is.",
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
          },
          "vat_id": {
            "description": "Replaces the VAT ID; `null` clears it. Omitted leaves it as it is. A new ID is re-checked through VIES.",
            "anyOf": [
              {
                "type": "string",
                "maxLength": 20
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "additionalProperties": false
      },
      "billing-view": {
        "type": "object",
        "properties": {
          "available": {
            "type": "boolean",
            "description": "Whether this cloud takes payments at all — `false` when no Mollie key is configured."
          },
          "account": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "payer": {
                    "oneOf": [
                      {
                        "type": "object",
                        "properties": {
                          "kind": {
                            "type": "string",
                            "const": "company",
                            "description": "Billed as a company."
                          },
                          "company_name": {
                            "type": "string",
                            "minLength": 1,
                            "maxLength": 200,
                            "description": "The company's legal name, printed on the invoice."
                          },
                          "vat_id": {
                            "anyOf": [
                              {
                                "type": "string",
                                "maxLength": 20
                              },
                              {
                                "type": "null"
                              }
                            ],
                            "description": "The company's VAT ID, or `null` for none. Required, and must check out through VIES, for a company outside Germany."
                          },
                          "address": {
                            "type": "object",
                            "properties": {
                              "line1": {
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 200,
                                "description": "Street and number, or the first address line."
                              },
                              "line2": {
                                "anyOf": [
                                  {
                                    "type": "string",
                                    "maxLength": 200
                                  },
                                  {
                                    "type": "null"
                                  }
                                ],
                                "description": "A second address line, or `null` when there is none."
                              },
                              "postal_code": {
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 20,
                                "description": "Postal or ZIP code."
                              },
                              "city": {
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 100,
                                "description": "City or town."
                              },
                              "country": {
                                "type": "string",
                                "pattern": "^[A-Z]{2}$",
                                "description": "The billing country. Decides VAT (`vatFor`) and currency (`currencyForCountry`)."
                              }
                            },
                            "required": [
                              "line1",
                              "line2",
                              "postal_code",
                              "city",
                              "country"
                            ],
                            "additionalProperties": false,
                            "description": "The billing address."
                          },
                          "invoice_email": {
                            "type": "string",
                            "format": "email",
                            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                            "description": "Where invoices and billing mail are sent."
                          }
                        },
                        "required": [
                          "kind",
                          "company_name",
                          "vat_id",
                          "address",
                          "invoice_email"
                        ],
                        "additionalProperties": false,
                        "description": "A company: name and an optional VAT ID, never 'full name'."
                      },
                      {
                        "type": "object",
                        "properties": {
                          "kind": {
                            "type": "string",
                            "const": "person",
                            "description": "Billed as a person."
                          },
                          "full_name": {
                            "type": "string",
                            "minLength": 1,
                            "maxLength": 200,
                            "description": "The person's full name, printed on the invoice."
                          },
                          "address": {
                            "type": "object",
                            "properties": {
                              "line1": {
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 200,
                                "description": "Street and number, or the first address line."
                              },
                              "line2": {
                                "anyOf": [
                                  {
                                    "type": "string",
                                    "maxLength": 200
                                  },
                                  {
                                    "type": "null"
                                  }
                                ],
                                "description": "A second address line, or `null` when there is none."
                              },
                              "postal_code": {
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 20,
                                "description": "Postal or ZIP code."
                              },
                              "city": {
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 100,
                                "description": "City or town."
                              },
                              "country": {
                                "type": "string",
                                "pattern": "^[A-Z]{2}$",
                                "description": "The billing country. Decides VAT (`vatFor`) and currency (`currencyForCountry`)."
                              }
                            },
                            "required": [
                              "line1",
                              "line2",
                              "postal_code",
                              "city",
                              "country"
                            ],
                            "additionalProperties": false,
                            "description": "The billing address."
                          },
                          "invoice_email": {
                            "type": "string",
                            "format": "email",
                            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                            "description": "Where invoices and billing mail are sent."
                          }
                        },
                        "required": [
                          "kind",
                          "full_name",
                          "address",
                          "invoice_email"
                        ],
                        "additionalProperties": false,
                        "description": "A person: a full name, never a VAT ID — only a company can be VAT-registered."
                      }
                    ],
                    "description": "Who is paying, and the invoice address and email."
                  },
                  "vat_id_status": {
                    "anyOf": [
                      {
                        "type": "string",
                        "enum": [
                          "valid",
                          "unverified",
                          "invalid"
                        ]
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "The payer's VAT ID check, or `null` when no VAT ID was given."
                  },
                  "vat": {
                    "type": "object",
                    "properties": {
                      "treatment": {
                        "type": "string",
                        "enum": [
                          "de_standard",
                          "reverse_charge",
                          "outside_eu"
                        ],
                        "description": "How the account is taxed."
                      },
                      "rate_percent": {
                        "type": "integer",
                        "minimum": -9007199254740991,
                        "maximum": 9007199254740991,
                        "description": "The VAT rate, in whole percent."
                      }
                    },
                    "required": [
                      "treatment",
                      "rate_percent"
                    ],
                    "additionalProperties": false,
                    "description": "The VAT treatment and rate this account charges at (`vatFor`)."
                  },
                  "currency": {
                    "type": "string",
                    "enum": [
                      "eur",
                      "usd"
                    ],
                    "description": "The org's billing currency, fixed at the first payment."
                  },
                  "cycle": {
                    "type": "string",
                    "enum": [
                      "monthly",
                      "yearly"
                    ],
                    "description": "The current billing cycle."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "pending",
                      "active",
                      "past_due",
                      "canceled"
                    ],
                    "description": "The account's own status."
                  },
                  "period_starts_at": {
                    "anyOf": [
                      {
                        "type": "string",
                        "format": "date-time",
                        "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "The current period's start. `null` before the first payment."
                  },
                  "period_ends_at": {
                    "anyOf": [
                      {
                        "type": "string",
                        "format": "date-time",
                        "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "The current period's end. `null` before the first payment."
                  },
                  "next_charge": {
                    "anyOf": [
                      {
                        "type": "object",
                        "properties": {
                          "at": {
                            "type": "string",
                            "format": "date-time",
                            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                            "description": "When the next charge is due."
                          },
                          "net_cents": {
                            "type": "integer",
                            "minimum": -9007199254740991,
                            "maximum": 9007199254740991,
                            "description": "The next charge, excluding VAT, in integer cents."
                          },
                          "vat_cents": {
                            "type": "integer",
                            "minimum": -9007199254740991,
                            "maximum": 9007199254740991,
                            "description": "VAT on the next charge, in integer cents."
                          },
                          "gross_cents": {
                            "type": "integer",
                            "minimum": -9007199254740991,
                            "maximum": 9007199254740991,
                            "description": "The next charge including VAT, in integer cents."
                          }
                        },
                        "required": [
                          "at",
                          "net_cents",
                          "vat_cents",
                          "gross_cents"
                        ],
                        "additionalProperties": false
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "`null` while a cancel is pending or nothing else renews."
                  },
                  "scheduled": {
                    "type": "object",
                    "properties": {
                      "cycle": {
                        "anyOf": [
                          {
                            "type": "string",
                            "enum": [
                              "monthly",
                              "yearly"
                            ]
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "description": "A cycle change queued for the period's end, or `null`."
                      },
                      "addons": {
                        "anyOf": [
                          {
                            "type": "object",
                            "properties": {
                              "seats": {
                                "type": "integer",
                                "minimum": 0,
                                "maximum": 9007199254740991,
                                "description": "Extra developer seats, one each."
                              },
                              "robots": {
                                "type": "integer",
                                "minimum": 0,
                                "maximum": 9007199254740991,
                                "description": "Extra robots, one each."
                              },
                              "apps": {
                                "type": "integer",
                                "minimum": 0,
                                "maximum": 9007199254740991,
                                "description": "Extra apps, one each."
                              },
                              "app_user_packs": {
                                "type": "integer",
                                "minimum": 0,
                                "maximum": 9007199254740991,
                                "description": "Packs of five extra app users."
                              },
                              "live_video_packs": {
                                "type": "integer",
                                "minimum": 0,
                                "maximum": 9007199254740991,
                                "description": "Packs of 250 extra hours of app-user live video per month."
                              }
                            },
                            "required": [
                              "seats",
                              "robots",
                              "apps",
                              "app_user_packs",
                              "live_video_packs"
                            ],
                            "additionalProperties": false
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "description": "Add-on counts queued for the period's end, or `null`."
                      }
                    },
                    "required": [
                      "cycle",
                      "addons"
                    ],
                    "additionalProperties": false,
                    "description": "What takes effect at the period's end."
                  },
                  "dunning": {
                    "anyOf": [
                      {
                        "type": "object",
                        "properties": {
                          "invoice_id": {
                            "type": "string",
                            "format": "uuid",
                            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                            "description": "The open invoice dunning is chasing — or, while the payer's VAT ID is unsettled, the due charge that becomes one: it has no number yet and is not in `invoices`. `POST /api/billing/invoices/:id/pay` takes either."
                          },
                          "gross_cents": {
                            "type": "integer",
                            "minimum": -9007199254740991,
                            "maximum": 9007199254740991,
                            "description": "The amount owed, in integer cents — after a partial chargeback only the charged-back part."
                          },
                          "due_at": {
                            "type": "string",
                            "format": "date-time",
                            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                            "description": "The charge's due date; retries and the lock count from here."
                          },
                          "next_retry_at": {
                            "anyOf": [
                              {
                                "type": "string",
                                "format": "date-time",
                                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                              },
                              {
                                "type": "null"
                              }
                            ],
                            "description": "The next automatic retry, or `null` once retries are exhausted. Always `null` after a chargeback: a charged-back payment is never charged again automatically."
                          },
                          "lock_at": {
                            "type": "string",
                            "format": "date-time",
                            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                            "description": "When the org is locked if still unpaid (`BILLING_LOCK_DAY`)."
                          },
                          "failure": {
                            "anyOf": [
                              {
                                "type": "string"
                              },
                              {
                                "type": "null"
                              }
                            ],
                            "description": "Mollie's own reason, e.g. 'card_expired'; 'vat_id_invalid' while a renewal is held because VIES does not confirm the VAT ID; 'charged_back' after a chargeback or a lost PayPal dispute."
                          }
                        },
                        "required": [
                          "invoice_id",
                          "gross_cents",
                          "due_at",
                          "next_retry_at",
                          "lock_at",
                          "failure"
                        ],
                        "additionalProperties": false
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "`null` while nothing is overdue. During a VAT-ID hold the charge is due but not yet invoiced."
                  }
                },
                "required": [
                  "payer",
                  "vat_id_status",
                  "vat",
                  "currency",
                  "cycle",
                  "status",
                  "period_starts_at",
                  "period_ends_at",
                  "next_charge",
                  "scheduled",
                  "dunning"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "description": "`null` before the org has ever checked out."
          },
          "payment_method": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "kind": {
                        "type": "string",
                        "const": "card",
                        "description": "A card mandate."
                      },
                      "brand": {
                        "type": "string",
                        "description": "The card network, as Mollie's mandate reports it, e.g. 'Visa'."
                      },
                      "last4": {
                        "type": "string",
                        "pattern": "^\\d{4}$",
                        "description": "The last four digits of the card."
                      },
                      "expires": {
                        "type": "string",
                        "pattern": "^\\d{2}\\/\\d{2}$",
                        "description": "Expiry as Mollie's mandate shows it, 'MM/YY'."
                      }
                    },
                    "required": [
                      "kind",
                      "brand",
                      "last4",
                      "expires"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "kind": {
                        "type": "string",
                        "const": "applepay",
                        "description": "A card mandate from an Apple Pay first payment — Mollie stores it as `creditcard`; Fleetless labels it Apple Pay."
                      },
                      "brand": {
                        "type": "string",
                        "description": "The card network, as Mollie's mandate reports it, e.g. 'Mastercard'."
                      },
                      "last4": {
                        "type": "string",
                        "pattern": "^\\d{4}$",
                        "description": "The last four digits of the card behind Apple Pay."
                      },
                      "expires": {
                        "type": "string",
                        "pattern": "^\\d{2}\\/\\d{2}$",
                        "description": "Expiry as Mollie's mandate shows it, 'MM/YY'."
                      }
                    },
                    "required": [
                      "kind",
                      "brand",
                      "last4",
                      "expires"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "kind": {
                        "type": "string",
                        "const": "paypal",
                        "description": "A PayPal mandate."
                      },
                      "account": {
                        "type": "string",
                        "description": "The PayPal account Mollie's mandate names."
                      }
                    },
                    "required": [
                      "kind",
                      "account"
                    ],
                    "additionalProperties": false
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "description": "The payment method on file, or `null`."
          },
          "payment_method_options": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "method": {
                  "type": "string",
                  "enum": [
                    "card",
                    "paypal",
                    "applepay"
                  ],
                  "description": "A method `POST /api/billing/payment-method` accepts now."
                },
                "pays_invoice": {
                  "type": "boolean",
                  "description": "Whether changing to it also pays the open invoice — `true` only for `applepay` when a zero-amount Apple Pay payment is not available."
                }
              },
              "required": [
                "method",
                "pays_invoice"
              ],
              "additionalProperties": false
            },
            "description": "What a payment-method change may offer right now, so a client offers exactly what the cloud accepts. `applepay` is absent when it needs an open invoice and none is open; `[]` when the org has no billing account."
          },
          "invoices": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The invoice's id."
                },
                "number": {
                  "type": "string",
                  "pattern": "^FL-\\d{4}-\\d{4,}$",
                  "description": "The invoice number: `FL-<year>-<seq>`, `seq` zero-padded to four digits, gapless per calendar year in `Europe/Berlin`."
                },
                "issued_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the invoice was issued."
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "open",
                    "paid",
                    "uncollectible"
                  ],
                  "description": "This invoice's own status."
                },
                "currency": {
                  "type": "string",
                  "enum": [
                    "eur",
                    "usd"
                  ],
                  "description": "The org's billing currency."
                },
                "net_cents": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 9007199254740991,
                  "description": "The charge, excluding VAT, in integer cents."
                },
                "vat_rate_percent": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 100,
                  "description": "The VAT rate applied, in whole percent."
                },
                "vat_cents": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 9007199254740991,
                  "description": "VAT, in integer cents (`vatCents`)."
                },
                "gross_cents": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 9007199254740991,
                  "description": "Net plus VAT, in integer cents."
                }
              },
              "required": [
                "id",
                "number",
                "issued_at",
                "status",
                "currency",
                "net_cents",
                "vat_rate_percent",
                "vat_cents",
                "gross_cents"
              ],
              "additionalProperties": false
            },
            "description": "Newest first, at most 24."
          }
        },
        "required": [
          "available",
          "account",
          "payment_method",
          "payment_method_options",
          "invoices"
        ],
        "additionalProperties": false
      },
      "camera-list-response": {
        "type": "object",
        "properties": {
          "cameras": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "slug": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
                  "description": "The name a client addresses this camera by."
                },
                "width": {
                  "type": "integer",
                  "exclusiveMinimum": 0,
                  "maximum": 9007199254740991,
                  "description": "Frame width in pixels, as the published configuration declares it."
                },
                "height": {
                  "type": "integer",
                  "exclusiveMinimum": 0,
                  "maximum": 9007199254740991,
                  "description": "Frame height in pixels, as the published configuration declares it."
                },
                "fps": {
                  "type": "integer",
                  "exclusiveMinimum": 0,
                  "maximum": 9007199254740991,
                  "description": "How many frames per second the camera is configured to publish while somebody is watching live."
                },
                "snapshot_interval_seconds": {
                  "type": "integer",
                  "minimum": 1,
                  "maximum": 3600,
                  "description": "How often a still frame is captured for the cheap snapshot reads, in seconds, between `1` and `3600`. Independent of `fps`, which is about live video."
                }
              },
              "required": [
                "slug",
                "width",
                "height",
                "fps",
                "snapshot_interval_seconds"
              ],
              "additionalProperties": false
            },
            "description": "Every camera the published configuration exposes on this robot **and** the caller's role grants. A developer sees all of them; an end user sees what their role allows."
          }
        },
        "required": [
          "cameras"
        ],
        "additionalProperties": false
      },
      "cancel-request": {
        "type": "object",
        "properties": {
          "job_id": {
            "description": "The one job to stop. Absent or `null` cancels whatever is currently running on the slug, which is what every caller written before this field existed means. Unknown keys are refused rather than stripped, so a misspelling cannot silently become the slug-wide cancel.",
            "anyOf": [
              {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "additionalProperties": false
      },
      "checkout-request": {
        "type": "object",
        "properties": {
          "plan": {
            "type": "string",
            "enum": [
              "plus",
              "pro"
            ],
            "description": "The plan to buy."
          },
          "cycle": {
            "type": "string",
            "enum": [
              "monthly",
              "yearly"
            ],
            "description": "Monthly, or yearly at 15 % off."
          },
          "addons": {
            "description": "Add-on counts to buy alongside the plan. Pro only; named on `plus`, or without the feature, `400 validation_error`.",
            "type": "object",
            "properties": {
              "seats": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Extra developer seats, one each."
              },
              "robots": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Extra robots, one each."
              },
              "apps": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Extra apps, one each."
              },
              "app_user_packs": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Packs of five extra app users."
              },
              "live_video_packs": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Packs of 250 extra hours of app-user live video per month."
              }
            },
            "additionalProperties": false
          },
          "billing": {
            "oneOf": [
              {
                "type": "object",
                "properties": {
                  "kind": {
                    "type": "string",
                    "const": "company",
                    "description": "Billed as a company."
                  },
                  "company_name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200,
                    "description": "The company's legal name, printed on the invoice."
                  },
                  "vat_id": {
                    "anyOf": [
                      {
                        "type": "string",
                        "maxLength": 20
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "The company's VAT ID, or `null` for none. Required, and must check out through VIES, for a company outside Germany."
                  },
                  "address": {
                    "type": "object",
                    "properties": {
                      "line1": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 200,
                        "description": "Street and number, or the first address line."
                      },
                      "line2": {
                        "anyOf": [
                          {
                            "type": "string",
                            "maxLength": 200
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "description": "A second address line, or `null` when there is none."
                      },
                      "postal_code": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 20,
                        "description": "Postal or ZIP code."
                      },
                      "city": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 100,
                        "description": "City or town."
                      },
                      "country": {
                        "type": "string",
                        "pattern": "^[A-Z]{2}$",
                        "description": "The billing country. Decides VAT (`vatFor`) and currency (`currencyForCountry`)."
                      }
                    },
                    "required": [
                      "line1",
                      "line2",
                      "postal_code",
                      "city",
                      "country"
                    ],
                    "additionalProperties": false,
                    "description": "The billing address."
                  },
                  "invoice_email": {
                    "type": "string",
                    "format": "email",
                    "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                    "description": "Where invoices and billing mail are sent."
                  }
                },
                "required": [
                  "kind",
                  "company_name",
                  "vat_id",
                  "address",
                  "invoice_email"
                ],
                "additionalProperties": false,
                "description": "A company: name and an optional VAT ID, never 'full name'."
              },
              {
                "type": "object",
                "properties": {
                  "kind": {
                    "type": "string",
                    "const": "person",
                    "description": "Billed as a person."
                  },
                  "full_name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200,
                    "description": "The person's full name, printed on the invoice."
                  },
                  "address": {
                    "type": "object",
                    "properties": {
                      "line1": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 200,
                        "description": "Street and number, or the first address line."
                      },
                      "line2": {
                        "anyOf": [
                          {
                            "type": "string",
                            "maxLength": 200
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "description": "A second address line, or `null` when there is none."
                      },
                      "postal_code": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 20,
                        "description": "Postal or ZIP code."
                      },
                      "city": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 100,
                        "description": "City or town."
                      },
                      "country": {
                        "type": "string",
                        "pattern": "^[A-Z]{2}$",
                        "description": "The billing country. Decides VAT (`vatFor`) and currency (`currencyForCountry`)."
                      }
                    },
                    "required": [
                      "line1",
                      "line2",
                      "postal_code",
                      "city",
                      "country"
                    ],
                    "additionalProperties": false,
                    "description": "The billing address."
                  },
                  "invoice_email": {
                    "type": "string",
                    "format": "email",
                    "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                    "description": "Where invoices and billing mail are sent."
                  }
                },
                "required": [
                  "kind",
                  "full_name",
                  "address",
                  "invoice_email"
                ],
                "additionalProperties": false,
                "description": "A person: a full name, never a VAT ID — only a company can be VAT-registered."
              }
            ],
            "description": "Who is paying, and where the invoice goes."
          },
          "accept_terms": {
            "type": "boolean",
            "const": true,
            "description": "Confirms Fleetless's terms of service. Always required."
          },
          "accept_withdrawal": {
            "description": "Confirms the plan starts at once and the 14-day right of withdrawal ends with it. Required for a person; a company sending it is `400 validation_error` — it has no withdrawal right to confirm.",
            "type": "boolean",
            "const": true
          }
        },
        "required": [
          "plan",
          "cycle",
          "billing",
          "accept_terms"
        ],
        "additionalProperties": false
      },
      "checkout-response": {
        "type": "object",
        "properties": {
          "checkout_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "Identifies this checkout: polled by `GET /api/billing/checkout/:id` and carried on the return URL."
          },
          "checkout_url": {
            "type": "string",
            "format": "uri",
            "description": "Mollie's hosted checkout page. The caller's browser is sent here."
          }
        },
        "required": [
          "checkout_id",
          "checkout_url"
        ],
        "additionalProperties": false
      },
      "checkout-status": {
        "type": "object",
        "properties": {
          "checkout_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The checkout this status is for."
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "paid",
              "failed",
              "canceled",
              "expired"
            ],
            "description": "Mollie's payment status, as `reconcilePayment` last read it."
          },
          "purpose": {
            "type": "string",
            "enum": [
              "upgrade",
              "payment_method",
              "invoice"
            ],
            "description": "What this checkout paid for: a plan upgrade, a payment-method change, or an open invoice."
          },
          "plan": {
            "type": "string",
            "enum": [
              "basic",
              "plus",
              "pro",
              "enterprise"
            ],
            "description": "The org's plan after applying — unchanged unless `purpose` is `upgrade` and `status` is `paid`."
          }
        },
        "required": [
          "checkout_id",
          "status",
          "purpose",
          "plan"
        ],
        "additionalProperties": false
      },
      "client-accept-invitation-request": {
        "type": "object",
        "properties": {
          "token": {
            "type": "string",
            "minLength": 1,
            "description": "The opaque token from the invitation link, valid seven days. Unknown, expired, revoked and already-accepted all answer `410 token_spent`."
          },
          "password": {
            "description": "The password the new account will use, at least 12 characters. **Required while the app's password method is on, refused while it is off** — both as `400 validation_error` naming `password`. An email-code-only app accepts invitations without one.",
            "type": "string",
            "minLength": 12,
            "maxLength": 256
          },
          "display_name": {
            "description": "An optional name, overriding whatever the invitation pre-filled.",
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "token"
        ],
        "additionalProperties": false
      },
      "client-identity": {
        "type": "object",
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "developer",
              "app_user",
              "server_key"
            ],
            "description": "Which of the three kinds of caller this is: a `developer` working through the console, an `app_user` holding a token from a client login, or a `server_key` used by server-side code. Stated outright, not inferred from which id is set."
          },
          "developer_id": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The Fleetless user behind this session, or `null` when `kind` is not `developer`."
          },
          "app_user_id": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The app user behind this session, or `null` when `kind` is not `app_user`. An app user belongs to exactly one app and is unrelated to any Fleetless user with the same address."
          },
          "server_key_id": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The server key this session was authenticated with, or `null` when `kind` is not `server_key`."
          },
          "app_id": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The app this session belongs to, and `null` for a developer — a developer is organisation-scoped and owns the configuration of every robot in the organisation rather than reaching one through an app."
          },
          "role_id": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The role that decides what this caller may reach, and `null` for a developer. Roles are the only visibility filter: what a role does not grant does not exist for that user."
          },
          "email": {
            "anyOf": [
              {
                "type": "string",
                "format": "email",
                "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The address of the Fleetless user or app user behind this session, and `null` for a server key, which is not a person."
          },
          "two_factor_enabled": {
            "anyOf": [
              {
                "type": "boolean"
              },
              {
                "type": "null"
              }
            ],
            "description": "Whether the app user has a confirmed authenticator, so the app's account settings can offer to turn it on or off. `null` unless `kind` is `app_user`."
          }
        },
        "required": [
          "kind",
          "developer_id",
          "app_user_id",
          "server_key_id",
          "app_id",
          "role_id",
          "email",
          "two_factor_enabled"
        ],
        "additionalProperties": false
      },
      "client-login-code-request": {
        "type": "object",
        "properties": {
          "app_identifier": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
            "description": "The app to sign in to. An identifier no app carries is `404 not_found`; the address is never the subject of a refusal."
          },
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address to mail the code to, trimmed and compared case-insensitively. `202` whether or not it names an account of this app."
          }
        },
        "required": [
          "app_identifier",
          "email"
        ],
        "additionalProperties": false
      },
      "client-login-code-verify-request": {
        "type": "object",
        "properties": {
          "app_identifier": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
            "description": "The app the code was requested for."
          },
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address the code was mailed to, as typed when it was requested; trimmed and compared case-insensitively."
          },
          "code": {
            "type": "string",
            "pattern": "^\\d{6}$",
            "description": "The six digits from the mail, exactly — leading zeros included, no spaces."
          }
        },
        "required": [
          "app_identifier",
          "email",
          "code"
        ],
        "additionalProperties": false
      },
      "client-login-request": {
        "type": "object",
        "properties": {
          "app_identifier": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
            "description": "The app being logged in to: its globally unique, lowercase, underscore-separated identifier, chosen by the developer at creation. There is no organisation context at login, so this is what decides which app the credentials are checked for."
          },
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The app user's address. Addresses are unique **per app**, not across Fleetless: the same address may be an unrelated account in another app of the same organisation, so this pair is what identifies a person here."
          },
          "password": {
            "type": "string",
            "minLength": 1,
            "description": "The app user's password. A wrong pair is refused without saying which half was wrong — and a `blocked` or not-yet-verified account is refused identically, so a failed login is not an account-enumeration oracle in any of its three forms."
          }
        },
        "required": [
          "app_identifier",
          "email",
          "password"
        ]
      },
      "client-logout-request": {
        "type": "object",
        "properties": {
          "refresh_token": {
            "type": "string",
            "minLength": 1,
            "description": "Any refresh token of the session to end. The whole token family is revoked server-side, so a token stolen before this call stops working too — clearing a client-side store is a gesture, not a revocation. The answer is `204`: a token the server does not recognise gets it too, since that is the end state being asked for."
          }
        },
        "required": [
          "refresh_token"
        ]
      },
      "client-mcp-interaction": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The interaction, as it arrived in the app's `mcp_login_url`. Not a credential: it names a pending request the server already holds, and approving it needs the app user's own access token."
          },
          "app_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The app this authorization is for. The approving token's `app_id` must match it — an interaction of one app cannot be approved with a session from another."
          },
          "client_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "What the MCP client calls itself, or `null` if it named nothing. **Unverified** — see `client_name_verified`."
          },
          "client_name_verified": {
            "type": "boolean",
            "const": false,
            "description": "Always `false`. The client registered itself without authentication and named itself, so it must be rendered as a claim and never as an identity. There is no verified case, which is why this is a literal and not a boolean: a `true` branch would be dead code that looked like a safeguard."
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The scopes the client asked for, to show the person before they approve."
          },
          "already_granted": {
            "type": "boolean",
            "description": "Whether this user has already approved this client. It is a record of what they answered last time, and **this route makes no second use of it**: an app that skips its own consent screen when this is `true` is the only thing deciding that, and approve succeeds identically for a user who holds no grant at all. The standing grant is read elsewhere, on every request to the app's MCP endpoint. Withdrawing it is `DELETE /api/client/mcp/grants/:clientId` for the person themselves and `DELETE /api/apps/:id/users/:userId/mcp-grants/:clientId` for the developer. A withdrawal makes this `false` again at the next authorization **and stops the client at its very next MCP call**, unexpired access token and all — up to fifteen minutes of it — because the endpoint keys that check on the `client_id` the token carries."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the interaction stops being approvable. Ten minutes from the authorize step; afterwards both approve and deny answer `interaction_expired`."
          }
        },
        "required": [
          "id",
          "app_id",
          "client_name",
          "client_name_verified",
          "scopes",
          "already_granted",
          "expires_at"
        ],
        "additionalProperties": false
      },
      "client-mcp-interaction-decision-response": {
        "type": "object",
        "properties": {
          "redirect_to": {
            "type": "string",
            "format": "uri",
            "description": "Send the browser here. It is the MCP client's own callback, carrying either the authorization code or `error=access_denied` — a denial redirects as well, so the client learns the outcome from the place it is waiting."
          }
        },
        "required": [
          "redirect_to"
        ],
        "additionalProperties": false
      },
      "client-oidc-exchange-request": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "minLength": 1,
            "description": "The one-time code from the callback redirect. Valid 60 seconds, single-use, and bound to the PKCE challenge the start step carried."
          },
          "code_verifier": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_.~-]{43,128}$",
            "description": "The verifier for the challenge sent at `start`. RFC 7636 §4.1's alphabet and length."
          }
        },
        "required": [
          "code",
          "code_verifier"
        ],
        "additionalProperties": false
      },
      "client-password-reset-confirm-request": {
        "type": "object",
        "properties": {
          "token": {
            "type": "string",
            "minLength": 1,
            "description": "The opaque token from the reset link, valid one hour. Single-use; unknown, expired and spent all answer `410 token_spent`."
          },
          "new_password": {
            "type": "string",
            "minLength": 12,
            "maxLength": 256,
            "description": "The replacement password. Accepting it revokes every refresh family the account holds — the answer carries a fresh pair, so the person is signed in on the device that completed the reset and nowhere else."
          }
        },
        "required": [
          "token",
          "new_password"
        ],
        "additionalProperties": false
      },
      "client-password-reset-request": {
        "type": "object",
        "properties": {
          "app_identifier": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
            "description": "The app the address belongs to."
          },
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address to mail a reset link to. The answer is `202` for a known address and an unknown one alike, in status, body and timing. An app identifier no app carries is `404 not_found`; the address is never the subject of a refusal."
          }
        },
        "required": [
          "app_identifier",
          "email"
        ],
        "additionalProperties": false
      },
      "client-provider-list-response": {
        "type": "object",
        "properties": {
          "providers": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "slug": {
                  "type": "string",
                  "maxLength": 40,
                  "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$",
                  "description": "The handle to put in the start URL: `GET /api/client/oidc/<slug>/start`."
                },
                "name": {
                  "type": "string",
                  "description": "What to write on the button, as the developer configured it."
                }
              },
              "required": [
                "slug",
                "name"
              ],
              "additionalProperties": false
            },
            "description": "The app's **enabled** providers, slug and display name only. An app with none answers an empty array, which is the state of an app that offers password or code sign-in alone."
          },
          "sign_in_methods": {
            "type": "object",
            "properties": {
              "password": {
                "type": "boolean",
                "description": "Whether app users may sign in with a password. Off refuses `POST /api/client/login` with `method_not_allowed`, and registration and invitations then take no password."
              },
              "email_code": {
                "type": "boolean",
                "description": "Whether app users may sign in with a six-digit code mailed to them, valid ten minutes. A code needs no URL, so it works in local development and in an app with no web UI."
              }
            },
            "required": [
              "password",
              "email_code"
            ],
            "additionalProperties": false,
            "description": "Which of password and emailed code the app accepts, so its sign-in page draws the right fields without guessing. The same value the developer set; public, like the provider buttons."
          }
        },
        "required": [
          "providers",
          "sign_in_methods"
        ],
        "additionalProperties": false
      },
      "client-refresh-request": {
        "type": "object",
        "properties": {
          "refresh_token": {
            "type": "string",
            "minLength": 1,
            "description": "The refresh token from the last login or refresh. Refresh tokens rotate on every use, so the value sent here is spent — keep the one that comes back, and presenting a spent one is treated as theft and ends the whole family."
          }
        },
        "required": [
          "refresh_token"
        ]
      },
      "client-register-request": {
        "type": "object",
        "properties": {
          "app_identifier": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
            "description": "The app to register with. An identifier no app carries is `404 not_found` — an identifier is public, so naming it is no disclosure, and collapsing it into `registration_closed` sent a developer who mistyped their own identifier hunting a configuration bug that was not there. The **address** is never the subject of a refusal."
          },
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address to register. Unique per app, case-insensitively. An address this app already knows still answers `202`, without a mail — the answer may not say whether an account exists."
          },
          "password": {
            "description": "The password for the new account, at least 12 characters. **Required while the app's password method is on, refused while it is off** — both as `400 validation_error` naming `password`. An email-code-only app registers people without one.",
            "type": "string",
            "minLength": 12,
            "maxLength": 256
          },
          "display_name": {
            "description": "An optional human name for the account. The developer's own UI decides whether to ask for it.",
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "app_identifier",
          "email"
        ],
        "additionalProperties": false
      },
      "client-resend-verification-request": {
        "type": "object",
        "properties": {
          "app_identifier": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
            "description": "The app the address belongs to."
          },
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address to re-send to. The answer is `202` whether or not it names an account, and whether or not that account is already verified."
          }
        },
        "required": [
          "app_identifier",
          "email"
        ],
        "additionalProperties": false
      },
      "client-robot-list-response": {
        "type": "object",
        "properties": {
          "robots": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The robot, and what every robot-scoped route takes as its `:id`."
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 63,
                  "description": "The robot's display name, at most 63 characters. Free text, changed through `PATCH /api/robots/:id`."
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the robot was created, as an ISO 8601 timestamp."
                },
                "bridge_state": {
                  "type": "object",
                  "properties": {
                    "online": {
                      "type": "boolean"
                    },
                    "latency_ms": {
                      "anyOf": [
                        {
                          "type": "number",
                          "minimum": 0
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "low_bandwidth": {
                      "type": "boolean",
                      "description": "Whether the bridge is in its low-bandwidth mode: datapoints capped, cameras reduced or stopped. Bridge-reported."
                    }
                  },
                  "required": [
                    "online",
                    "latency_ms",
                    "low_bandwidth"
                  ],
                  "additionalProperties": false,
                  "description": "The built-in `bridge_state` datapoint as the cloud observes it right now: whether the bridge is connected, and its latency when it is."
                },
                "published_version": {
                  "anyOf": [
                    {
                      "type": "integer",
                      "exclusiveMinimum": 0,
                      "maximum": 9007199254740991
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "The published configuration version, or `null` when nothing has been published yet. A robot with nothing published is still listed — \"not configured yet\" is a real state, and the caller is entitled to it — and its datasheet answers an empty exposure list."
                }
              },
              "required": [
                "id",
                "name",
                "created_at",
                "bridge_state",
                "published_version"
              ],
              "additionalProperties": false
            },
            "description": "Every robot the caller reaches, in name order with the id as the tiebreak. An app user reaches the robots their app attaches on which their role grants at least one slug or capability; a server key reaches every robot its app attaches; a developer reaches every robot of the organisation."
          }
        },
        "required": [
          "robots"
        ],
        "additionalProperties": false
      },
      "client-sign-in-result": {
        "anyOf": [
          {
            "type": "object",
            "properties": {
              "access_token": {
                "type": "string",
                "minLength": 1,
                "description": "The token to send as `Authorization: Bearer <token>` on every call. Short-lived: read `expires_in` rather than assuming a lifetime."
              },
              "refresh_token": {
                "type": "string",
                "minLength": 1,
                "description": "The token that buys the next access token. It rotates on every use, so a value presented twice is detectable theft and ends the whole family."
              },
              "expires_in": {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991,
                "description": "How long the access token stays valid, in **seconds** from now. Not a timestamp, and not milliseconds."
              }
            },
            "required": [
              "access_token",
              "refresh_token",
              "expires_in"
            ],
            "additionalProperties": false
          },
          {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "two_factor_required",
                  "two_factor_setup_required"
                ],
                "description": "`two_factor_required`: ask for the authenticator code. `two_factor_setup_required`: the app requires two-factor and the person has none yet, so set one up before any session exists."
              },
              "challenge": {
                "type": "string",
                "minLength": 1,
                "description": "The handle the next step spends. Valid five minutes; afterwards it answers `410 token_spent` and the sign-in starts over."
              }
            },
            "required": [
              "status",
              "challenge"
            ],
            "additionalProperties": false
          }
        ]
      },
      "client-two-factor-disable-request": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "pattern": "^\\d{6}$",
            "description": "A code the authenticator shows now."
          }
        },
        "required": [
          "code"
        ],
        "additionalProperties": false
      },
      "client-two-factor-setup-confirm-request": {
        "type": "object",
        "properties": {
          "challenge": {
            "description": "The same challenge as at `setup`, during sign-in; absent with a bearer.",
            "type": "string",
            "minLength": 1
          },
          "code": {
            "type": "string",
            "pattern": "^\\d{6}$",
            "description": "A code the new authenticator shows now. It proves the secret was copied correctly before anything depends on it."
          }
        },
        "required": [
          "code"
        ],
        "additionalProperties": false
      },
      "client-two-factor-setup-confirm-response": {
        "type": "object",
        "properties": {
          "recovery_codes": {
            "minItems": 10,
            "maxItems": 10,
            "type": "array",
            "items": {
              "type": "string",
              "pattern": "^[a-z2-7]{5}-[a-z2-7]{5}$"
            },
            "description": "The ten single-use recovery codes, lowercase, shown once. Any earlier set is void."
          },
          "session": {
            "type": "object",
            "properties": {
              "access_token": {
                "type": "string",
                "minLength": 1,
                "description": "The token to send as `Authorization: Bearer <token>` on every call. Short-lived: read `expires_in` rather than assuming a lifetime."
              },
              "refresh_token": {
                "type": "string",
                "minLength": 1,
                "description": "The token that buys the next access token. It rotates on every use, so a value presented twice is detectable theft and ends the whole family."
              },
              "expires_in": {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991,
                "description": "How long the access token stays valid, in **seconds** from now. Not a timestamp, and not milliseconds."
              }
            },
            "required": [
              "access_token",
              "refresh_token",
              "expires_in"
            ],
            "additionalProperties": false,
            "description": "The session the sign-in was waiting for, or a fresh one for the account settings."
          }
        },
        "required": [
          "recovery_codes",
          "session"
        ],
        "additionalProperties": false
      },
      "client-two-factor-setup-request": {
        "type": "object",
        "properties": {
          "challenge": {
            "description": "The `two_factor_setup_required` challenge, during sign-in. Absent when the call carries the app user's bearer instead.",
            "type": "string",
            "minLength": 1
          }
        },
        "additionalProperties": false
      },
      "client-two-factor-verify-request": {
        "type": "object",
        "properties": {
          "challenge": {
            "type": "string",
            "minLength": 1,
            "description": "The challenge the sign-in step answered."
          },
          "code": {
            "description": "The six-digit code the authenticator shows now. A code already accepted once is refused, so a replay of a seen code does not sign anybody in.",
            "type": "string",
            "pattern": "^\\d{6}$"
          },
          "recovery_code": {
            "description": "One of the ten recovery codes, `xxxxx-xxxxx`, in either case. Spent by its use.",
            "type": "string",
            "pattern": "^[a-zA-Z2-7]{5}-[a-zA-Z2-7]{5}$"
          }
        },
        "required": [
          "challenge"
        ],
        "additionalProperties": false
      },
      "client-verify-email-request": {
        "type": "object",
        "properties": {
          "token": {
            "type": "string",
            "minLength": 1,
            "description": "The opaque token from the verification link, valid 24 hours. Unknown, expired and already-spent all answer `410 token_spent` — telling them apart would say whether a token ever existed."
          }
        },
        "required": [
          "token"
        ],
        "additionalProperties": false
      },
      "config-draft-response": {
        "type": "object",
        "properties": {
          "doc": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "fleetless": {
                    "type": "number",
                    "const": 1,
                    "description": "The format version, and the first line of the file. It decides how everything below is read, so a file that omits it — or names a version this cloud does not know — is **refused rather than half understood**."
                  },
                  "messages": {
                    "type": "object",
                    "propertyNames": {
                      "type": "string",
                      "minLength": 2,
                      "maxLength": 63,
                      "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                    },
                    "additionalProperties": {
                      "description": "The message as it will be sent, written out in full: literals are fixed, `${name}` is a hole a caller fills, and a field written `0.0` is one no client can change. Directly after `message:` a `${name}` standing alone names a shared message instead; anywhere inside a body it is a parameter. `null` is refused **at every depth** — omitting a key is the only spelling of \"not set\"."
                    },
                    "description": "Reusable message bodies, keyed by name, inserted elsewhere by writing `${name}` directly after `message:`. A shared body may hold placeholders and whoever inserts it declares the parameters, so two publishers can send the same message under different bounds. **A shared message may not insert another**, so a `${name}` inside a body is always a parameter and never a second message."
                  },
                  "datapoints": {
                    "type": "object",
                    "propertyNames": {
                      "type": "string",
                      "minLength": 2,
                      "maxLength": 63,
                      "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                    },
                    "additionalProperties": {
                      "type": "object",
                      "properties": {
                        "topic": {
                          "type": "string",
                          "maxLength": 255,
                          "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$",
                          "description": "The ROS topic this datapoint reads, as an absolute graph name. One datapoint reads **one** topic: a value assembled from two topics is not expressible here.",
                          "examples": [
                            "/battery"
                          ]
                        },
                        "type": {
                          "type": "string",
                          "maxLength": 255,
                          "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$",
                          "description": "The message type carried by `topic`, spelled the way ROS 2 spells it, with the `msg` segment in the middle — `sensor_msgs/msg/BatteryState`, never `sensor_msgs/BatteryState`. It is declared here rather than discovered, so a configuration can be written for a robot that has never been connected; the cloud checks it against the robot's own message definitions only once one is there.",
                          "examples": [
                            "sensor_msgs/msg/BatteryState"
                          ]
                        },
                        "field": {
                          "type": "string",
                          "maxLength": 255,
                          "pattern": "^[a-z_][a-z0-9_]*(?:\\[\\d+\\])?(?:\\.[a-z_][a-z0-9_]*(?:\\[\\d+\\])?)*$",
                          "description": "A dotted path into the message naming the single value this datapoint carries, each segment indexing at most one array level — `ranges[0]`, never `ranges[0][1]`, because ROS 2 has no nested arrays. Without it the datapoint is the whole message, and `numeric`, `chart` and `alerts` are then refused.",
                          "examples": [
                            "voltage",
                            "pose.position.x",
                            "ranges[0]"
                          ]
                        },
                        "rate_throttle_hz": {
                          "type": "number",
                          "minimum": 0,
                          "maximum": 20,
                          "description": "A ceiling on how often this datapoint is sent, in hertz. Omitted or `0` means no throttling. It is **a ceiling, not a clock**: a slow topic stays slow, a value is never repeated to manufacture a rate, and within a window the newest value wins. The bridge enforces it, so the robot's bandwidth is genuinely saved.",
                          "examples": [
                            2,
                            0.5
                          ]
                        },
                        "low_bandwidth": {
                          "description": "`keep` exempts this datapoint from the low-bandwidth rate cap; its backfill still pauses.",
                          "type": "string",
                          "const": "keep"
                        },
                        "description": {
                          "description": "Prose about what this value is, for whoever meets it in the console. It changes nothing the robot does, so a publish that touches only it pushes no configuration at all — but it is carried verbatim into `robot_describe`, where a model that has never seen this robot reads it. The datapoint is offered whenever the role grants it; without one it is offered with `description: null` and the model has less to go on, as for actions, services, publishers and cameras. Omission is the only way to say nothing; an empty string is refused, here and on all five.",
                          "examples": [
                            "What this value is, for whoever meets it in the console."
                          ],
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 2000
                        },
                        "numeric": {
                          "type": "object",
                          "properties": {
                            "scale": {
                              "type": "number",
                              "description": "A factor the robot multiplies the raw value by before sending it (`value * scale + offset`). The arithmetic happens once, at the source, so REST, realtime and history can never disagree about a number.",
                              "examples": [
                                100
                              ]
                            },
                            "offset": {
                              "type": "number",
                              "description": "A constant the robot adds after `scale` (`value * scale + offset`), for a value whose zero sits in the wrong place. Like `scale` it is applied before sending, so history stores the converted value and a later correction cannot reach what is already stored.",
                              "examples": [
                                -273.15
                              ]
                            },
                            "unit": {
                              "type": "string",
                              "maxLength": 32,
                              "description": "The unit of the value **after** `scale` and `offset`, not the robot's own. It is shown beside the value and carried by `robot_describe` as its own field, so a model does not have to guess whether 15 means percent, volts or minutes.",
                              "examples": [
                                "%"
                              ]
                            },
                            "decimals": {
                              "type": "integer",
                              "minimum": 0,
                              "maximum": 6,
                              "description": "How many fraction digits the console shows the value with — value tile, chart axis and tooltip, and the datapoint detail page — and the number `robot_describe` reports as its own field, so a model formats the value the way the console does. Presentation only: the stored value keeps the precision it arrived with, and absent means the console's own default rather than zero.",
                              "examples": [
                                1
                              ]
                            }
                          },
                          "additionalProperties": false,
                          "description": "Arithmetic and formatting for a numeric value. `scale` and `offset` are applied **on the robot**, before sending, which is why REST, realtime and history all carry identical numbers. `unit` and `decimals` change nothing the robot does, so a publish that touches only those pushes no configuration."
                        },
                        "retention": {
                          "type": "object",
                          "properties": {
                            "enabled": {
                              "type": "boolean",
                              "description": "Whether values are written to the time series and become queryable. Off by default: without it the value is live only, and nobody who was not watching will ever see it."
                            },
                            "interval_seconds": {
                              "type": "integer",
                              "minimum": 1,
                              "maximum": 3600,
                              "description": "How often a value is written to history, in seconds; absent means `300`. **Not** how often it is sent — that is `rate_throttle_hz`. Stored points are billed, so this is the direct lever on what a robot costs, and a bumper that is true for 200 ms does not appear unless a write falls inside it.",
                              "examples": [
                                300,
                                60
                              ]
                            },
                            "max_buffer_values": {
                              "type": "integer",
                              "minimum": 1,
                              "maximum": 100000,
                              "description": "How many values the robot holds while the bridge is disconnected, to be pushed once it reconnects. The catch-up runs behind live telemetry and job results at a limited rate, so closing a gap never delays the present; without it the series simply has a gap, which is an honest answer.",
                              "examples": [
                                5000
                              ]
                            }
                          },
                          "additionalProperties": false,
                          "description": "What outlives the moment: whether this value is written to the time series, how often, and how many points the robot buffers while the bridge is away. Absent means no history at all — the value is live only."
                        },
                        "chart": {
                          "type": "object",
                          "properties": {
                            "y_min": {
                              "type": "number",
                              "description": "A fixed floor for the chart's y axis; omitted, the axis scales to the data. `0` is a real floor and is read as `0`, never as unset.",
                              "examples": [
                                0
                              ]
                            },
                            "y_max": {
                              "type": "number",
                              "description": "A fixed ceiling for the chart's y axis; omitted, the axis scales to the data. It may not sit below `y_min`: a reversed pair is refused here because nothing downstream catches it, and the chart would render empty.",
                              "examples": [
                                100
                              ]
                            },
                            "style": {
                              "type": "string",
                              "enum": [
                                "line",
                                "step"
                              ],
                              "description": "How the drawing joins two samples, which is not a matter of taste. `line` claims the value moved evenly between them, roughly true of a temperature or a charge; `step` holds and then jumps, the only honest drawing for a mode, a switch or a counter, where a straight line would show values that never existed."
                            },
                            "default_window_minutes": {
                              "type": "integer",
                              "minimum": 1,
                              "maximum": 43200,
                              "description": "How far back the chart reaches when it is first opened, in minutes; absent means `60`. Only the starting zoom: a viewer may look further, and nothing about what is stored follows from it.",
                              "examples": [
                                1440
                              ]
                            }
                          },
                          "additionalProperties": false,
                          "description": "How the console draws this value over time: axis bounds, whether the line interpolates or steps, and the window a chart opens on. **Display only** — it changes no stored value, no alert and nothing the robot does, so a publish that touches only it pushes no configuration."
                        },
                        "alerts": {
                          "type": "object",
                          "propertyNames": {
                            "type": "string",
                            "minLength": 2,
                            "maxLength": 63,
                            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                          },
                          "additionalProperties": {
                            "type": "object",
                            "properties": {
                              "condition": {
                                "type": "object",
                                "properties": {
                                  "fire_at": {
                                    "anyOf": [
                                      {
                                        "type": "number"
                                      },
                                      {
                                        "type": "string"
                                      },
                                      {
                                        "type": "boolean"
                                      }
                                    ],
                                    "description": "The value at which the alert starts firing. Alone it is an **equality**: it fires while the value equals `fire_at` and is ok again as soon as it differs, which is what makes a boolean or a string condition meaningful. Adding `resolve_at` turns it into a threshold instead.",
                                    "examples": [
                                      15,
                                      true
                                    ]
                                  },
                                  "resolve_at": {
                                    "type": "number",
                                    "description": "The value at which a firing alert becomes ok again — allowed only when `fire_at` is a number, and it **must differ from it**. That gap is the hysteresis, and it makes the condition a threshold whose direction follows from which of the two values is higher. Without a gap a value sitting on the line flips on every sample.",
                                    "examples": [
                                      18
                                    ]
                                  }
                                },
                                "required": [
                                  "fire_at"
                                ],
                                "additionalProperties": false,
                                "description": "When this alert fires and when it is ok again. It carries **no discriminator**: upper threshold, lower threshold or equality all follow from the two values in it. Editing it resets the alert to `ok` on the next publish, while a publish that leaves it untouched keeps the running state."
                              },
                              "severity": {
                                "type": "string",
                                "enum": [
                                  "warning",
                                  "error"
                                ],
                                "description": "How bad it is when this alert fires; absent means `warning`. It changes no behaviour — nothing is escalated, retried or delivered differently — it travels with the org event and colours the alert wherever it is shown."
                              },
                              "name": {
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 120,
                                "description": "A human-readable label shown wherever this alert appears, in place of its bare key. It is not the alert's identity — the key is — so the label can be reworded freely, while changing the key deletes one alert and creates another.",
                                "examples": [
                                  "Battery low"
                                ]
                              },
                              "enabled": {
                                "type": "boolean",
                                "description": "Whether this alert is evaluated at all. Absent means on, the opposite of `retention.enabled`: an alert that is written down watches unless it is explicitly switched off, which is how one is silenced without losing the key that identifies it."
                              }
                            },
                            "required": [
                              "condition"
                            ],
                            "additionalProperties": false
                          },
                          "description": "Alerts watching this value, keyed by slug; each moves between `ok` and `firing` and writes an org event on every transition. No mail is sent. **The key is the identity**, so renaming an alert is a delete plus a create: its runtime state is lost, and an alert that is still true fires again."
                        }
                      },
                      "required": [
                        "topic",
                        "type"
                      ],
                      "additionalProperties": false
                    },
                    "description": "Values the robot publishes, each one field of one topic or a whole topic, and **never several topics**. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all three are refused when the document is validated."
                  },
                  "actions": {
                    "type": "object",
                    "propertyNames": {
                      "type": "string",
                      "minLength": 2,
                      "maxLength": 63,
                      "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                    },
                    "additionalProperties": {
                      "type": "object",
                      "properties": {
                        "ros_name": {
                          "type": "string",
                          "maxLength": 255,
                          "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$",
                          "description": "The action server on the robot, as an absolute graph name — this is what the bridge sends the goal to. Clients never see it: they address this entry by its slug, so a server can be renamed on the robot without a single app changing.",
                          "examples": [
                            "/navigate_to_pose"
                          ]
                        },
                        "type": {
                          "type": "string",
                          "maxLength": 255,
                          "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$",
                          "description": "The action type `ros_name` implements, with the `action` segment in the middle — `nav2_msgs/action/NavigateToPose`, never `nav2_msgs/NavigateToPose`. Declared rather than introspected, so an action can be configured for a robot that has never connected; the cloud checks it against the robot's own definitions only once one is there.",
                          "examples": [
                            "nav2_msgs/action/NavigateToPose"
                          ]
                        },
                        "message": {
                          "description": "The message as it will be sent, written out in full: literals are fixed, `${name}` is a hole a caller fills, and a field written `0.0` is one no client can change. Directly after `message:` a `${name}` standing alone names a shared message instead; anywhere inside a body it is a parameter. `null` is refused **at every depth** — omitting a key is the only spelling of \"not set\"."
                        },
                        "parameters": {
                          "type": "object",
                          "propertyNames": {
                            "type": "string",
                            "minLength": 2,
                            "maxLength": 63,
                            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                          },
                          "additionalProperties": {
                            "type": "object",
                            "properties": {
                              "type": {
                                "type": "string",
                                "enum": [
                                  "bool",
                                  "byte",
                                  "char",
                                  "int8",
                                  "uint8",
                                  "int16",
                                  "uint16",
                                  "int32",
                                  "uint32",
                                  "int64",
                                  "uint64",
                                  "float32",
                                  "float64",
                                  "string",
                                  "wstring"
                                ],
                                "description": "The ROS 2 primitive a value of this parameter must be, spelled the way ROS 2 spells it — `float64`, not `double`. It **decides which other constraints are allowed at all**: `min_value` and `max_value` need a numeric type, `regex` needs a string one, and a constraint on the wrong type is refused rather than quietly ignored."
                              },
                              "default": {
                                "anyOf": [
                                  {
                                    "type": "number"
                                  },
                                  {
                                    "type": "string"
                                  },
                                  {
                                    "type": "boolean"
                                  }
                                ],
                                "description": "The value used when a caller omits this parameter: **without a `default` the parameter is required**, because the message cannot be built without it. It must itself satisfy `min_value`, `max_value`, `enum` and `regex` — a default the constraints reject is refused here rather than becoming the one value that reaches the robot unchecked."
                              },
                              "min_value": {
                                "type": "number",
                                "description": "The lowest value a caller may send; numeric types only. It is **enforced in the cloud, before anything reaches the robot** — this is where a speed limit actually holds, rather than in the app that is supposed to respect it.",
                                "examples": [
                                  -0.5
                                ]
                              },
                              "max_value": {
                                "type": "number",
                                "description": "The highest value a caller may send; numeric types only, and it may not sit below `min_value`. A reversed pair is refused at parse time, because nothing downstream catches it and every call would then fail against a bound no value can satisfy.",
                                "examples": [
                                  0.5
                                ]
                              },
                              "enum": {
                                "minItems": 1,
                                "type": "array",
                                "items": {
                                  "anyOf": [
                                    {
                                      "type": "string"
                                    },
                                    {
                                      "type": "number"
                                    }
                                  ]
                                },
                                "description": "The complete set of values a caller may send. Integer and string types only — **never a float**, because equality on floating point is unreliable and an enumerated float list is a trap that only shows up in operation. Every entry must match `type`, and a `default` must be one of them."
                              },
                              "regex": {
                                "type": "string",
                                "minLength": 1,
                                "description": "A pattern the value must match; string types only. It is compiled as a JavaScript regular expression and is **not anchored**, so `[a-z]+` accepts any value that merely contains a lowercase run — a pattern meant to cover the whole value writes its own `^` and `$`.",
                                "examples": [
                                  "^[a-z_]+$"
                                ]
                              },
                              "description": {
                                "description": "What this parameter means, in the developer's own words, and documentation only — the robot does nothing with it. It travels into the input schema `robot_describe` publishes for this call, beside the bounds, so `type` and the range say what the value *is* and this is the only place that says what it *does*.",
                                "examples": [
                                  "What a caller is choosing when they set this."
                                ],
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 500
                              }
                            },
                            "required": [
                              "type"
                            ],
                            "additionalProperties": false
                          },
                          "description": "The holes in this entry's `message` that a caller fills, keyed by **parameter name** rather than by field path — so the name survives the field moving inside the message, and a caller sends something that means what it says. Every declared parameter must appear somewhere in the message and every `${name}` in the message must be declared; either half alone is an error."
                        },
                        "description": {
                          "description": "What this action does, in the developer's own words — documentation for the console and for MCP clients, which is all it is: the robot does nothing with it. It is carried verbatim into `robot_describe` and read by a model that has never seen this robot. The action is offered whenever the role grants it; without one it is offered with `description: null`, and the model has nothing but the slug.",
                          "examples": [
                            "Drives to a target pose on the map."
                          ],
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 2000
                        }
                      },
                      "required": [
                        "ros_name",
                        "type"
                      ],
                      "additionalProperties": false
                    },
                    "description": "Things the robot does on request that take time, each reported as a job with progress. **At most one job runs per action slug**: a second call is refused `busy`, and every observer of that slug watches the same job. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all three are refused when the document is validated."
                  },
                  "services": {
                    "type": "object",
                    "propertyNames": {
                      "type": "string",
                      "minLength": 2,
                      "maxLength": 63,
                      "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                    },
                    "additionalProperties": {
                      "type": "object",
                      "properties": {
                        "ros_name": {
                          "type": "string",
                          "maxLength": 255,
                          "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$",
                          "description": "The ROS service the robot answers on, as an absolute graph name. The call is one request and one reply with no progress in between, so whatever this service does has to finish inside that reply; anything long-running belongs in `actions`.",
                          "examples": [
                            "/reset_odometry"
                          ]
                        },
                        "type": {
                          "type": "string",
                          "maxLength": 255,
                          "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$",
                          "description": "The service type `ros_name` implements, with the `srv` segment in the middle — `std_srvs/srv/Trigger`. A type whose request has no fields, like `Trigger`, needs neither `message` nor `parameters`: there is nothing to fill.",
                          "examples": [
                            "std_srvs/srv/Trigger"
                          ]
                        },
                        "message": {
                          "description": "The message as it will be sent, written out in full: literals are fixed, `${name}` is a hole a caller fills, and a field written `0.0` is one no client can change. Directly after `message:` a `${name}` standing alone names a shared message instead; anywhere inside a body it is a parameter. `null` is refused **at every depth** — omitting a key is the only spelling of \"not set\"."
                        },
                        "parameters": {
                          "type": "object",
                          "propertyNames": {
                            "type": "string",
                            "minLength": 2,
                            "maxLength": 63,
                            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                          },
                          "additionalProperties": {
                            "type": "object",
                            "properties": {
                              "type": {
                                "type": "string",
                                "enum": [
                                  "bool",
                                  "byte",
                                  "char",
                                  "int8",
                                  "uint8",
                                  "int16",
                                  "uint16",
                                  "int32",
                                  "uint32",
                                  "int64",
                                  "uint64",
                                  "float32",
                                  "float64",
                                  "string",
                                  "wstring"
                                ],
                                "description": "The ROS 2 primitive a value of this parameter must be, spelled the way ROS 2 spells it — `float64`, not `double`. It **decides which other constraints are allowed at all**: `min_value` and `max_value` need a numeric type, `regex` needs a string one, and a constraint on the wrong type is refused rather than quietly ignored."
                              },
                              "default": {
                                "anyOf": [
                                  {
                                    "type": "number"
                                  },
                                  {
                                    "type": "string"
                                  },
                                  {
                                    "type": "boolean"
                                  }
                                ],
                                "description": "The value used when a caller omits this parameter: **without a `default` the parameter is required**, because the message cannot be built without it. It must itself satisfy `min_value`, `max_value`, `enum` and `regex` — a default the constraints reject is refused here rather than becoming the one value that reaches the robot unchecked."
                              },
                              "min_value": {
                                "type": "number",
                                "description": "The lowest value a caller may send; numeric types only. It is **enforced in the cloud, before anything reaches the robot** — this is where a speed limit actually holds, rather than in the app that is supposed to respect it.",
                                "examples": [
                                  -0.5
                                ]
                              },
                              "max_value": {
                                "type": "number",
                                "description": "The highest value a caller may send; numeric types only, and it may not sit below `min_value`. A reversed pair is refused at parse time, because nothing downstream catches it and every call would then fail against a bound no value can satisfy.",
                                "examples": [
                                  0.5
                                ]
                              },
                              "enum": {
                                "minItems": 1,
                                "type": "array",
                                "items": {
                                  "anyOf": [
                                    {
                                      "type": "string"
                                    },
                                    {
                                      "type": "number"
                                    }
                                  ]
                                },
                                "description": "The complete set of values a caller may send. Integer and string types only — **never a float**, because equality on floating point is unreliable and an enumerated float list is a trap that only shows up in operation. Every entry must match `type`, and a `default` must be one of them."
                              },
                              "regex": {
                                "type": "string",
                                "minLength": 1,
                                "description": "A pattern the value must match; string types only. It is compiled as a JavaScript regular expression and is **not anchored**, so `[a-z]+` accepts any value that merely contains a lowercase run — a pattern meant to cover the whole value writes its own `^` and `$`.",
                                "examples": [
                                  "^[a-z_]+$"
                                ]
                              },
                              "description": {
                                "description": "What this parameter means, in the developer's own words, and documentation only — the robot does nothing with it. It travels into the input schema `robot_describe` publishes for this call, beside the bounds, so `type` and the range say what the value *is* and this is the only place that says what it *does*.",
                                "examples": [
                                  "What a caller is choosing when they set this."
                                ],
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 500
                              }
                            },
                            "required": [
                              "type"
                            ],
                            "additionalProperties": false
                          },
                          "description": "The holes in this entry's `message` that a caller fills, keyed by **parameter name** rather than by field path — so the name survives the field moving inside the message, and a caller sends something that means what it says. Every declared parameter must appear somewhere in the message and every `${name}` in the message must be declared; either half alone is an error."
                        },
                        "description": {
                          "description": "What this service does, in the developer's own words. The robot does nothing with it — the readers are the console and MCP clients, and without one the service is still offered, with `description: null`, exactly as for an action. It sits on the configuration rather than on the app, so one wording is true for every app that reaches this robot.",
                          "examples": [
                            "Resets odometry to the origin."
                          ],
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 2000
                        }
                      },
                      "required": [
                        "ros_name",
                        "type"
                      ],
                      "additionalProperties": false
                    },
                    "description": "ROS service calls the robot answers — one request, one reply. Unlike an action a service reports **no progress** and the call returns with its result already on the job, so there is nothing left to observe; a second concurrent call is still refused `busy`, exactly as for an action. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all three are refused when the document is validated."
                  },
                  "publishers": {
                    "type": "object",
                    "propertyNames": {
                      "type": "string",
                      "minLength": 2,
                      "maxLength": 63,
                      "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                    },
                    "additionalProperties": {
                      "type": "object",
                      "properties": {
                        "topic": {
                          "type": "string",
                          "maxLength": 255,
                          "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$",
                          "description": "The ROS topic the message is published onto, as an absolute graph name. **No client ever names a topic**: a caller addresses this entry by its slug, so the topics an app can write to are exactly the ones written in this file.",
                          "examples": [
                            "/cmd_vel"
                          ]
                        },
                        "type": {
                          "type": "string",
                          "maxLength": 255,
                          "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$",
                          "description": "The message type of `topic`, spelled the way ROS 2 spells it, with the `msg` segment. It fixes the shape that `message` and `failsafe.message` must both fill, which is why one publisher carries one type and a second type needs a second publisher.",
                          "examples": [
                            "geometry_msgs/msg/Twist"
                          ]
                        },
                        "message": {
                          "description": "The message as it will be sent, written out in full: literals are fixed, `${name}` is a hole a caller fills, and a field written `0.0` is one no client can change. Directly after `message:` a `${name}` standing alone names a shared message instead; anywhere inside a body it is a parameter. `null` is refused **at every depth** — omitting a key is the only spelling of \"not set\"."
                        },
                        "parameters": {
                          "type": "object",
                          "propertyNames": {
                            "type": "string",
                            "minLength": 2,
                            "maxLength": 63,
                            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                          },
                          "additionalProperties": {
                            "type": "object",
                            "properties": {
                              "type": {
                                "type": "string",
                                "enum": [
                                  "bool",
                                  "byte",
                                  "char",
                                  "int8",
                                  "uint8",
                                  "int16",
                                  "uint16",
                                  "int32",
                                  "uint32",
                                  "int64",
                                  "uint64",
                                  "float32",
                                  "float64",
                                  "string",
                                  "wstring"
                                ],
                                "description": "The ROS 2 primitive a value of this parameter must be, spelled the way ROS 2 spells it — `float64`, not `double`. It **decides which other constraints are allowed at all**: `min_value` and `max_value` need a numeric type, `regex` needs a string one, and a constraint on the wrong type is refused rather than quietly ignored."
                              },
                              "default": {
                                "anyOf": [
                                  {
                                    "type": "number"
                                  },
                                  {
                                    "type": "string"
                                  },
                                  {
                                    "type": "boolean"
                                  }
                                ],
                                "description": "The value used when a caller omits this parameter: **without a `default` the parameter is required**, because the message cannot be built without it. It must itself satisfy `min_value`, `max_value`, `enum` and `regex` — a default the constraints reject is refused here rather than becoming the one value that reaches the robot unchecked."
                              },
                              "min_value": {
                                "type": "number",
                                "description": "The lowest value a caller may send; numeric types only. It is **enforced in the cloud, before anything reaches the robot** — this is where a speed limit actually holds, rather than in the app that is supposed to respect it.",
                                "examples": [
                                  -0.5
                                ]
                              },
                              "max_value": {
                                "type": "number",
                                "description": "The highest value a caller may send; numeric types only, and it may not sit below `min_value`. A reversed pair is refused at parse time, because nothing downstream catches it and every call would then fail against a bound no value can satisfy.",
                                "examples": [
                                  0.5
                                ]
                              },
                              "enum": {
                                "minItems": 1,
                                "type": "array",
                                "items": {
                                  "anyOf": [
                                    {
                                      "type": "string"
                                    },
                                    {
                                      "type": "number"
                                    }
                                  ]
                                },
                                "description": "The complete set of values a caller may send. Integer and string types only — **never a float**, because equality on floating point is unreliable and an enumerated float list is a trap that only shows up in operation. Every entry must match `type`, and a `default` must be one of them."
                              },
                              "regex": {
                                "type": "string",
                                "minLength": 1,
                                "description": "A pattern the value must match; string types only. It is compiled as a JavaScript regular expression and is **not anchored**, so `[a-z]+` accepts any value that merely contains a lowercase run — a pattern meant to cover the whole value writes its own `^` and `$`.",
                                "examples": [
                                  "^[a-z_]+$"
                                ]
                              },
                              "description": {
                                "description": "What this parameter means, in the developer's own words, and documentation only — the robot does nothing with it. It travels into the input schema `robot_describe` publishes for this call, beside the bounds, so `type` and the range say what the value *is* and this is the only place that says what it *does*.",
                                "examples": [
                                  "What a caller is choosing when they set this."
                                ],
                                "type": "string",
                                "minLength": 1,
                                "maxLength": 500
                              }
                            },
                            "required": [
                              "type"
                            ],
                            "additionalProperties": false
                          },
                          "description": "The holes in this entry's `message` that a caller fills, keyed by **parameter name** rather than by field path — so the name survives the field moving inside the message, and a caller sends something that means what it says. Every declared parameter must appear somewhere in the message and every `${name}` in the message must be declared; either half alone is an error."
                        },
                        "failsafe": {
                          "type": "object",
                          "properties": {
                            "timeout_ms": {
                              "type": "integer",
                              "exclusiveMinimum": 0,
                              "maximum": 60000,
                              "description": "How long the bridge waits for the client's next send before sending the failsafe message itself, in milliseconds. The deadline runs **on the robot**, so it still fires when the link to the cloud is what failed — which is the case it exists for.",
                              "examples": [
                                500,
                                1000
                              ]
                            },
                            "message": {
                              "description": "What the bridge sends once `timeout_ms` runs out — for a drive command, a zero twist. It must be safe in **every** state, because it is sent precisely when nobody is watching any more, and it may hold no placeholder: there is no caller left to fill one."
                            }
                          },
                          "required": [
                            "timeout_ms",
                            "message"
                          ],
                          "additionalProperties": false,
                          "description": "What the bridge sends **by itself** once a client stops sending, and how long it waits first. This is the format's safety story in one field: a client that crashes, loses its connection or whose operator closes the window does not leave a robot driving. The message may hold no placeholder, inline or through a shared message — there is nobody left to fill one."
                        },
                        "quiet_timeout_ms": {
                          "type": "integer",
                          "minimum": 0,
                          "maximum": 600000,
                          "description": "How long this publisher must stay silent before a **different** user may send to it. Whoever sends holds it implicitly exclusive, with no session and no lock, so this one number is the whole handover policy: too short and two operators fight over one robot, too long and a crashed client blocks it for everyone.",
                          "examples": [
                            2000
                          ]
                        },
                        "description": {
                          "description": "What sending to this publisher does, in the developer's own words. It is documentation for the console and for MCP clients — the robot does nothing with it — and as for actions and services, the publisher is offered whether or not one is written, with `description: null` when it is not. A caller sends here repeatedly and continuously rather than once, which is why this kind alone carries `failsafe` and `quiet_timeout_ms`.",
                          "examples": [
                            "Velocity command. If sending stops, the robot stops."
                          ],
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 2000
                        }
                      },
                      "required": [
                        "topic",
                        "type",
                        "message",
                        "failsafe",
                        "quiet_timeout_ms"
                      ],
                      "additionalProperties": false
                    },
                    "description": "Topics clients may send to, and where the format's whole safety story lives. The `message` template fixes every value a caller cannot change, and **`failsafe` is required**: once a client falls silent the bridge sends the failsafe message itself, so an operator whose window closed does not leave a robot driving. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all three are refused when the document is validated."
                  },
                  "cameras": {
                    "type": "object",
                    "propertyNames": {
                      "type": "string",
                      "minLength": 2,
                      "maxLength": 63,
                      "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                    },
                    "additionalProperties": {
                      "type": "object",
                      "properties": {
                        "source": {
                          "oneOf": [
                            {
                              "type": "object",
                              "properties": {
                                "kind": {
                                  "type": "string",
                                  "const": "ros",
                                  "description": "Selects the ROS image-topic source: this camera then carries `topic` and `type`, and no field of another kind."
                                },
                                "topic": {
                                  "type": "string",
                                  "maxLength": 255,
                                  "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$",
                                  "description": "The ROS image topic the bridge subscribes to, as an absolute graph name. Clients never name it — they address the camera by its slug — so the topic can be renamed on the robot without an app changing.",
                                  "examples": [
                                    "/camera/image_raw"
                                  ]
                                },
                                "type": {
                                  "type": "string",
                                  "maxLength": 255,
                                  "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$",
                                  "description": "The message type of `topic`: `sensor_msgs/msg/Image` for raw frames, `sensor_msgs/msg/CompressedImage` for a camera that already encodes. Declared here rather than introspected, so a camera can be configured for a robot that has never connected.",
                                  "examples": [
                                    "sensor_msgs/msg/Image"
                                  ]
                                }
                              },
                              "required": [
                                "kind",
                                "topic",
                                "type"
                              ],
                              "additionalProperties": false,
                              "description": "Frames come from an image topic the robot already publishes. It is the only source the bridge **subscribes** to rather than opens, so it needs no URL, no device and nobody to authenticate to."
                            },
                            {
                              "type": "object",
                              "properties": {
                                "kind": {
                                  "type": "string",
                                  "const": "rtsp",
                                  "description": "Selects the RTSP source: this camera then carries `url`, and optionally `transport` and `credentials`."
                                },
                                "url": {
                                  "type": "string",
                                  "minLength": 1,
                                  "maxLength": 2048,
                                  "pattern": "^rtsps?:\\/\\/",
                                  "description": "Where the stream lives, reached from the robot rather than from the cloud. **`rtsp://` or `rtsps://` only** — the bridge opens this with a library that would equally honour `file:`, so an unconstrained URL would turn a configuration document into arbitrary file access on the robot. The bridge re-checks the scheme itself, so a validator that changed could not make a robot serve files.",
                                  "examples": [
                                    "rtsp://cam-1.plant.local/stream1"
                                  ]
                                },
                                "transport": {
                                  "description": "How the RTSP payload is carried. Omitted means `tcp`: `udp` loses frames on a congested link and loses them silently, so the result looks like a failing camera rather than like a choice made here.",
                                  "type": "string",
                                  "enum": [
                                    "tcp",
                                    "udp"
                                  ]
                                },
                                "credentials": {
                                  "type": "object",
                                  "properties": {
                                    "username": {
                                      "description": "The account name the camera expects. For MJPEG the bridge sends a real HTTP `Authorization: Basic` header and leaves the URL untouched. RTSP offers no such channel through ffmpeg, so there the name goes inside the connect URL instead — built fresh for that one call and never written back into the stored document.",
                                      "examples": [
                                        "ops"
                                      ],
                                      "type": "string",
                                      "minLength": 1,
                                      "maxLength": 128
                                    },
                                    "password": {
                                      "description": "The password for `username`. **There is no secret store behind this**: the value written here is the value stored, so treat it as readable by everyone who may read this robot's configuration, now and in its history.",
                                      "type": "string",
                                      "minLength": 1,
                                      "maxLength": 128
                                    }
                                  },
                                  "additionalProperties": false,
                                  "description": "Username and password for the stream, standing **in clear text in the document**. A published version is immutable, so a password here cannot be removed from history or rotated without republishing — which is why the publish audit event carries only the version number and never the document body. Userinfo in the `url` works too; an explicit block here wins over it."
                                }
                              },
                              "required": [
                                "kind",
                                "url"
                              ],
                              "additionalProperties": false,
                              "description": "Frames come from an RTSP stream the robot itself can reach — a network camera on its own LAN. The bridge opens the connection; the cloud never does, and never needs a route to the camera."
                            },
                            {
                              "type": "object",
                              "properties": {
                                "kind": {
                                  "type": "string",
                                  "const": "mjpeg",
                                  "description": "Selects the MJPEG-over-HTTP source: this camera then carries `url`, and optionally `credentials`."
                                },
                                "url": {
                                  "type": "string",
                                  "minLength": 1,
                                  "maxLength": 2048,
                                  "pattern": "^https?:\\/\\/",
                                  "description": "Where the stream lives. **`http://` or `https://` only** — as for the `rtsp` URL, the bridge opens it with a library that would also serve `file:`. Plain `http://` is permitted because these cameras usually sit on the robot's own network, but Basic credentials on such a URL then travel in the clear.",
                                  "examples": [
                                    "http://cam-1.plant.local/video.mjpg"
                                  ]
                                },
                                "credentials": {
                                  "type": "object",
                                  "properties": {
                                    "username": {
                                      "description": "The account name the camera expects. For MJPEG the bridge sends a real HTTP `Authorization: Basic` header and leaves the URL untouched. RTSP offers no such channel through ffmpeg, so there the name goes inside the connect URL instead — built fresh for that one call and never written back into the stored document.",
                                      "examples": [
                                        "ops"
                                      ],
                                      "type": "string",
                                      "minLength": 1,
                                      "maxLength": 128
                                    },
                                    "password": {
                                      "description": "The password for `username`. **There is no secret store behind this**: the value written here is the value stored, so treat it as readable by everyone who may read this robot's configuration, now and in its history.",
                                      "type": "string",
                                      "minLength": 1,
                                      "maxLength": 128
                                    }
                                  },
                                  "additionalProperties": false,
                                  "description": "Username and password for the stream, standing **in clear text in the document**. A published version is immutable, so a password here cannot be removed from history or rotated without republishing — which is why the publish audit event carries only the version number and never the document body. Userinfo in the `url` works too; an explicit block here wins over it."
                                }
                              },
                              "required": [
                                "kind",
                                "url"
                              ],
                              "additionalProperties": false,
                              "description": "Frames come from an MJPEG stream over HTTP — one JPEG after another, the simplest network source there is. Unlike `rtsp` there is no `transport` to choose: it is HTTP, and any `credentials` therefore travel as HTTP Basic."
                            },
                            {
                              "type": "object",
                              "properties": {
                                "kind": {
                                  "type": "string",
                                  "const": "v4l2",
                                  "description": "Selects the local capture-device source: this camera then carries `device` and nothing else."
                                },
                                "device": {
                                  "type": "string",
                                  "minLength": 1,
                                  "maxLength": 128,
                                  "pattern": "^\\/dev\\/[A-Za-z0-9][A-Za-z0-9._/-]*$",
                                  "description": "The capture device, resolved on the robot and never by the cloud; a `/dev/v4l/by-id/...` symlink survives a reboot that renumbers `/dev/video0`. **Constrained to `/dev/`** — the string reaches OpenCV, which will just as happily open an ordinary video file or an `http://` URL and publish its pixels to the cloud. The bridge re-derives the same constraint rather than trusting the wire.",
                                  "examples": [
                                    "/dev/video0"
                                  ]
                                }
                              },
                              "required": [
                                "kind",
                                "device"
                              ],
                              "additionalProperties": false,
                              "description": "Frames come from a capture device attached to the robot itself, such as a USB camera on `/dev/video0`. Nothing leaves the robot to fetch them, and there is nothing to authenticate to, so this source takes no `credentials`."
                            }
                          ],
                          "description": "Where this camera's frames come from. `kind` picks one of four sources and fixes which other fields the source may carry, so an impossible camera is unrepresentable rather than merely invalid — there is no way to write an RTSP camera with a ROS topic."
                        },
                        "width": {
                          "type": "integer",
                          "exclusiveMinimum": 0,
                          "maximum": 7680,
                          "description": "The width the bridge scales frames to before sending, in pixels — what the bridge produces, not what the sensor captures; a snapshot can arrive narrower, since the bridge reduces both dimensions together to fit its JPEG byte ceiling. It stands in the configuration and never in a viewer's request, so no client can make the robot encode a larger frame than the developer allowed.",
                          "examples": [
                            1280
                          ]
                        },
                        "height": {
                          "type": "integer",
                          "exclusiveMinimum": 0,
                          "maximum": 4320,
                          "description": "The height the bridge scales every frame to, in pixels; with `width` it is the size the live stream carries. A snapshot can arrive **smaller** than this — its JPEG has a byte ceiling, and the bridge gives up quality first and then resolution to fit, reporting the size it actually encoded.",
                          "examples": [
                            720
                          ]
                        },
                        "fps": {
                          "type": "integer",
                          "exclusiveMinimum": 0,
                          "maximum": 60,
                          "description": "How many frames a second the bridge forwards, at most. It is a ceiling, not a clock: a camera that delivers ten frames a second stays at ten. Both modes read the same throttled pipeline, so this also bounds how fresh a snapshot can be.",
                          "examples": [
                            15
                          ]
                        },
                        "bitrate_kbps": {
                          "type": "integer",
                          "exclusiveMinimum": 0,
                          "maximum": 50000,
                          "description": "The ceiling for the **live** encoding, in kilobits per second — this is what bounds a watched camera against the robot's uplink. Snapshots are not covered by it: they are JPEGs under their own byte ceiling. Raising `width`, `height` or `fps` against a fixed bitrate buys blur, not detail.",
                          "examples": [
                            2000
                          ]
                        },
                        "snapshot_interval_seconds": {
                          "type": "integer",
                          "minimum": 1,
                          "maximum": 3600,
                          "description": "How often a still frame is captured, in seconds. **It runs whether or not anyone is watching**, unlike the live stream, which the cloud refcounts — first viewer starts it, last one ends it. The cloud caches the one frame and serves every reader from it, so a hundred pollers cost the robot exactly one image per interval.",
                          "examples": [
                            5
                          ]
                        },
                        "description": {
                          "description": "What this camera shows, in the developer's own words — documentation for whoever reads the configuration, for the console and for MCP clients; the robot does nothing with it. A camera without one is still offered, with `description: null`, as for actions, services and publishers. What `camera_snapshot` serves is the latest snapshot with its age; a live session is never a tool.",
                          "examples": [
                            "Forward-facing camera on the mast."
                          ],
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 2000
                        }
                      },
                      "required": [
                        "source",
                        "width",
                        "height",
                        "fps",
                        "bitrate_kbps",
                        "snapshot_interval_seconds"
                      ],
                      "additionalProperties": false
                    },
                    "description": "Video the robot streams, and the still frames the cloud serves from it. `width`, `height`, `fps` and `bitrate_kbps` are what **the bridge produces before sending**, not what the camera captures — they live in the configuration rather than in a viewer's request precisely so that no viewer can make a robot send more. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all three are refused when the document is validated."
                  },
                  "low_bandwidth": {
                    "description": "Overrides for the bridge's low-bandwidth mode; see the section schema.",
                    "type": "object",
                    "properties": {
                      "mode": {
                        "description": "`auto` decides from the measured lag; `on` and `off` force the mode, for tests and for an operator who knows the link.",
                        "type": "string",
                        "enum": [
                          "auto",
                          "on",
                          "off"
                        ]
                      },
                      "enter_lag_ms": {
                        "description": "Lag or queue dwell above this enters the mode. Checked against exit_lag_ms only when both are in this document; a lone key composes with the bridge's parameter or the default on the robot, and a crossed pair is refused there when the configuration is applied, so name both when you change either.",
                        "type": "integer",
                        "minimum": 100,
                        "maximum": 9007199254740991
                      },
                      "enter_after_s": {
                        "description": "The entry condition must hold this long.",
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 9007199254740991
                      },
                      "exit_lag_ms": {
                        "description": "Lag and dwell both at or below this leave the mode. Must be at or below enter_lag_ms: a crossed pair is a mode that leaves as it arrives. Checked here only when both keys are present; a lone key is checked on the robot against the parameter or default it composes with.",
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 9007199254740991
                      },
                      "exit_after_s": {
                        "description": "The exit condition must hold this long.",
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 9007199254740991
                      },
                      "datapoint_max_hz": {
                        "description": "The long-run rate for every datapoint in the mode, unless the datapoint says `low_bandwidth: keep`. It is an average, not a minimum gap: after a quiet spell two samples may go out close together, and over any longer window the rate holds.",
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 20
                      },
                      "camera": {
                        "description": "What happens to a running stream in the mode. New streams are refused either way.",
                        "type": "string",
                        "enum": [
                          "reduce",
                          "stop"
                        ]
                      },
                      "camera_bitrate_kbps": {
                        "description": "Bitrate applied to running streams under `reduce`.",
                        "type": "integer",
                        "minimum": 50,
                        "maximum": 20000
                      }
                    },
                    "additionalProperties": false
                  }
                },
                "required": [
                  "fleetless"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ]
          },
          "source": {
            "type": "string"
          },
          "updated_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time",
                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
              },
              {
                "type": "null"
              }
            ]
          },
          "issues": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "path": {
                  "type": "string",
                  "minLength": 1
                },
                "slug": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "code": {
                  "type": "string",
                  "minLength": 1
                },
                "message": {
                  "type": "string",
                  "minLength": 1
                },
                "severity": {
                  "type": "string",
                  "enum": [
                    "error",
                    "warning"
                  ]
                }
              },
              "required": [
                "path",
                "slug",
                "code",
                "message",
                "severity"
              ],
              "additionalProperties": false
            }
          }
        },
        "required": [
          "doc",
          "source",
          "updated_at",
          "issues"
        ],
        "additionalProperties": false
      },
      "config-version-response": {
        "type": "object",
        "properties": {
          "version": {
            "type": "integer",
            "exclusiveMinimum": 0,
            "maximum": 9007199254740991
          },
          "published_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
          },
          "doc": {
            "type": "object",
            "properties": {
              "fleetless": {
                "type": "number",
                "const": 1,
                "description": "The format version, and the first line of the file. It decides how everything below is read, so a file that omits it — or names a version this cloud does not know — is **refused rather than half understood**."
              },
              "messages": {
                "type": "object",
                "propertyNames": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                },
                "additionalProperties": {
                  "description": "The message as it will be sent, written out in full: literals are fixed, `${name}` is a hole a caller fills, and a field written `0.0` is one no client can change. Directly after `message:` a `${name}` standing alone names a shared message instead; anywhere inside a body it is a parameter. `null` is refused **at every depth** — omitting a key is the only spelling of \"not set\"."
                },
                "description": "Reusable message bodies, keyed by name, inserted elsewhere by writing `${name}` directly after `message:`. A shared body may hold placeholders and whoever inserts it declares the parameters, so two publishers can send the same message under different bounds. **A shared message may not insert another**, so a `${name}` inside a body is always a parameter and never a second message."
              },
              "datapoints": {
                "type": "object",
                "propertyNames": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                },
                "additionalProperties": {
                  "type": "object",
                  "properties": {
                    "topic": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$",
                      "description": "The ROS topic this datapoint reads, as an absolute graph name. One datapoint reads **one** topic: a value assembled from two topics is not expressible here.",
                      "examples": [
                        "/battery"
                      ]
                    },
                    "type": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$",
                      "description": "The message type carried by `topic`, spelled the way ROS 2 spells it, with the `msg` segment in the middle — `sensor_msgs/msg/BatteryState`, never `sensor_msgs/BatteryState`. It is declared here rather than discovered, so a configuration can be written for a robot that has never been connected; the cloud checks it against the robot's own message definitions only once one is there.",
                      "examples": [
                        "sensor_msgs/msg/BatteryState"
                      ]
                    },
                    "field": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z_][a-z0-9_]*(?:\\[\\d+\\])?(?:\\.[a-z_][a-z0-9_]*(?:\\[\\d+\\])?)*$",
                      "description": "A dotted path into the message naming the single value this datapoint carries, each segment indexing at most one array level — `ranges[0]`, never `ranges[0][1]`, because ROS 2 has no nested arrays. Without it the datapoint is the whole message, and `numeric`, `chart` and `alerts` are then refused.",
                      "examples": [
                        "voltage",
                        "pose.position.x",
                        "ranges[0]"
                      ]
                    },
                    "rate_throttle_hz": {
                      "type": "number",
                      "minimum": 0,
                      "maximum": 20,
                      "description": "A ceiling on how often this datapoint is sent, in hertz. Omitted or `0` means no throttling. It is **a ceiling, not a clock**: a slow topic stays slow, a value is never repeated to manufacture a rate, and within a window the newest value wins. The bridge enforces it, so the robot's bandwidth is genuinely saved.",
                      "examples": [
                        2,
                        0.5
                      ]
                    },
                    "low_bandwidth": {
                      "description": "`keep` exempts this datapoint from the low-bandwidth rate cap; its backfill still pauses.",
                      "type": "string",
                      "const": "keep"
                    },
                    "description": {
                      "description": "Prose about what this value is, for whoever meets it in the console. It changes nothing the robot does, so a publish that touches only it pushes no configuration at all — but it is carried verbatim into `robot_describe`, where a model that has never seen this robot reads it. The datapoint is offered whenever the role grants it; without one it is offered with `description: null` and the model has less to go on, as for actions, services, publishers and cameras. Omission is the only way to say nothing; an empty string is refused, here and on all five.",
                      "examples": [
                        "What this value is, for whoever meets it in the console."
                      ],
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 2000
                    },
                    "numeric": {
                      "type": "object",
                      "properties": {
                        "scale": {
                          "type": "number",
                          "description": "A factor the robot multiplies the raw value by before sending it (`value * scale + offset`). The arithmetic happens once, at the source, so REST, realtime and history can never disagree about a number.",
                          "examples": [
                            100
                          ]
                        },
                        "offset": {
                          "type": "number",
                          "description": "A constant the robot adds after `scale` (`value * scale + offset`), for a value whose zero sits in the wrong place. Like `scale` it is applied before sending, so history stores the converted value and a later correction cannot reach what is already stored.",
                          "examples": [
                            -273.15
                          ]
                        },
                        "unit": {
                          "type": "string",
                          "maxLength": 32,
                          "description": "The unit of the value **after** `scale` and `offset`, not the robot's own. It is shown beside the value and carried by `robot_describe` as its own field, so a model does not have to guess whether 15 means percent, volts or minutes.",
                          "examples": [
                            "%"
                          ]
                        },
                        "decimals": {
                          "type": "integer",
                          "minimum": 0,
                          "maximum": 6,
                          "description": "How many fraction digits the console shows the value with — value tile, chart axis and tooltip, and the datapoint detail page — and the number `robot_describe` reports as its own field, so a model formats the value the way the console does. Presentation only: the stored value keeps the precision it arrived with, and absent means the console's own default rather than zero.",
                          "examples": [
                            1
                          ]
                        }
                      },
                      "additionalProperties": false,
                      "description": "Arithmetic and formatting for a numeric value. `scale` and `offset` are applied **on the robot**, before sending, which is why REST, realtime and history all carry identical numbers. `unit` and `decimals` change nothing the robot does, so a publish that touches only those pushes no configuration."
                    },
                    "retention": {
                      "type": "object",
                      "properties": {
                        "enabled": {
                          "type": "boolean",
                          "description": "Whether values are written to the time series and become queryable. Off by default: without it the value is live only, and nobody who was not watching will ever see it."
                        },
                        "interval_seconds": {
                          "type": "integer",
                          "minimum": 1,
                          "maximum": 3600,
                          "description": "How often a value is written to history, in seconds; absent means `300`. **Not** how often it is sent — that is `rate_throttle_hz`. Stored points are billed, so this is the direct lever on what a robot costs, and a bumper that is true for 200 ms does not appear unless a write falls inside it.",
                          "examples": [
                            300,
                            60
                          ]
                        },
                        "max_buffer_values": {
                          "type": "integer",
                          "minimum": 1,
                          "maximum": 100000,
                          "description": "How many values the robot holds while the bridge is disconnected, to be pushed once it reconnects. The catch-up runs behind live telemetry and job results at a limited rate, so closing a gap never delays the present; without it the series simply has a gap, which is an honest answer.",
                          "examples": [
                            5000
                          ]
                        }
                      },
                      "additionalProperties": false,
                      "description": "What outlives the moment: whether this value is written to the time series, how often, and how many points the robot buffers while the bridge is away. Absent means no history at all — the value is live only."
                    },
                    "chart": {
                      "type": "object",
                      "properties": {
                        "y_min": {
                          "type": "number",
                          "description": "A fixed floor for the chart's y axis; omitted, the axis scales to the data. `0` is a real floor and is read as `0`, never as unset.",
                          "examples": [
                            0
                          ]
                        },
                        "y_max": {
                          "type": "number",
                          "description": "A fixed ceiling for the chart's y axis; omitted, the axis scales to the data. It may not sit below `y_min`: a reversed pair is refused here because nothing downstream catches it, and the chart would render empty.",
                          "examples": [
                            100
                          ]
                        },
                        "style": {
                          "type": "string",
                          "enum": [
                            "line",
                            "step"
                          ],
                          "description": "How the drawing joins two samples, which is not a matter of taste. `line` claims the value moved evenly between them, roughly true of a temperature or a charge; `step` holds and then jumps, the only honest drawing for a mode, a switch or a counter, where a straight line would show values that never existed."
                        },
                        "default_window_minutes": {
                          "type": "integer",
                          "minimum": 1,
                          "maximum": 43200,
                          "description": "How far back the chart reaches when it is first opened, in minutes; absent means `60`. Only the starting zoom: a viewer may look further, and nothing about what is stored follows from it.",
                          "examples": [
                            1440
                          ]
                        }
                      },
                      "additionalProperties": false,
                      "description": "How the console draws this value over time: axis bounds, whether the line interpolates or steps, and the window a chart opens on. **Display only** — it changes no stored value, no alert and nothing the robot does, so a publish that touches only it pushes no configuration."
                    },
                    "alerts": {
                      "type": "object",
                      "propertyNames": {
                        "type": "string",
                        "minLength": 2,
                        "maxLength": 63,
                        "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                      },
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "condition": {
                            "type": "object",
                            "properties": {
                              "fire_at": {
                                "anyOf": [
                                  {
                                    "type": "number"
                                  },
                                  {
                                    "type": "string"
                                  },
                                  {
                                    "type": "boolean"
                                  }
                                ],
                                "description": "The value at which the alert starts firing. Alone it is an **equality**: it fires while the value equals `fire_at` and is ok again as soon as it differs, which is what makes a boolean or a string condition meaningful. Adding `resolve_at` turns it into a threshold instead.",
                                "examples": [
                                  15,
                                  true
                                ]
                              },
                              "resolve_at": {
                                "type": "number",
                                "description": "The value at which a firing alert becomes ok again — allowed only when `fire_at` is a number, and it **must differ from it**. That gap is the hysteresis, and it makes the condition a threshold whose direction follows from which of the two values is higher. Without a gap a value sitting on the line flips on every sample.",
                                "examples": [
                                  18
                                ]
                              }
                            },
                            "required": [
                              "fire_at"
                            ],
                            "additionalProperties": false,
                            "description": "When this alert fires and when it is ok again. It carries **no discriminator**: upper threshold, lower threshold or equality all follow from the two values in it. Editing it resets the alert to `ok` on the next publish, while a publish that leaves it untouched keeps the running state."
                          },
                          "severity": {
                            "type": "string",
                            "enum": [
                              "warning",
                              "error"
                            ],
                            "description": "How bad it is when this alert fires; absent means `warning`. It changes no behaviour — nothing is escalated, retried or delivered differently — it travels with the org event and colours the alert wherever it is shown."
                          },
                          "name": {
                            "type": "string",
                            "minLength": 1,
                            "maxLength": 120,
                            "description": "A human-readable label shown wherever this alert appears, in place of its bare key. It is not the alert's identity — the key is — so the label can be reworded freely, while changing the key deletes one alert and creates another.",
                            "examples": [
                              "Battery low"
                            ]
                          },
                          "enabled": {
                            "type": "boolean",
                            "description": "Whether this alert is evaluated at all. Absent means on, the opposite of `retention.enabled`: an alert that is written down watches unless it is explicitly switched off, which is how one is silenced without losing the key that identifies it."
                          }
                        },
                        "required": [
                          "condition"
                        ],
                        "additionalProperties": false
                      },
                      "description": "Alerts watching this value, keyed by slug; each moves between `ok` and `firing` and writes an org event on every transition. No mail is sent. **The key is the identity**, so renaming an alert is a delete plus a create: its runtime state is lost, and an alert that is still true fires again."
                    }
                  },
                  "required": [
                    "topic",
                    "type"
                  ],
                  "additionalProperties": false
                },
                "description": "Values the robot publishes, each one field of one topic or a whole topic, and **never several topics**. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all three are refused when the document is validated."
              },
              "actions": {
                "type": "object",
                "propertyNames": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                },
                "additionalProperties": {
                  "type": "object",
                  "properties": {
                    "ros_name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$",
                      "description": "The action server on the robot, as an absolute graph name — this is what the bridge sends the goal to. Clients never see it: they address this entry by its slug, so a server can be renamed on the robot without a single app changing.",
                      "examples": [
                        "/navigate_to_pose"
                      ]
                    },
                    "type": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$",
                      "description": "The action type `ros_name` implements, with the `action` segment in the middle — `nav2_msgs/action/NavigateToPose`, never `nav2_msgs/NavigateToPose`. Declared rather than introspected, so an action can be configured for a robot that has never connected; the cloud checks it against the robot's own definitions only once one is there.",
                      "examples": [
                        "nav2_msgs/action/NavigateToPose"
                      ]
                    },
                    "message": {
                      "description": "The message as it will be sent, written out in full: literals are fixed, `${name}` is a hole a caller fills, and a field written `0.0` is one no client can change. Directly after `message:` a `${name}` standing alone names a shared message instead; anywhere inside a body it is a parameter. `null` is refused **at every depth** — omitting a key is the only spelling of \"not set\"."
                    },
                    "parameters": {
                      "type": "object",
                      "propertyNames": {
                        "type": "string",
                        "minLength": 2,
                        "maxLength": 63,
                        "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                      },
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "type": {
                            "type": "string",
                            "enum": [
                              "bool",
                              "byte",
                              "char",
                              "int8",
                              "uint8",
                              "int16",
                              "uint16",
                              "int32",
                              "uint32",
                              "int64",
                              "uint64",
                              "float32",
                              "float64",
                              "string",
                              "wstring"
                            ],
                            "description": "The ROS 2 primitive a value of this parameter must be, spelled the way ROS 2 spells it — `float64`, not `double`. It **decides which other constraints are allowed at all**: `min_value` and `max_value` need a numeric type, `regex` needs a string one, and a constraint on the wrong type is refused rather than quietly ignored."
                          },
                          "default": {
                            "anyOf": [
                              {
                                "type": "number"
                              },
                              {
                                "type": "string"
                              },
                              {
                                "type": "boolean"
                              }
                            ],
                            "description": "The value used when a caller omits this parameter: **without a `default` the parameter is required**, because the message cannot be built without it. It must itself satisfy `min_value`, `max_value`, `enum` and `regex` — a default the constraints reject is refused here rather than becoming the one value that reaches the robot unchecked."
                          },
                          "min_value": {
                            "type": "number",
                            "description": "The lowest value a caller may send; numeric types only. It is **enforced in the cloud, before anything reaches the robot** — this is where a speed limit actually holds, rather than in the app that is supposed to respect it.",
                            "examples": [
                              -0.5
                            ]
                          },
                          "max_value": {
                            "type": "number",
                            "description": "The highest value a caller may send; numeric types only, and it may not sit below `min_value`. A reversed pair is refused at parse time, because nothing downstream catches it and every call would then fail against a bound no value can satisfy.",
                            "examples": [
                              0.5
                            ]
                          },
                          "enum": {
                            "minItems": 1,
                            "type": "array",
                            "items": {
                              "anyOf": [
                                {
                                  "type": "string"
                                },
                                {
                                  "type": "number"
                                }
                              ]
                            },
                            "description": "The complete set of values a caller may send. Integer and string types only — **never a float**, because equality on floating point is unreliable and an enumerated float list is a trap that only shows up in operation. Every entry must match `type`, and a `default` must be one of them."
                          },
                          "regex": {
                            "type": "string",
                            "minLength": 1,
                            "description": "A pattern the value must match; string types only. It is compiled as a JavaScript regular expression and is **not anchored**, so `[a-z]+` accepts any value that merely contains a lowercase run — a pattern meant to cover the whole value writes its own `^` and `$`.",
                            "examples": [
                              "^[a-z_]+$"
                            ]
                          },
                          "description": {
                            "description": "What this parameter means, in the developer's own words, and documentation only — the robot does nothing with it. It travels into the input schema `robot_describe` publishes for this call, beside the bounds, so `type` and the range say what the value *is* and this is the only place that says what it *does*.",
                            "examples": [
                              "What a caller is choosing when they set this."
                            ],
                            "type": "string",
                            "minLength": 1,
                            "maxLength": 500
                          }
                        },
                        "required": [
                          "type"
                        ],
                        "additionalProperties": false
                      },
                      "description": "The holes in this entry's `message` that a caller fills, keyed by **parameter name** rather than by field path — so the name survives the field moving inside the message, and a caller sends something that means what it says. Every declared parameter must appear somewhere in the message and every `${name}` in the message must be declared; either half alone is an error."
                    },
                    "description": {
                      "description": "What this action does, in the developer's own words — documentation for the console and for MCP clients, which is all it is: the robot does nothing with it. It is carried verbatim into `robot_describe` and read by a model that has never seen this robot. The action is offered whenever the role grants it; without one it is offered with `description: null`, and the model has nothing but the slug.",
                      "examples": [
                        "Drives to a target pose on the map."
                      ],
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 2000
                    }
                  },
                  "required": [
                    "ros_name",
                    "type"
                  ],
                  "additionalProperties": false
                },
                "description": "Things the robot does on request that take time, each reported as a job with progress. **At most one job runs per action slug**: a second call is refused `busy`, and every observer of that slug watches the same job. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all three are refused when the document is validated."
              },
              "services": {
                "type": "object",
                "propertyNames": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                },
                "additionalProperties": {
                  "type": "object",
                  "properties": {
                    "ros_name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$",
                      "description": "The ROS service the robot answers on, as an absolute graph name. The call is one request and one reply with no progress in between, so whatever this service does has to finish inside that reply; anything long-running belongs in `actions`.",
                      "examples": [
                        "/reset_odometry"
                      ]
                    },
                    "type": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$",
                      "description": "The service type `ros_name` implements, with the `srv` segment in the middle — `std_srvs/srv/Trigger`. A type whose request has no fields, like `Trigger`, needs neither `message` nor `parameters`: there is nothing to fill.",
                      "examples": [
                        "std_srvs/srv/Trigger"
                      ]
                    },
                    "message": {
                      "description": "The message as it will be sent, written out in full: literals are fixed, `${name}` is a hole a caller fills, and a field written `0.0` is one no client can change. Directly after `message:` a `${name}` standing alone names a shared message instead; anywhere inside a body it is a parameter. `null` is refused **at every depth** — omitting a key is the only spelling of \"not set\"."
                    },
                    "parameters": {
                      "type": "object",
                      "propertyNames": {
                        "type": "string",
                        "minLength": 2,
                        "maxLength": 63,
                        "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                      },
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "type": {
                            "type": "string",
                            "enum": [
                              "bool",
                              "byte",
                              "char",
                              "int8",
                              "uint8",
                              "int16",
                              "uint16",
                              "int32",
                              "uint32",
                              "int64",
                              "uint64",
                              "float32",
                              "float64",
                              "string",
                              "wstring"
                            ],
                            "description": "The ROS 2 primitive a value of this parameter must be, spelled the way ROS 2 spells it — `float64`, not `double`. It **decides which other constraints are allowed at all**: `min_value` and `max_value` need a numeric type, `regex` needs a string one, and a constraint on the wrong type is refused rather than quietly ignored."
                          },
                          "default": {
                            "anyOf": [
                              {
                                "type": "number"
                              },
                              {
                                "type": "string"
                              },
                              {
                                "type": "boolean"
                              }
                            ],
                            "description": "The value used when a caller omits this parameter: **without a `default` the parameter is required**, because the message cannot be built without it. It must itself satisfy `min_value`, `max_value`, `enum` and `regex` — a default the constraints reject is refused here rather than becoming the one value that reaches the robot unchecked."
                          },
                          "min_value": {
                            "type": "number",
                            "description": "The lowest value a caller may send; numeric types only. It is **enforced in the cloud, before anything reaches the robot** — this is where a speed limit actually holds, rather than in the app that is supposed to respect it.",
                            "examples": [
                              -0.5
                            ]
                          },
                          "max_value": {
                            "type": "number",
                            "description": "The highest value a caller may send; numeric types only, and it may not sit below `min_value`. A reversed pair is refused at parse time, because nothing downstream catches it and every call would then fail against a bound no value can satisfy.",
                            "examples": [
                              0.5
                            ]
                          },
                          "enum": {
                            "minItems": 1,
                            "type": "array",
                            "items": {
                              "anyOf": [
                                {
                                  "type": "string"
                                },
                                {
                                  "type": "number"
                                }
                              ]
                            },
                            "description": "The complete set of values a caller may send. Integer and string types only — **never a float**, because equality on floating point is unreliable and an enumerated float list is a trap that only shows up in operation. Every entry must match `type`, and a `default` must be one of them."
                          },
                          "regex": {
                            "type": "string",
                            "minLength": 1,
                            "description": "A pattern the value must match; string types only. It is compiled as a JavaScript regular expression and is **not anchored**, so `[a-z]+` accepts any value that merely contains a lowercase run — a pattern meant to cover the whole value writes its own `^` and `$`.",
                            "examples": [
                              "^[a-z_]+$"
                            ]
                          },
                          "description": {
                            "description": "What this parameter means, in the developer's own words, and documentation only — the robot does nothing with it. It travels into the input schema `robot_describe` publishes for this call, beside the bounds, so `type` and the range say what the value *is* and this is the only place that says what it *does*.",
                            "examples": [
                              "What a caller is choosing when they set this."
                            ],
                            "type": "string",
                            "minLength": 1,
                            "maxLength": 500
                          }
                        },
                        "required": [
                          "type"
                        ],
                        "additionalProperties": false
                      },
                      "description": "The holes in this entry's `message` that a caller fills, keyed by **parameter name** rather than by field path — so the name survives the field moving inside the message, and a caller sends something that means what it says. Every declared parameter must appear somewhere in the message and every `${name}` in the message must be declared; either half alone is an error."
                    },
                    "description": {
                      "description": "What this service does, in the developer's own words. The robot does nothing with it — the readers are the console and MCP clients, and without one the service is still offered, with `description: null`, exactly as for an action. It sits on the configuration rather than on the app, so one wording is true for every app that reaches this robot.",
                      "examples": [
                        "Resets odometry to the origin."
                      ],
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 2000
                    }
                  },
                  "required": [
                    "ros_name",
                    "type"
                  ],
                  "additionalProperties": false
                },
                "description": "ROS service calls the robot answers — one request, one reply. Unlike an action a service reports **no progress** and the call returns with its result already on the job, so there is nothing left to observe; a second concurrent call is still refused `busy`, exactly as for an action. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all three are refused when the document is validated."
              },
              "publishers": {
                "type": "object",
                "propertyNames": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                },
                "additionalProperties": {
                  "type": "object",
                  "properties": {
                    "topic": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$",
                      "description": "The ROS topic the message is published onto, as an absolute graph name. **No client ever names a topic**: a caller addresses this entry by its slug, so the topics an app can write to are exactly the ones written in this file.",
                      "examples": [
                        "/cmd_vel"
                      ]
                    },
                    "type": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$",
                      "description": "The message type of `topic`, spelled the way ROS 2 spells it, with the `msg` segment. It fixes the shape that `message` and `failsafe.message` must both fill, which is why one publisher carries one type and a second type needs a second publisher.",
                      "examples": [
                        "geometry_msgs/msg/Twist"
                      ]
                    },
                    "message": {
                      "description": "The message as it will be sent, written out in full: literals are fixed, `${name}` is a hole a caller fills, and a field written `0.0` is one no client can change. Directly after `message:` a `${name}` standing alone names a shared message instead; anywhere inside a body it is a parameter. `null` is refused **at every depth** — omitting a key is the only spelling of \"not set\"."
                    },
                    "parameters": {
                      "type": "object",
                      "propertyNames": {
                        "type": "string",
                        "minLength": 2,
                        "maxLength": 63,
                        "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                      },
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "type": {
                            "type": "string",
                            "enum": [
                              "bool",
                              "byte",
                              "char",
                              "int8",
                              "uint8",
                              "int16",
                              "uint16",
                              "int32",
                              "uint32",
                              "int64",
                              "uint64",
                              "float32",
                              "float64",
                              "string",
                              "wstring"
                            ],
                            "description": "The ROS 2 primitive a value of this parameter must be, spelled the way ROS 2 spells it — `float64`, not `double`. It **decides which other constraints are allowed at all**: `min_value` and `max_value` need a numeric type, `regex` needs a string one, and a constraint on the wrong type is refused rather than quietly ignored."
                          },
                          "default": {
                            "anyOf": [
                              {
                                "type": "number"
                              },
                              {
                                "type": "string"
                              },
                              {
                                "type": "boolean"
                              }
                            ],
                            "description": "The value used when a caller omits this parameter: **without a `default` the parameter is required**, because the message cannot be built without it. It must itself satisfy `min_value`, `max_value`, `enum` and `regex` — a default the constraints reject is refused here rather than becoming the one value that reaches the robot unchecked."
                          },
                          "min_value": {
                            "type": "number",
                            "description": "The lowest value a caller may send; numeric types only. It is **enforced in the cloud, before anything reaches the robot** — this is where a speed limit actually holds, rather than in the app that is supposed to respect it.",
                            "examples": [
                              -0.5
                            ]
                          },
                          "max_value": {
                            "type": "number",
                            "description": "The highest value a caller may send; numeric types only, and it may not sit below `min_value`. A reversed pair is refused at parse time, because nothing downstream catches it and every call would then fail against a bound no value can satisfy.",
                            "examples": [
                              0.5
                            ]
                          },
                          "enum": {
                            "minItems": 1,
                            "type": "array",
                            "items": {
                              "anyOf": [
                                {
                                  "type": "string"
                                },
                                {
                                  "type": "number"
                                }
                              ]
                            },
                            "description": "The complete set of values a caller may send. Integer and string types only — **never a float**, because equality on floating point is unreliable and an enumerated float list is a trap that only shows up in operation. Every entry must match `type`, and a `default` must be one of them."
                          },
                          "regex": {
                            "type": "string",
                            "minLength": 1,
                            "description": "A pattern the value must match; string types only. It is compiled as a JavaScript regular expression and is **not anchored**, so `[a-z]+` accepts any value that merely contains a lowercase run — a pattern meant to cover the whole value writes its own `^` and `$`.",
                            "examples": [
                              "^[a-z_]+$"
                            ]
                          },
                          "description": {
                            "description": "What this parameter means, in the developer's own words, and documentation only — the robot does nothing with it. It travels into the input schema `robot_describe` publishes for this call, beside the bounds, so `type` and the range say what the value *is* and this is the only place that says what it *does*.",
                            "examples": [
                              "What a caller is choosing when they set this."
                            ],
                            "type": "string",
                            "minLength": 1,
                            "maxLength": 500
                          }
                        },
                        "required": [
                          "type"
                        ],
                        "additionalProperties": false
                      },
                      "description": "The holes in this entry's `message` that a caller fills, keyed by **parameter name** rather than by field path — so the name survives the field moving inside the message, and a caller sends something that means what it says. Every declared parameter must appear somewhere in the message and every `${name}` in the message must be declared; either half alone is an error."
                    },
                    "failsafe": {
                      "type": "object",
                      "properties": {
                        "timeout_ms": {
                          "type": "integer",
                          "exclusiveMinimum": 0,
                          "maximum": 60000,
                          "description": "How long the bridge waits for the client's next send before sending the failsafe message itself, in milliseconds. The deadline runs **on the robot**, so it still fires when the link to the cloud is what failed — which is the case it exists for.",
                          "examples": [
                            500,
                            1000
                          ]
                        },
                        "message": {
                          "description": "What the bridge sends once `timeout_ms` runs out — for a drive command, a zero twist. It must be safe in **every** state, because it is sent precisely when nobody is watching any more, and it may hold no placeholder: there is no caller left to fill one."
                        }
                      },
                      "required": [
                        "timeout_ms",
                        "message"
                      ],
                      "additionalProperties": false,
                      "description": "What the bridge sends **by itself** once a client stops sending, and how long it waits first. This is the format's safety story in one field: a client that crashes, loses its connection or whose operator closes the window does not leave a robot driving. The message may hold no placeholder, inline or through a shared message — there is nobody left to fill one."
                    },
                    "quiet_timeout_ms": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 600000,
                      "description": "How long this publisher must stay silent before a **different** user may send to it. Whoever sends holds it implicitly exclusive, with no session and no lock, so this one number is the whole handover policy: too short and two operators fight over one robot, too long and a crashed client blocks it for everyone.",
                      "examples": [
                        2000
                      ]
                    },
                    "description": {
                      "description": "What sending to this publisher does, in the developer's own words. It is documentation for the console and for MCP clients — the robot does nothing with it — and as for actions and services, the publisher is offered whether or not one is written, with `description: null` when it is not. A caller sends here repeatedly and continuously rather than once, which is why this kind alone carries `failsafe` and `quiet_timeout_ms`.",
                      "examples": [
                        "Velocity command. If sending stops, the robot stops."
                      ],
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 2000
                    }
                  },
                  "required": [
                    "topic",
                    "type",
                    "message",
                    "failsafe",
                    "quiet_timeout_ms"
                  ],
                  "additionalProperties": false
                },
                "description": "Topics clients may send to, and where the format's whole safety story lives. The `message` template fixes every value a caller cannot change, and **`failsafe` is required**: once a client falls silent the bridge sends the failsafe message itself, so an operator whose window closed does not leave a robot driving. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all three are refused when the document is validated."
              },
              "cameras": {
                "type": "object",
                "propertyNames": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                },
                "additionalProperties": {
                  "type": "object",
                  "properties": {
                    "source": {
                      "oneOf": [
                        {
                          "type": "object",
                          "properties": {
                            "kind": {
                              "type": "string",
                              "const": "ros",
                              "description": "Selects the ROS image-topic source: this camera then carries `topic` and `type`, and no field of another kind."
                            },
                            "topic": {
                              "type": "string",
                              "maxLength": 255,
                              "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$",
                              "description": "The ROS image topic the bridge subscribes to, as an absolute graph name. Clients never name it — they address the camera by its slug — so the topic can be renamed on the robot without an app changing.",
                              "examples": [
                                "/camera/image_raw"
                              ]
                            },
                            "type": {
                              "type": "string",
                              "maxLength": 255,
                              "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$",
                              "description": "The message type of `topic`: `sensor_msgs/msg/Image` for raw frames, `sensor_msgs/msg/CompressedImage` for a camera that already encodes. Declared here rather than introspected, so a camera can be configured for a robot that has never connected.",
                              "examples": [
                                "sensor_msgs/msg/Image"
                              ]
                            }
                          },
                          "required": [
                            "kind",
                            "topic",
                            "type"
                          ],
                          "additionalProperties": false,
                          "description": "Frames come from an image topic the robot already publishes. It is the only source the bridge **subscribes** to rather than opens, so it needs no URL, no device and nobody to authenticate to."
                        },
                        {
                          "type": "object",
                          "properties": {
                            "kind": {
                              "type": "string",
                              "const": "rtsp",
                              "description": "Selects the RTSP source: this camera then carries `url`, and optionally `transport` and `credentials`."
                            },
                            "url": {
                              "type": "string",
                              "minLength": 1,
                              "maxLength": 2048,
                              "pattern": "^rtsps?:\\/\\/",
                              "description": "Where the stream lives, reached from the robot rather than from the cloud. **`rtsp://` or `rtsps://` only** — the bridge opens this with a library that would equally honour `file:`, so an unconstrained URL would turn a configuration document into arbitrary file access on the robot. The bridge re-checks the scheme itself, so a validator that changed could not make a robot serve files.",
                              "examples": [
                                "rtsp://cam-1.plant.local/stream1"
                              ]
                            },
                            "transport": {
                              "description": "How the RTSP payload is carried. Omitted means `tcp`: `udp` loses frames on a congested link and loses them silently, so the result looks like a failing camera rather than like a choice made here.",
                              "type": "string",
                              "enum": [
                                "tcp",
                                "udp"
                              ]
                            },
                            "credentials": {
                              "type": "object",
                              "properties": {
                                "username": {
                                  "description": "The account name the camera expects. For MJPEG the bridge sends a real HTTP `Authorization: Basic` header and leaves the URL untouched. RTSP offers no such channel through ffmpeg, so there the name goes inside the connect URL instead — built fresh for that one call and never written back into the stored document.",
                                  "examples": [
                                    "ops"
                                  ],
                                  "type": "string",
                                  "minLength": 1,
                                  "maxLength": 128
                                },
                                "password": {
                                  "description": "The password for `username`. **There is no secret store behind this**: the value written here is the value stored, so treat it as readable by everyone who may read this robot's configuration, now and in its history.",
                                  "type": "string",
                                  "minLength": 1,
                                  "maxLength": 128
                                }
                              },
                              "additionalProperties": false,
                              "description": "Username and password for the stream, standing **in clear text in the document**. A published version is immutable, so a password here cannot be removed from history or rotated without republishing — which is why the publish audit event carries only the version number and never the document body. Userinfo in the `url` works too; an explicit block here wins over it."
                            }
                          },
                          "required": [
                            "kind",
                            "url"
                          ],
                          "additionalProperties": false,
                          "description": "Frames come from an RTSP stream the robot itself can reach — a network camera on its own LAN. The bridge opens the connection; the cloud never does, and never needs a route to the camera."
                        },
                        {
                          "type": "object",
                          "properties": {
                            "kind": {
                              "type": "string",
                              "const": "mjpeg",
                              "description": "Selects the MJPEG-over-HTTP source: this camera then carries `url`, and optionally `credentials`."
                            },
                            "url": {
                              "type": "string",
                              "minLength": 1,
                              "maxLength": 2048,
                              "pattern": "^https?:\\/\\/",
                              "description": "Where the stream lives. **`http://` or `https://` only** — as for the `rtsp` URL, the bridge opens it with a library that would also serve `file:`. Plain `http://` is permitted because these cameras usually sit on the robot's own network, but Basic credentials on such a URL then travel in the clear.",
                              "examples": [
                                "http://cam-1.plant.local/video.mjpg"
                              ]
                            },
                            "credentials": {
                              "type": "object",
                              "properties": {
                                "username": {
                                  "description": "The account name the camera expects. For MJPEG the bridge sends a real HTTP `Authorization: Basic` header and leaves the URL untouched. RTSP offers no such channel through ffmpeg, so there the name goes inside the connect URL instead — built fresh for that one call and never written back into the stored document.",
                                  "examples": [
                                    "ops"
                                  ],
                                  "type": "string",
                                  "minLength": 1,
                                  "maxLength": 128
                                },
                                "password": {
                                  "description": "The password for `username`. **There is no secret store behind this**: the value written here is the value stored, so treat it as readable by everyone who may read this robot's configuration, now and in its history.",
                                  "type": "string",
                                  "minLength": 1,
                                  "maxLength": 128
                                }
                              },
                              "additionalProperties": false,
                              "description": "Username and password for the stream, standing **in clear text in the document**. A published version is immutable, so a password here cannot be removed from history or rotated without republishing — which is why the publish audit event carries only the version number and never the document body. Userinfo in the `url` works too; an explicit block here wins over it."
                            }
                          },
                          "required": [
                            "kind",
                            "url"
                          ],
                          "additionalProperties": false,
                          "description": "Frames come from an MJPEG stream over HTTP — one JPEG after another, the simplest network source there is. Unlike `rtsp` there is no `transport` to choose: it is HTTP, and any `credentials` therefore travel as HTTP Basic."
                        },
                        {
                          "type": "object",
                          "properties": {
                            "kind": {
                              "type": "string",
                              "const": "v4l2",
                              "description": "Selects the local capture-device source: this camera then carries `device` and nothing else."
                            },
                            "device": {
                              "type": "string",
                              "minLength": 1,
                              "maxLength": 128,
                              "pattern": "^\\/dev\\/[A-Za-z0-9][A-Za-z0-9._/-]*$",
                              "description": "The capture device, resolved on the robot and never by the cloud; a `/dev/v4l/by-id/...` symlink survives a reboot that renumbers `/dev/video0`. **Constrained to `/dev/`** — the string reaches OpenCV, which will just as happily open an ordinary video file or an `http://` URL and publish its pixels to the cloud. The bridge re-derives the same constraint rather than trusting the wire.",
                              "examples": [
                                "/dev/video0"
                              ]
                            }
                          },
                          "required": [
                            "kind",
                            "device"
                          ],
                          "additionalProperties": false,
                          "description": "Frames come from a capture device attached to the robot itself, such as a USB camera on `/dev/video0`. Nothing leaves the robot to fetch them, and there is nothing to authenticate to, so this source takes no `credentials`."
                        }
                      ],
                      "description": "Where this camera's frames come from. `kind` picks one of four sources and fixes which other fields the source may carry, so an impossible camera is unrepresentable rather than merely invalid — there is no way to write an RTSP camera with a ROS topic."
                    },
                    "width": {
                      "type": "integer",
                      "exclusiveMinimum": 0,
                      "maximum": 7680,
                      "description": "The width the bridge scales frames to before sending, in pixels — what the bridge produces, not what the sensor captures; a snapshot can arrive narrower, since the bridge reduces both dimensions together to fit its JPEG byte ceiling. It stands in the configuration and never in a viewer's request, so no client can make the robot encode a larger frame than the developer allowed.",
                      "examples": [
                        1280
                      ]
                    },
                    "height": {
                      "type": "integer",
                      "exclusiveMinimum": 0,
                      "maximum": 4320,
                      "description": "The height the bridge scales every frame to, in pixels; with `width` it is the size the live stream carries. A snapshot can arrive **smaller** than this — its JPEG has a byte ceiling, and the bridge gives up quality first and then resolution to fit, reporting the size it actually encoded.",
                      "examples": [
                        720
                      ]
                    },
                    "fps": {
                      "type": "integer",
                      "exclusiveMinimum": 0,
                      "maximum": 60,
                      "description": "How many frames a second the bridge forwards, at most. It is a ceiling, not a clock: a camera that delivers ten frames a second stays at ten. Both modes read the same throttled pipeline, so this also bounds how fresh a snapshot can be.",
                      "examples": [
                        15
                      ]
                    },
                    "bitrate_kbps": {
                      "type": "integer",
                      "exclusiveMinimum": 0,
                      "maximum": 50000,
                      "description": "The ceiling for the **live** encoding, in kilobits per second — this is what bounds a watched camera against the robot's uplink. Snapshots are not covered by it: they are JPEGs under their own byte ceiling. Raising `width`, `height` or `fps` against a fixed bitrate buys blur, not detail.",
                      "examples": [
                        2000
                      ]
                    },
                    "snapshot_interval_seconds": {
                      "type": "integer",
                      "minimum": 1,
                      "maximum": 3600,
                      "description": "How often a still frame is captured, in seconds. **It runs whether or not anyone is watching**, unlike the live stream, which the cloud refcounts — first viewer starts it, last one ends it. The cloud caches the one frame and serves every reader from it, so a hundred pollers cost the robot exactly one image per interval.",
                      "examples": [
                        5
                      ]
                    },
                    "description": {
                      "description": "What this camera shows, in the developer's own words — documentation for whoever reads the configuration, for the console and for MCP clients; the robot does nothing with it. A camera without one is still offered, with `description: null`, as for actions, services and publishers. What `camera_snapshot` serves is the latest snapshot with its age; a live session is never a tool.",
                      "examples": [
                        "Forward-facing camera on the mast."
                      ],
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 2000
                    }
                  },
                  "required": [
                    "source",
                    "width",
                    "height",
                    "fps",
                    "bitrate_kbps",
                    "snapshot_interval_seconds"
                  ],
                  "additionalProperties": false
                },
                "description": "Video the robot streams, and the still frames the cloud serves from it. `width`, `height`, `fps` and `bitrate_kbps` are what **the bridge produces before sending**, not what the camera captures — they live in the configuration rather than in a viewer's request precisely so that no viewer can make a robot send more. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all three are refused when the document is validated."
              },
              "low_bandwidth": {
                "description": "Overrides for the bridge's low-bandwidth mode; see the section schema.",
                "type": "object",
                "properties": {
                  "mode": {
                    "description": "`auto` decides from the measured lag; `on` and `off` force the mode, for tests and for an operator who knows the link.",
                    "type": "string",
                    "enum": [
                      "auto",
                      "on",
                      "off"
                    ]
                  },
                  "enter_lag_ms": {
                    "description": "Lag or queue dwell above this enters the mode. Checked against exit_lag_ms only when both are in this document; a lone key composes with the bridge's parameter or the default on the robot, and a crossed pair is refused there when the configuration is applied, so name both when you change either.",
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 9007199254740991
                  },
                  "enter_after_s": {
                    "description": "The entry condition must hold this long.",
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 9007199254740991
                  },
                  "exit_lag_ms": {
                    "description": "Lag and dwell both at or below this leave the mode. Must be at or below enter_lag_ms: a crossed pair is a mode that leaves as it arrives. Checked here only when both keys are present; a lone key is checked on the robot against the parameter or default it composes with.",
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991
                  },
                  "exit_after_s": {
                    "description": "The exit condition must hold this long.",
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 9007199254740991
                  },
                  "datapoint_max_hz": {
                    "description": "The long-run rate for every datapoint in the mode, unless the datapoint says `low_bandwidth: keep`. It is an average, not a minimum gap: after a quiet spell two samples may go out close together, and over any longer window the rate holds.",
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "maximum": 20
                  },
                  "camera": {
                    "description": "What happens to a running stream in the mode. New streams are refused either way.",
                    "type": "string",
                    "enum": [
                      "reduce",
                      "stop"
                    ]
                  },
                  "camera_bitrate_kbps": {
                    "description": "Bitrate applied to running streams under `reduce`.",
                    "type": "integer",
                    "minimum": 50,
                    "maximum": 20000
                  }
                },
                "additionalProperties": false
              }
            },
            "required": [
              "fleetless"
            ],
            "additionalProperties": false
          },
          "source": {
            "type": "string"
          }
        },
        "required": [
          "version",
          "published_at",
          "doc",
          "source"
        ],
        "additionalProperties": false
      },
      "config-versions-response": {
        "type": "object",
        "properties": {
          "versions": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "version": {
                  "type": "integer",
                  "exclusiveMinimum": 0,
                  "maximum": 9007199254740991
                },
                "published_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                }
              },
              "required": [
                "version",
                "published_at"
              ],
              "additionalProperties": false
            }
          }
        },
        "required": [
          "versions"
        ],
        "additionalProperties": false
      },
      "create-app-invitation-request": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address to invite. An invitation always bypasses the domain whitelist — a developer inviting somebody by hand has already made the decision the whitelist automates."
          },
          "role_id": {
            "description": "The role the invitee gets on acceptance. Absent means the app's `default_role_id`.",
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
          },
          "display_name": {
            "description": "An optional name to pre-fill the account with; the invitee can change it afterwards.",
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ]
          },
          "send_mail": {
            "type": "boolean",
            "description": "Whether Fleetless mails the invitation. The link points at the app's `invite_url`, or at the Fleetless-hosted invitation page when the app has configured none — so the mail always leads somewhere, and nothing is refused for a missing URL."
          }
        },
        "required": [
          "email",
          "send_mail"
        ],
        "additionalProperties": false
      },
      "create-app-oidc-provider-request": {
        "type": "object",
        "properties": {
          "slug": {
            "type": "string",
            "maxLength": 40,
            "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$",
            "description": "The handle for this provider, unique per app. Immutable once created — identities are keyed by it, so a rename would orphan every linked account."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "What the developer's sign-in page calls this provider."
          },
          "issuer": {
            "type": "string",
            "maxLength": 500,
            "format": "uri",
            "description": "The provider's issuer URL. Shape-checked here (http(s), no credentials, query or fragment); the authoritative SSRF defence is at the discovery fetch."
          },
          "client_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "The OAuth client registered at the provider for Fleetless."
          },
          "client_secret": {
            "type": "string",
            "minLength": 16,
            "maxLength": 500,
            "description": "The client secret, **write-only**: it is stored encrypted and comes back through nothing — not the read, not this route's own answer, not an audit detail. Required on create, since a provider with no secret cannot exchange a code; the minimum length refuses a value that is a misconfiguration rather than a secret."
          },
          "scopes": {
            "default": [
              "openid",
              "email",
              "profile"
            ],
            "description": "The scopes to request. Defaults to `openid email profile`, which is what account linking actually reads: the subject, the address and its verified flag, and a name.",
            "minItems": 1,
            "maxItems": 20,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 60
            }
          },
          "link_verified_emails": {
            "default": false,
            "description": "Whether a federated login may join an existing app user by verified address. **Defaults to off**, because relaxing later is additive and admitting duplicates now and tightening afterwards is not.",
            "type": "boolean"
          },
          "enabled": {
            "default": true,
            "description": "Whether the provider is offered immediately. Defaults to on: a developer who just typed a client secret is configuring a provider they mean to use.",
            "type": "boolean"
          }
        },
        "required": [
          "slug",
          "name",
          "issuer",
          "client_id",
          "client_secret"
        ],
        "additionalProperties": false
      },
      "create-app-request": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "identifier": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
          },
          "robot_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            }
          }
        },
        "required": [
          "name",
          "identifier"
        ],
        "additionalProperties": false
      },
      "create-app-user-request": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address, unique per app case-insensitively. An address this app already knows is refused with `email_taken` — a developer-authenticated route may say so, unlike the public registration route."
          },
          "password": {
            "type": "string",
            "minLength": 12,
            "maxLength": 256,
            "description": "The initial password. At least 12 characters: length only, because a rule a user cannot predict is a rule they work around."
          },
          "display_name": {
            "description": "Optional human name. Absent leaves it unset; an explicit `null` is the same end state.",
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ]
          },
          "role_id": {
            "description": "The role the new user holds. Absent means the app's `default_role_id`, and `409 target_state_conflict` when the app has none or its default no longer resolves; a role belonging to another app is `404 not_found`, the same refusal a role that never existed gets.",
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
          }
        },
        "required": [
          "email",
          "password"
        ],
        "additionalProperties": false
      },
      "create-passkey-request": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "What to call the passkey, such as the device it lives on."
          },
          "credential": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {},
            "description": "The browser's `RegistrationResponseJSON` for the options `POST /api/auth/passkeys/options` answered."
          }
        },
        "required": [
          "name",
          "credential"
        ],
        "additionalProperties": false
      },
      "create-passkey-response": {
        "type": "object",
        "properties": {
          "passkey": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                "description": "The passkey in the API, as renamed and removed through `/api/auth/passkeys/:id`."
              },
              "name": {
                "type": "string",
                "minLength": 1,
                "maxLength": 80,
                "description": "What the person called it, such as the device it lives on."
              },
              "created_at": {
                "type": "string",
                "format": "date-time",
                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                "description": "When it was registered."
              },
              "last_used_at": {
                "anyOf": [
                  {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "When it last signed the person in or confirmed a sign-in, or `null` if never."
              },
              "synced": {
                "anyOf": [
                  {
                    "type": "boolean"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Whether the authenticator reported the passkey as syncable across the person's devices (the backup-eligible flag), or `null` when it said nothing."
              }
            },
            "required": [
              "id",
              "name",
              "created_at",
              "last_used_at",
              "synced"
            ],
            "additionalProperties": false,
            "description": "The passkey as it is now stored."
          },
          "recovery_codes": {
            "anyOf": [
              {
                "minItems": 10,
                "maxItems": 10,
                "type": "array",
                "items": {
                  "type": "string",
                  "pattern": "^[a-z2-7]{5}-[a-z2-7]{5}$"
                }
              },
              {
                "type": "null"
              }
            ],
            "description": "The ten recovery codes, shown once, when this passkey is the account's first second factor; `null` when the account already had one and its codes stay valid."
          }
        },
        "required": [
          "passkey",
          "recovery_codes"
        ],
        "additionalProperties": false
      },
      "create-robot-request": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 63
          }
        },
        "required": [
          "name"
        ]
      },
      "create-robot-response": {
        "type": "object",
        "properties": {
          "robot": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                "description": "The robot, and what every robot-scoped route takes as its `:id`."
              },
              "name": {
                "type": "string",
                "minLength": 1,
                "maxLength": 63,
                "description": "The robot's display name, at most 63 characters. Free text, changed through `PATCH /api/robots/:id`."
              },
              "created_at": {
                "type": "string",
                "format": "date-time",
                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                "description": "When the robot was created, as an ISO 8601 timestamp."
              }
            },
            "required": [
              "id",
              "name",
              "created_at"
            ],
            "additionalProperties": false
          },
          "token": {
            "type": "string",
            "pattern": "^frt_[0-9a-f]{32}$"
          }
        },
        "required": [
          "robot",
          "token"
        ],
        "additionalProperties": false
      },
      "create-server-key-response": {
        "type": "object",
        "properties": {
          "server_key": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                "description": "The key row, and what the rotate and delete routes address. It is not the key: this shape never carries the secret."
              },
              "app_id": {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                "description": "The app whose full rights this key carries. A key is never shared between apps."
              },
              "name": {
                "type": "string",
                "minLength": 1,
                "maxLength": 120,
                "description": "A label the developer chose, so a key can be recognised before it is rotated or deleted."
              },
              "created_at": {
                "type": "string",
                "format": "date-time",
                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                "description": "When the key was minted, as an ISO 8601 timestamp. `GET /api/apps/:id/server-keys` orders by this field."
              },
              "last_used_at": {
                "anyOf": [
                  {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "When this key last authenticated a request, or `null` if it never has — the cheapest way to spot a key nobody needs."
              }
            },
            "required": [
              "id",
              "app_id",
              "name",
              "created_at",
              "last_used_at"
            ],
            "additionalProperties": false
          },
          "key": {
            "type": "string",
            "pattern": "^flk_[0-9a-f]{32}$"
          }
        },
        "required": [
          "server_key",
          "key"
        ],
        "additionalProperties": false
      },
      "create-team-invite-request": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address to invite. Globally unique across Fleetless users, so an address already on another org's team is refused with `email_taken`."
          },
          "display_name": {
            "description": "An optional name to pre-fill the account with; the invitee can change it afterwards.",
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ]
          },
          "tier": {
            "type": "string",
            "enum": [
              "owner",
              "developer"
            ],
            "description": "The tier the invitee holds on acceptance. **Required, not defaulted** — \"I did not think about it\" and \"I meant developer\" would otherwise be the same request, on the field that decides who can remove whom. Inviting an owner requires the owner tier."
          },
          "send_mail": {
            "type": "boolean",
            "description": "Whether Fleetless mails the invitation. The link in the answer is the primary path either way: a deployment with no SMTP still issues invitations and says so through `mail`."
          }
        },
        "required": [
          "email",
          "tier",
          "send_mail"
        ],
        "additionalProperties": false
      },
      "datapoint-list-response": {
        "type": "object",
        "properties": {
          "datapoints": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "slug": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
                  "description": "The name a client reads this datapoint by."
                },
                "builtin": {
                  "type": "boolean",
                  "description": "`true` for the datapoints every robot has — `bridge_state` and `robot_details` — and `false` for everything the published configuration adds."
                },
                "unit": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "The unit the value carries **after** any scale and offset, shown beside the number so nobody has to guess whether `15` means percent, volts or minutes. `null` when the configuration names none."
                },
                "rate_throttle_hz": {
                  "anyOf": [
                    {
                      "type": "number",
                      "minimum": 0,
                      "maximum": 20
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "The ceiling on how often this datapoint is sent, in hertz. `null` means no ceiling is configured, which is also the answer for every built-in. A ceiling, not a clock: a slow topic stays slow and no value is repeated to manufacture a rate."
                }
              },
              "required": [
                "slug",
                "builtin",
                "unit",
                "rate_throttle_hz"
              ],
              "additionalProperties": false
            },
            "description": "Everything a client may read on this robot: the two built-ins, plus every datapoint the published configuration exposes and the caller's role grants."
          }
        },
        "required": [
          "datapoints"
        ],
        "additionalProperties": false
      },
      "datapoint-value": {
        "type": "object",
        "properties": {
          "slug": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
            "description": "The datapoint this value belongs to."
          },
          "value": {
            "description": "The value, shaped by the datapoint: a number, a boolean, a string, or the whole ROS message where the configuration names no field inside it. Any `scale` and `offset` the configuration declares have already been applied, at the robot."
          },
          "timestamp_ms": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "When the value was captured, as a unix timestamp in milliseconds. The **bridge's capture time**, never the time the cloud received it — the one exception is the built-in `bridge_state`, which the cloud observes by construction."
          }
        },
        "required": [
          "slug",
          "value",
          "timestamp_ms"
        ],
        "additionalProperties": false
      },
      "developer-passkey": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The passkey in the API, as renamed and removed through `/api/auth/passkeys/:id`."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "What the person called it, such as the device it lives on."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When it was registered."
          },
          "last_used_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time",
                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
              },
              {
                "type": "null"
              }
            ],
            "description": "When it last signed the person in or confirmed a sign-in, or `null` if never."
          },
          "synced": {
            "anyOf": [
              {
                "type": "boolean"
              },
              {
                "type": "null"
              }
            ],
            "description": "Whether the authenticator reported the passkey as syncable across the person's devices (the backup-eligible flag), or `null` when it said nothing."
          }
        },
        "required": [
          "id",
          "name",
          "created_at",
          "last_used_at",
          "synced"
        ],
        "additionalProperties": false
      },
      "developer-two-factor": {
        "type": "object",
        "properties": {
          "passkeys": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The passkey in the API, as renamed and removed through `/api/auth/passkeys/:id`."
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 80,
                  "description": "What the person called it, such as the device it lives on."
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When it was registered."
                },
                "last_used_at": {
                  "anyOf": [
                    {
                      "type": "string",
                      "format": "date-time",
                      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "When it last signed the person in or confirmed a sign-in, or `null` if never."
                },
                "synced": {
                  "anyOf": [
                    {
                      "type": "boolean"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "Whether the authenticator reported the passkey as syncable across the person's devices (the backup-eligible flag), or `null` when it said nothing."
                }
              },
              "required": [
                "id",
                "name",
                "created_at",
                "last_used_at",
                "synced"
              ],
              "additionalProperties": false
            },
            "description": "Every passkey the caller has registered, oldest first. Empty when none."
          },
          "authenticator": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "created_at": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                    "description": "When the authenticator was confirmed."
                  }
                },
                "required": [
                  "created_at"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "description": "The confirmed authenticator app, or `null` when there is none. At most one."
          },
          "recovery_codes_left": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10,
            "description": "How many of the ten recovery codes are unspent. `0` while the caller has no second factor."
          },
          "required_by_org": {
            "type": "boolean",
            "description": "Whether the organisation requires a second factor. While it does, the last one cannot be removed."
          }
        },
        "required": [
          "passkeys",
          "authenticator",
          "recovery_codes_left",
          "required_by_org"
        ],
        "additionalProperties": false
      },
      "dynamic-client-registration-request": {
        "type": "object",
        "properties": {
          "redirect_uris": {
            "minItems": 1,
            "maxItems": 5,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 2000
            },
            "description": "Where the authorization code may be returned, and the one field a registration cannot omit. Each must be an `https` URL, or `http` on an explicit loopback address for a native app that cannot hold a certificate, and none may carry a fragment. Between `1` and `5` of them; duplicates are collapsed rather than counted twice. Matched **exactly** at the authorize step against what was registered here."
          },
          "client_name": {
            "description": "The name the client calls itself. Optional: RFC 7591 makes every metadata field optional, so a registration without one is recorded under a default name. It is **not** vouched for by Fleetless and must never be rendered as if it were: a self-registered client chooses this string, and one has called itself *\"Fleetless Official Helper\"*.",
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "token_endpoint_auth_method": {
            "description": "`none`, RFC 7591's value for a public client, and the only value either server registers. Any other value is **refused rather than silently downgraded**: a client that believes it holds a secret and does not has a wrong mental model of its own security. There is no client secret to hold — mandatory PKCE (`S256`) is the defence.",
            "type": "string",
            "enum": [
              "none"
            ]
          },
          "grant_types": {
            "description": "Accepted for conformance with RFC 7591 and then **ignored**: both MCP authorization servers grant `authorization_code` and `refresh_token` to every registration, and the answer states what was granted (§3.2.1) rather than what was asked.",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "authorization_code",
                "refresh_token"
              ]
            }
          },
          "response_types": {
            "description": "Accepted for conformance and then **ignored**; the response names `code`, which is the only response type OAuth 2.1 leaves, the implicit grant having been removed.",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "code"
              ]
            }
          },
          "scope": {
            "description": "Accepted for conformance and then **ignored**. This authorization server issues no scopes at all, which is why the registration answer carries no `scope` field to echo one back in.",
            "type": "string",
            "maxLength": 500
          }
        },
        "required": [
          "redirect_uris"
        ],
        "description": "What an MCP client sends to register itself, per RFC 7591. Unknown metadata is ignored rather than refused (§3.1), and the answer states what was actually granted rather than what was asked for (§3.2.1)."
      },
      "dynamic-client-registration-response": {
        "type": "object",
        "properties": {
          "client_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "The identifier this client sends at the authorize and token endpoints. Opaque, and not the app identifier."
          },
          "client_name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "The name the client registered under, echoed back. Chosen by the client and not vouched for by Fleetless."
          },
          "redirect_uris": {
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 2000
            },
            "description": "The redirect URIs this registration was accepted for. A code is returned to one of these and nowhere else."
          },
          "grant_types": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The grants this client may use. Always exactly `[\"authorization_code\", \"refresh_token\"]` — an exchange mints a refresh token and the token endpoint rotates it."
          },
          "response_types": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The response types this client may ask for: `code`."
          },
          "token_endpoint_auth_method": {
            "type": "string",
            "const": "none",
            "description": "`none` — this server registers public clients only, and PKCE rather than a secret is what protects the exchange."
          },
          "client_id_issued_at": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "When the registration was created, in seconds since the epoch, per RFC 7591."
          },
          "client_secret_expires_at": {
            "type": "number",
            "const": 0,
            "description": "Always `0`, which is RFC 7591's way of saying the client secret never expires — there is none. The **registration** itself does expire: a self-registered client that never completes a flow is an unauthenticated write somebody left behind."
          }
        },
        "required": [
          "client_id",
          "client_name",
          "redirect_uris",
          "grant_types",
          "response_types",
          "token_endpoint_auth_method",
          "client_id_issued_at",
          "client_secret_expires_at"
        ],
        "additionalProperties": false
      },
      "exposure-list-response": {
        "type": "object",
        "properties": {
          "exposures": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "slug": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "datapoint",
                    "action",
                    "service",
                    "publisher",
                    "camera"
                  ]
                },
                "builtin": {
                  "type": "boolean"
                }
              },
              "required": [
                "slug",
                "kind",
                "builtin"
              ],
              "additionalProperties": false
            }
          }
        },
        "required": [
          "exposures"
        ],
        "additionalProperties": false
      },
      "feedback-request": {
        "type": "object",
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "idea",
              "problem",
              "question",
              "other"
            ],
            "description": "What the message is: an `idea`, a `problem`, a `question` or `other`. It only sorts the inbox; it changes nothing about how the message is handled."
          },
          "message": {
            "type": "string",
            "minLength": 1,
            "maxLength": 5000,
            "description": "What the developer wrote, trimmed. At most 5000 characters; a message that is only whitespace is refused."
          },
          "page": {
            "type": "string",
            "maxLength": 512,
            "pattern": "^\\/.*",
            "description": "The console path the message was sent from, e.g. `/robots/:id/jobs` with its real id. A path, never a full URL, so no host and no query string reach the inbox by accident."
          }
        },
        "required": [
          "kind",
          "message",
          "page"
        ],
        "additionalProperties": false
      },
      "feedback-response": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The stored message. It exists whatever `mail` says."
          },
          "mail": {
            "type": "string",
            "enum": [
              "sent",
              "not_requested",
              "not_configured",
              "failed"
            ],
            "description": "What happened to the notification mail: `sent`, `failed`, or `not_configured` when this cloud has no feedback address. The message is stored in every case, so a client shows success for all three."
          }
        },
        "required": [
          "id",
          "mail"
        ],
        "additionalProperties": false
      },
      "fetch-types-request": {
        "type": "object",
        "properties": {
          "type_names": {
            "minItems": 1,
            "maxItems": 50,
            "type": "array",
            "items": {
              "type": "string",
              "maxLength": 255,
              "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$"
            }
          }
        },
        "required": [
          "type_names"
        ]
      },
      "fetch-types-response": {
        "type": "object",
        "properties": {
          "types": {
            "type": "array",
            "items": {
              "oneOf": [
                {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$"
                    },
                    "kind": {
                      "type": "string",
                      "const": "msg"
                    },
                    "fields": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/fetch-types-response--__schema0"
                      }
                    }
                  },
                  "required": [
                    "name",
                    "kind",
                    "fields"
                  ],
                  "additionalProperties": false
                },
                {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$"
                    },
                    "kind": {
                      "type": "string",
                      "const": "srv"
                    },
                    "request": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/fetch-types-response--__schema0"
                      }
                    },
                    "response": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/fetch-types-response--__schema0"
                      }
                    }
                  },
                  "required": [
                    "name",
                    "kind",
                    "request",
                    "response"
                  ],
                  "additionalProperties": false
                },
                {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$"
                    },
                    "kind": {
                      "type": "string",
                      "const": "action"
                    },
                    "goal": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/fetch-types-response--__schema0"
                      }
                    },
                    "result": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/fetch-types-response--__schema0"
                      }
                    },
                    "feedback": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/fetch-types-response--__schema0"
                      }
                    }
                  },
                  "required": [
                    "name",
                    "kind",
                    "goal",
                    "result",
                    "feedback"
                  ],
                  "additionalProperties": false
                }
              ]
            }
          },
          "unresolved": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "types",
          "unresolved"
        ],
        "additionalProperties": false
      },
      "fetch-types-response--__schema0": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 128
          },
          "type": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "array": {
            "type": "boolean"
          },
          "fields": {
            "anyOf": [
              {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/fetch-types-response--__schema0"
                }
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "name",
          "type",
          "array",
          "fields"
        ],
        "additionalProperties": false
      },
      "fleetless-user": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The Fleetless user in the API, assigned by the cloud and stable for the life of the account."
          },
          "org_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The organisation this person belongs to. Every developer route is already scoped to the caller's org, so this confirms what a client is looking at, not a filter it applies."
          },
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address the account is identified by, **globally unique** across every organisation. Immutable after creation: it is what every invitation, reset link and audit line names."
          },
          "display_name": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ],
            "description": "Optional human name, shown by the console instead of the address where present. Self-service through `PATCH /api/auth/me`; never used for authentication. `null` when the person never supplied one."
          },
          "tier": {
            "type": "string",
            "enum": [
              "owner",
              "developer"
            ],
            "description": "The console powers this person holds. **Required** — every Fleetless user has a tier; it was optional only while the org also held people with no console powers to grade, and that pool is gone."
          },
          "two_factor": {
            "type": "object",
            "properties": {
              "passkeys": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "How many passkeys the person has registered."
              },
              "authenticator": {
                "type": "boolean",
                "description": "Whether the person has a confirmed authenticator app."
              }
            },
            "required": [
              "passkeys",
              "authenticator"
            ],
            "additionalProperties": false,
            "description": "The person's second factors, as the team list shows them: none, passkeys, an authenticator, or both. No credential travels here. An owner resets them through `DELETE /api/org/users/:id/two-factor`."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the account was created, as an ISO 8601 timestamp."
          }
        },
        "required": [
          "id",
          "org_id",
          "email",
          "display_name",
          "tier",
          "two_factor",
          "created_at"
        ],
        "additionalProperties": false
      },
      "fleetless-user-list-response": {
        "type": "object",
        "properties": {
          "users": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The Fleetless user in the API, assigned by the cloud and stable for the life of the account."
                },
                "org_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The organisation this person belongs to. Every developer route is already scoped to the caller's org, so this confirms what a client is looking at, not a filter it applies."
                },
                "email": {
                  "type": "string",
                  "format": "email",
                  "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                  "description": "The address the account is identified by, **globally unique** across every organisation. Immutable after creation: it is what every invitation, reset link and audit line names."
                },
                "display_name": {
                  "anyOf": [
                    {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 120
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "Optional human name, shown by the console instead of the address where present. Self-service through `PATCH /api/auth/me`; never used for authentication. `null` when the person never supplied one."
                },
                "tier": {
                  "type": "string",
                  "enum": [
                    "owner",
                    "developer"
                  ],
                  "description": "The console powers this person holds. **Required** — every Fleetless user has a tier; it was optional only while the org also held people with no console powers to grade, and that pool is gone."
                },
                "two_factor": {
                  "type": "object",
                  "properties": {
                    "passkeys": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991,
                      "description": "How many passkeys the person has registered."
                    },
                    "authenticator": {
                      "type": "boolean",
                      "description": "Whether the person has a confirmed authenticator app."
                    }
                  },
                  "required": [
                    "passkeys",
                    "authenticator"
                  ],
                  "additionalProperties": false,
                  "description": "The person's second factors, as the team list shows them: none, passkeys, an authenticator, or both. No credential travels here. An owner resets them through `DELETE /api/org/users/:id/two-factor`."
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the account was created, as an ISO 8601 timestamp."
                }
              },
              "required": [
                "id",
                "org_id",
                "email",
                "display_name",
                "tier",
                "two_factor",
                "created_at"
              ],
              "additionalProperties": false
            },
            "description": "Every Fleetless user of the caller's organisation. This is the team, not an app's users — those are listed per app."
          }
        },
        "required": [
          "users"
        ],
        "additionalProperties": false
      },
      "history-response": {
        "anyOf": [
          {
            "type": "object",
            "properties": {
              "slug": {
                "type": "string",
                "minLength": 2,
                "maxLength": 63,
                "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
                "description": "The datapoint these samples belong to."
              },
              "kind": {
                "type": "string",
                "const": "samples",
                "description": "Says this is the raw-sample shape, which the query asked for by omitting `window`. A client reads this rather than inspecting which fields arrived."
              },
              "samples": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "timestamp_ms": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991,
                      "description": "When the sample was captured, as a unix timestamp in milliseconds. It is the **bridge's capture time** — the same instant the live value carried, so a recorded point and a live one sit on one axis without apology."
                    },
                    "value": {
                      "description": "The value as it was stored, shaped by the datapoint. A `field` in the query narrows a message down to one number; without one the whole stored value comes back."
                    }
                  },
                  "required": [
                    "timestamp_ms",
                    "value"
                  ],
                  "additionalProperties": false
                },
                "description": "The samples in the queried window, oldest first. The window is half-open, `[from, to)`, so a sample landing exactly on `to` belongs to the next window."
              },
              "truncated": {
                "type": "boolean",
                "description": "Whether the response was cut short. A short array that does not admit it is indistinguishable from a quiet period, and the two lead a developer to opposite conclusions."
              },
              "truncated_by": {
                "anyOf": [
                  {
                    "type": "string",
                    "enum": [
                      "limit",
                      "bytes"
                    ]
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Why it was cut, and `null` when it was not — the two causes have **different remedies** and one boolean cannot tell them apart. `limit` means too many rows, so raising `limit` helps. `bytes` means the rows are large, so raising `limit` changes nothing: narrow the range, or name a numeric `field` so whole messages are not carried."
              }
            },
            "required": [
              "slug",
              "kind",
              "samples",
              "truncated",
              "truncated_by"
            ],
            "additionalProperties": false
          },
          {
            "type": "object",
            "properties": {
              "slug": {
                "type": "string",
                "minLength": 2,
                "maxLength": 63,
                "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
                "description": "The datapoint these buckets summarise."
              },
              "kind": {
                "type": "string",
                "const": "buckets",
                "description": "Says this is the aggregated shape, which the query asked for by naming a `window`. A separate shape rather than the sample shape with nulls in it, so a client knows by type what it received rather than by inspection."
              },
              "window_ms": {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991,
                "description": "The bucket width actually used, in milliseconds — the query's `window` resolved to a number, so a rendered chart can say what it is drawing without re-parsing the expression it sent."
              },
              "agg": {
                "type": "string",
                "enum": [
                  "min",
                  "max",
                  "avg"
                ],
                "description": "How each bucket reduced the samples inside it, echoed back from the query."
              },
              "buckets": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "bucket_start_ms": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991,
                      "description": "The instant this bucket opens, as a unix timestamp in milliseconds. Buckets are half-open and `window_ms` wide, so this one covers up to but not including `bucket_start_ms + window_ms`."
                    },
                    "value": {
                      "anyOf": [
                        {
                          "type": "number"
                        },
                        {
                          "type": "null"
                        }
                      ],
                      "description": "The aggregate over this bucket's **numeric** samples, or `null` when none of them were numeric — which is **not** the same as the bucket being empty. `sample_count` separates those: `null` with a count of `0` is a gap a chart should draw as a break, `null` with a count above `0` is data that simply has no height."
                    },
                    "sample_count": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991,
                      "description": "Every sample that landed in this bucket and inside the queried range, whether or not it contributed to `value` — only a count of *all* samples can prove a bucket empty rather than merely unplottable. Two consequences: `value * sample_count` is **not** a sum, and on a first or last bucket the count reflects the range rather than the bucket, so a low edge count is a boundary effect and not a quiet period."
                    }
                  },
                  "required": [
                    "bucket_start_ms",
                    "value",
                    "sample_count"
                  ],
                  "additionalProperties": false
                },
                "description": "The buckets covering the queried window, oldest first. A range and window that would produce more than `limit` buckets is refused before the query runs, because this shape carries no `truncated` field and a refusal is then the only honest answer."
              }
            },
            "required": [
              "slug",
              "kind",
              "window_ms",
              "agg",
              "buckets"
            ],
            "additionalProperties": false
          }
        ]
      },
      "introspection-response": {
        "type": "object",
        "properties": {
          "graph": {
            "type": "object",
            "properties": {
              "topics": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$"
                    },
                    "types": {
                      "minItems": 1,
                      "type": "array",
                      "items": {
                        "type": "string",
                        "maxLength": 255,
                        "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$"
                      }
                    }
                  },
                  "required": [
                    "name",
                    "types"
                  ],
                  "additionalProperties": false
                }
              },
              "services": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$"
                    },
                    "types": {
                      "minItems": 1,
                      "type": "array",
                      "items": {
                        "type": "string",
                        "maxLength": 255,
                        "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$"
                      }
                    }
                  },
                  "required": [
                    "name",
                    "types"
                  ],
                  "additionalProperties": false
                }
              },
              "actions": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^\\/[A-Za-z_][A-Za-z0-9_]*(?:\\/[A-Za-z_][A-Za-z0-9_]*)*$"
                    },
                    "types": {
                      "minItems": 1,
                      "type": "array",
                      "items": {
                        "type": "string",
                        "maxLength": 255,
                        "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$"
                      }
                    }
                  },
                  "required": [
                    "name",
                    "types"
                  ],
                  "additionalProperties": false
                }
              },
              "captured_at_ms": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              }
            },
            "required": [
              "topics",
              "services",
              "actions",
              "captured_at_ms"
            ],
            "additionalProperties": false
          },
          "fetched_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
          },
          "stale": {
            "type": "boolean"
          }
        },
        "required": [
          "graph",
          "fetched_at",
          "stale"
        ],
        "additionalProperties": false
      },
      "invoke-or-service-response": {
        "anyOf": [
          {
            "type": "object",
            "properties": {
              "job": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid",
                    "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                    "description": "The job's id, minted by the cloud when the invocation is accepted. Informative — state is observed by slug; a cancel names this id to stop one specific job rather than whatever is running."
                  },
                  "robot_id": {
                    "type": "string",
                    "format": "uuid",
                    "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                    "description": "The robot this job is running on."
                  },
                  "slug": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 63,
                    "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
                    "description": "The action or service this job is running, as the published configuration exposes it. One slug carries one job at a time, so every observer of that slug sees the same one."
                  },
                  "state": {
                    "type": "string",
                    "enum": [
                      "running",
                      "unknown",
                      "succeeded",
                      "failed",
                      "cancelled",
                      "lost"
                    ],
                    "description": "Where the job stands: `running`, `unknown`, `succeeded`, `failed`, `cancelled` or `lost`. `unknown` is not an outcome — the robot went offline or silent and the cloud does not know yet; the slug stays occupied and the bridge's next statement resolves it, `error` naming why the cloud lost sight of it. `lost` is final: the bridge stated it does not know the job and nothing else runs on its action, or the action server vanished mid-goal."
                  },
                  "origin": {
                    "type": "string",
                    "enum": [
                      "fleetless",
                      "external"
                    ],
                    "description": "Who started this job. `fleetless` for everything minted by the cloud; `external` for a goal the bridge found active on a published action without having sent it — no parameters, no starter, never written to `job_runs`."
                  },
                  "started_at": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                    "description": "When the cloud minted this job, as an ISO 8601 timestamp. For a job adopted from a reconnecting bridge, this is **adoption time**, not the real start — the cloud never minted it."
                  },
                  "updated_at": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                    "description": "When this job last changed, as an ISO 8601 timestamp."
                  },
                  "seq": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991,
                    "description": "A monotonic counter ascending in mint order, and the named tiebreaker for any listing that claims one — `started_at` alone is not a total order. Scoped per cloud process and per run: job state lives in memory, so this restarts with the registry it orders."
                  },
                  "result": {
                    "anyOf": [
                      {},
                      {
                        "type": "null"
                      }
                    ],
                    "description": "What the call returned once it succeeded, shaped by the ROS action or service itself. `null` until then, and for a job that did not succeed."
                  },
                  "error": {
                    "anyOf": [
                      {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string",
                            "minLength": 1,
                            "description": "A machine-readable code for the failure, such as `job_queue_full`, where one exists for it."
                          },
                          "message": {
                            "type": "string",
                            "minLength": 1,
                            "description": "A human-readable sentence saying what went wrong."
                          },
                          "details": {
                            "description": "The structured payload belonging to `code`, for the codes that document one — `job_queue_full` carries its `limit` and its `queued` count here. Absent for a failure with nothing structured to add, which is most of them."
                          }
                        },
                        "required": [
                          "code",
                          "message"
                        ],
                        "additionalProperties": false
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "Why the job failed, or why the cloud does not know how it stands: a human `message`, a `code` where one exists, and `details` for the codes that carry a documented payload. Set on `failed` and `lost`, and on `unknown` — where `code` is `bridge_disconnected` or `bridge_timeout`, the cloud's own reason for not knowing, cleared when the bridge reports the job running again."
                  }
                },
                "required": [
                  "id",
                  "robot_id",
                  "slug",
                  "state",
                  "origin",
                  "started_at",
                  "updated_at",
                  "seq",
                  "result",
                  "error"
                ],
                "additionalProperties": false,
                "description": "The job that now exists on this slug. It is returned as soon as the goal is accepted, so `state` is `running` here — the outcome is observed afterwards, by slug, over polling or a subscription."
              },
              "kind": {
                "type": "string",
                "enum": [
                  "action",
                  "service"
                ],
                "description": "Always `action` in this shape. A caller sends the same request for both kinds and cannot tell from a role grant which it invoked, so the answer says which it was rather than leaving it to be inferred from the shape."
              }
            },
            "required": [
              "job",
              "kind"
            ],
            "additionalProperties": false
          },
          {
            "type": "object",
            "properties": {
              "result": {
                "description": "What the service returned, shaped by the ROS service. A service call is awaited to completion, so there is no job to observe afterwards and no id to hold on to."
              }
            },
            "required": [
              "result"
            ],
            "additionalProperties": false
          }
        ]
      },
      "invoke-request": {
        "type": "object",
        "properties": {
          "params": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {},
            "description": "The values this call needs, keyed by **parameter name** rather than by field path — so a name survives the field moving inside the message. Every parameter without a default must be present, and the bounds the configuration declares are enforced in the cloud, before anything reaches the robot."
          },
          "patience_ms": {
            "description": "How long **this call** is worth waiting for, in milliseconds; absent means the platform default. The number travels to the robot too, so one deadline governs both sides. Above the maximum the call is refused rather than quietly clamped, because a caller given less than they asked for would read the timeout as the robot's failure.",
            "type": "integer",
            "minimum": 1000,
            "maximum": 120000
          }
        },
        "required": [
          "params"
        ]
      },
      "job-response": {
        "type": "object",
        "properties": {
          "job": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid",
                    "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                    "description": "The job's id, minted by the cloud when the invocation is accepted. Informative — state is observed by slug; a cancel names this id to stop one specific job rather than whatever is running."
                  },
                  "robot_id": {
                    "type": "string",
                    "format": "uuid",
                    "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                    "description": "The robot this job is running on."
                  },
                  "slug": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 63,
                    "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
                    "description": "The action or service this job is running, as the published configuration exposes it. One slug carries one job at a time, so every observer of that slug sees the same one."
                  },
                  "state": {
                    "type": "string",
                    "enum": [
                      "running",
                      "unknown",
                      "succeeded",
                      "failed",
                      "cancelled",
                      "lost"
                    ],
                    "description": "Where the job stands: `running`, `unknown`, `succeeded`, `failed`, `cancelled` or `lost`. `unknown` is not an outcome — the robot went offline or silent and the cloud does not know yet; the slug stays occupied and the bridge's next statement resolves it, `error` naming why the cloud lost sight of it. `lost` is final: the bridge stated it does not know the job and nothing else runs on its action, or the action server vanished mid-goal."
                  },
                  "origin": {
                    "type": "string",
                    "enum": [
                      "fleetless",
                      "external"
                    ],
                    "description": "Who started this job. `fleetless` for everything minted by the cloud; `external` for a goal the bridge found active on a published action without having sent it — no parameters, no starter, never written to `job_runs`."
                  },
                  "started_at": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                    "description": "When the cloud minted this job, as an ISO 8601 timestamp. For a job adopted from a reconnecting bridge, this is **adoption time**, not the real start — the cloud never minted it."
                  },
                  "updated_at": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                    "description": "When this job last changed, as an ISO 8601 timestamp."
                  },
                  "seq": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991,
                    "description": "A monotonic counter ascending in mint order, and the named tiebreaker for any listing that claims one — `started_at` alone is not a total order. Scoped per cloud process and per run: job state lives in memory, so this restarts with the registry it orders."
                  },
                  "result": {
                    "anyOf": [
                      {},
                      {
                        "type": "null"
                      }
                    ],
                    "description": "What the call returned once it succeeded, shaped by the ROS action or service itself. `null` until then, and for a job that did not succeed."
                  },
                  "error": {
                    "anyOf": [
                      {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string",
                            "minLength": 1,
                            "description": "A machine-readable code for the failure, such as `job_queue_full`, where one exists for it."
                          },
                          "message": {
                            "type": "string",
                            "minLength": 1,
                            "description": "A human-readable sentence saying what went wrong."
                          },
                          "details": {
                            "description": "The structured payload belonging to `code`, for the codes that document one — `job_queue_full` carries its `limit` and its `queued` count here. Absent for a failure with nothing structured to add, which is most of them."
                          }
                        },
                        "required": [
                          "code",
                          "message"
                        ],
                        "additionalProperties": false
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "Why the job failed, or why the cloud does not know how it stands: a human `message`, a `code` where one exists, and `details` for the codes that carry a documented payload. Set on `failed` and `lost`, and on `unknown` — where `code` is `bridge_disconnected` or `bridge_timeout`, the cloud's own reason for not knowing, cleared when the bridge reports the job running again."
                  }
                },
                "required": [
                  "id",
                  "robot_id",
                  "slug",
                  "state",
                  "origin",
                  "started_at",
                  "updated_at",
                  "seq",
                  "result",
                  "error"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "description": "The **most recent** job on this slug, running or already finished, and `null` only when nothing has ever run there. Read `state` to tell a live job from a settled one: a route that forgot a job the moment it settled would let a poller see `running` and then nothing."
          }
        },
        "required": [
          "job"
        ],
        "additionalProperties": false
      },
      "job-run-list-response": {
        "type": "object",
        "properties": {
          "runs": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The run's id — the same id the invocation was answered with, so a caller that kept a job id can find its durable record here later."
                },
                "robot_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The robot the run happened on."
                },
                "slug": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
                  "description": "The action or service that was invoked, as the published configuration exposed it at the time."
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "action",
                    "service"
                  ],
                  "description": "Whether the slug was an `action` or a `service`."
                },
                "state": {
                  "type": "string",
                  "enum": [
                    "running",
                    "unknown",
                    "succeeded",
                    "failed",
                    "cancelled",
                    "lost"
                  ],
                  "description": "How the run ended, or `running` while it is still going. `unknown` while the robot has not accounted for it — offline or silent — and updated once the bridge says how it stands. `lost` is final: the bridge did not know the run and nothing else ran on its action, so the outcome is unknowable rather than unknown."
                },
                "started_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the run started, as an ISO 8601 timestamp. Runs are listed and filtered by this instant."
                },
                "ended_at": {
                  "anyOf": [
                    {
                      "type": "string",
                      "format": "date-time",
                      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "When the run finished, as an ISO 8601 timestamp. `null` while it is still `running` or `unknown` — a run has an end only once it has one."
                },
                "duration_ms": {
                  "anyOf": [
                    {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "How long the run took, in milliseconds. `null` while it is still `running` or `unknown`, never `0` standing in for \"nothing so far\"."
                },
                "result": {
                  "anyOf": [
                    {},
                    {
                      "type": "null"
                    }
                  ],
                  "description": "What the action or service returned once it succeeded, shaped by ROS itself. `null` otherwise."
                },
                "error": {
                  "anyOf": [
                    {
                      "type": "object",
                      "properties": {
                        "code": {
                          "type": "string",
                          "minLength": 1,
                          "description": "A machine-readable code for the failure, such as `job_queue_full`, where one exists for it."
                        },
                        "message": {
                          "type": "string",
                          "minLength": 1,
                          "description": "A human-readable sentence saying what went wrong."
                        },
                        "details": {
                          "description": "The structured payload belonging to `code`, for the codes that document one. Absent for a failure with nothing structured to add."
                        }
                      },
                      "required": [
                        "code",
                        "message"
                      ],
                      "additionalProperties": false
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "Why the run failed — a `message`, a `code` where one exists, and the structured `details` some codes carry. `null` unless it failed."
                },
                "actor": {
                  "type": "object",
                  "properties": {
                    "kind": {
                      "type": "string",
                      "enum": [
                        "developer",
                        "end_user",
                        "app_user",
                        "server_key"
                      ],
                      "description": "What the caller was acting as: a `developer` in the console, an `app_user` of one app, or a `server_key` used by server-side code. A bridge invokes nothing, so it is deliberately not a case here. `end_user` appears only on runs recorded before app users replaced the organisation-wide user pool — kept so old runs still render; nothing writes it now."
                    },
                    "id": {
                      "type": "string",
                      "format": "uuid",
                      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                      "description": "The id of the Fleetless user, app user or server key that invoked the run."
                    },
                    "label": {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 200,
                      "description": "A display name taken at invoke time — the email for a Fleetless user or an app user, the key's own name for a server key. Storing it rather than joining is the point: renaming a key afterwards does not rewrite history."
                    },
                    "name": {
                      "anyOf": [
                        {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 200
                        },
                        {
                          "type": "null"
                        }
                      ],
                      "description": "The person's display name when the job started: the Fleetless user's `display_name` for a developer, the app user's `display_name` for an app user. `null` for a server key, when the person had no name set, and for runs recorded before contracts 5.3.0. Show `label` when it is null."
                    }
                  },
                  "required": [
                    "kind",
                    "id",
                    "label",
                    "name"
                  ],
                  "additionalProperties": false,
                  "description": "Who invoked the run, and what they were acting as at the time."
                },
                "seq": {
                  "type": "integer",
                  "exclusiveMinimum": 0,
                  "maximum": 9007199254740991,
                  "description": "The durable cursor this history is ordered and paged by. Unlike `job.seq` it does not restart when the cloud does; it is the value a caller sends back as `before_seq`."
                },
                "progress": {
                  "anyOf": [
                    {
                      "type": "number",
                      "minimum": 0,
                      "maximum": 1
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "How far a still-running run has got, as a fraction from `0` to `1`, read live from the in-memory registry. `null` means **not known right now** — after a cloud restart, before the bridge reconnects — and never a `0` standing in for \"no progress yet\"."
                },
                "feedback": {
                  "anyOf": [
                    {},
                    {
                      "type": "null"
                    }
                  ],
                  "description": "The most recent action feedback for a run that is still running, shaped by the ROS action. Live-only, so it is `null` for every settled run and whenever the registry has nothing."
                }
              },
              "required": [
                "id",
                "robot_id",
                "slug",
                "kind",
                "state",
                "started_at",
                "ended_at",
                "duration_ms",
                "result",
                "error",
                "actor",
                "seq",
                "progress",
                "feedback"
              ],
              "additionalProperties": false
            },
            "description": "This page of runs, newest first by `seq`. Empty means the filter matched nothing, not that the history is gone."
          },
          "next_cursor": {
            "anyOf": [
              {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ],
            "description": "The `seq` to send as `before_seq` to keep reading, or `null` when there is nothing further. **`null` is a promise, not an observation** — a caller who instead compares the page length against `limit` is wrong the moment a filter makes a page thin."
          }
        },
        "required": [
          "runs",
          "next_cursor"
        ],
        "additionalProperties": false
      },
      "job-run-summary": {
        "type": "object",
        "properties": {
          "running": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "started": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "failed": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "since_ms": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "running",
          "started",
          "failed",
          "since_ms"
        ],
        "additionalProperties": false
      },
      "joint-state-put-request": {
        "type": "object",
        "properties": {
          "slug": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 2,
                "maxLength": 63,
                "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The datapoint to read joint positions from, or `null` to choose none. It must name a whole-message `sensor_msgs/msg/JointState` datapoint of the published configuration; anything else is a `validation_error` naming the rule."
          }
        },
        "required": [
          "slug"
        ]
      },
      "joint-state-put-response": {
        "type": "object",
        "properties": {
          "joint_state_slug": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 2,
                "maxLength": 63,
                "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The stored mapping after the call, `null` when none is chosen. The same value `assetListResponse.joint_state_slug` carries."
          }
        },
        "required": [
          "joint_state_slug"
        ],
        "additionalProperties": false
      },
      "live-session-response": {
        "type": "object",
        "properties": {
          "session_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "This viewer's hold, and the only thing a release should be given. Two tabs of one logged-in user are two holds; releasing without an id lets go of both and leaves the other tab rendering a stream the robot has already stopped producing."
          },
          "url": {
            "type": "string",
            "minLength": 1,
            "description": "The LiveKit server to connect to, as a WebSocket URL."
          },
          "room": {
            "type": "string",
            "minLength": 1,
            "description": "The LiveKit room carrying this camera. Every viewer of one camera on one robot joins the same room, which is what makes the refcount hold meaningful."
          },
          "token": {
            "type": "string",
            "minLength": 1,
            "description": "The LiveKit access token to join `room` with. It is checked when the participant connects and **not again afterwards** — which is not the same as irrevocable: the cloud can still disconnect a participant after the fact, and does when membership, a role or a key changes."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "The deadline for **joining**, as an ISO 8601 timestamp — not a session backstop. A viewer who has already joined keeps receiving video past this moment, so cleanup belongs in an explicit release, never in a timer built on this value."
          }
        },
        "required": [
          "session_id",
          "url",
          "room",
          "token",
          "expires_at"
        ],
        "additionalProperties": false
      },
      "mail-outcome": {
        "type": "object",
        "properties": {
          "mail": {
            "type": "string",
            "enum": [
              "sent",
              "not_requested",
              "not_configured",
              "failed"
            ],
            "description": "What happened to the mail this call triggered. `sent` means the SMTP server accepted it, not that it was delivered; `not_requested` means none was attempted, because the caller asked for none or there was no link to carry; `not_configured` is an expected state and not a failure; `failed` is the one worth somebody's attention."
          }
        },
        "required": [
          "mail"
        ],
        "additionalProperties": false
      },
      "mail-template-preview-request": {
        "type": "object",
        "properties": {
          "subject": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "The subject line, a Liquid template. Bounded because a subject is rendered into a header."
          },
          "text": {
            "type": "string",
            "minLength": 1,
            "maxLength": 20000,
            "description": "The plain-text body, a Liquid template. Required even when an HTML part is given: a mail with no text part is unreadable to a client that refuses HTML."
          },
          "html": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 100000
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "subject",
          "text"
        ],
        "additionalProperties": false
      },
      "mail-template-preview-response": {
        "type": "object",
        "properties": {
          "subject": {
            "type": "string",
            "description": "The rendered subject line."
          },
          "text": {
            "type": "string",
            "description": "The rendered plain-text body."
          },
          "html": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "The rendered HTML body, or `null` when the template is text-only."
          }
        },
        "required": [
          "subject",
          "text",
          "html"
        ],
        "additionalProperties": false
      },
      "mcp-consent-grant-list-response": {
        "type": "object",
        "properties": {
          "grants": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "client_id": {
                  "type": "string",
                  "description": "The MCP client this consent is for, as its dynamic registration was issued. It is the value the withdrawal routes take in their path, and it is the only stable handle on a client — the name beside it is not one."
                },
                "client_name": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "What the client calls itself, or `null` once its registration is gone. **Unverified** — see `client_name_verified`."
                },
                "client_name_verified": {
                  "type": "boolean",
                  "const": false,
                  "description": "Always `false`. The client registered itself without authentication and named itself, so it must be rendered as a claim and never as an identity. There is no verified case, which is why this is a literal and not a boolean: a `true` branch would be dead code that looked like a safeguard."
                },
                "granted_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the consent was last given. A withdrawal followed by a fresh approval moves it, because the second approval is the agreement that stands — it is not a record of the first time anybody ever said yes."
                }
              },
              "required": [
                "client_id",
                "client_name",
                "client_name_verified",
                "granted_at"
              ],
              "additionalProperties": false
            },
            "description": "Every standing consent this app user holds, newest first. Withdrawn ones are absent rather than listed as withdrawn; an app user who has connected no MCP client answers an empty array."
          }
        },
        "required": [
          "grants"
        ],
        "additionalProperties": false
      },
      "mcp-robot-datasheet": {
        "type": "object",
        "properties": {
          "robot_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
          },
          "robot_name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "capabilities": {
            "type": "object",
            "properties": {
              "action_history": {
                "type": "boolean"
              },
              "assets": {
                "type": "boolean"
              }
            },
            "required": [
              "action_history",
              "assets"
            ],
            "additionalProperties": false
          },
          "exposures": {
            "maxItems": 2000,
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "slug": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "datapoint",
                    "service",
                    "action",
                    "publisher",
                    "camera"
                  ]
                },
                "description": {
                  "anyOf": [
                    {
                      "type": "string",
                      "maxLength": 2000
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "unit": {
                  "anyOf": [
                    {
                      "type": "string",
                      "maxLength": 32
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "decimals": {
                  "anyOf": [
                    {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 6
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "input_schema": {
                  "anyOf": [
                    {},
                    {
                      "type": "null"
                    }
                  ]
                }
              },
              "required": [
                "slug",
                "kind",
                "description",
                "unit",
                "decimals",
                "input_schema"
              ],
              "additionalProperties": false
            }
          }
        },
        "required": [
          "robot_id",
          "robot_name",
          "capabilities",
          "exposures"
        ],
        "additionalProperties": false
      },
      "mcp-role-preview-response": {
        "type": "object",
        "properties": {
          "role_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
          },
          "robots": {
            "maxItems": 500,
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "robot_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                },
                "robot_name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 200
                },
                "capabilities": {
                  "type": "object",
                  "properties": {
                    "action_history": {
                      "type": "boolean"
                    },
                    "assets": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "action_history",
                    "assets"
                  ],
                  "additionalProperties": false
                },
                "exposures": {
                  "maxItems": 2000,
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "slug": {
                        "type": "string",
                        "minLength": 2,
                        "maxLength": 63,
                        "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                      },
                      "kind": {
                        "type": "string",
                        "enum": [
                          "datapoint",
                          "service",
                          "action",
                          "publisher",
                          "camera"
                        ]
                      },
                      "description": {
                        "anyOf": [
                          {
                            "type": "string",
                            "maxLength": 2000
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "unit": {
                        "anyOf": [
                          {
                            "type": "string",
                            "maxLength": 32
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "decimals": {
                        "anyOf": [
                          {
                            "type": "integer",
                            "minimum": 0,
                            "maximum": 6
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "input_schema": {
                        "anyOf": [
                          {},
                          {
                            "type": "null"
                          }
                        ]
                      }
                    },
                    "required": [
                      "slug",
                      "kind",
                      "description",
                      "unit",
                      "decimals",
                      "input_schema"
                    ],
                    "additionalProperties": false
                  }
                }
              },
              "required": [
                "robot_id",
                "robot_name",
                "capabilities",
                "exposures"
              ],
              "additionalProperties": false
            }
          }
        },
        "required": [
          "role_id",
          "robots"
        ],
        "additionalProperties": false
      },
      "oauth-token-request": {
        "oneOf": [
          {
            "type": "object",
            "properties": {
              "grant_type": {
                "type": "string",
                "const": "authorization_code",
                "description": "`authorization_code`: this request exchanges the code from the authorize redirect for an access token and a refresh token."
              },
              "code": {
                "type": "string",
                "minLength": 1,
                "maxLength": 500,
                "description": "The authorization code from the redirect. It may be exchanged once; a second presentation is `invalid_grant`, the same answer a fabricated code gets."
              },
              "redirect_uri": {
                "type": "string",
                "minLength": 1,
                "maxLength": 2000,
                "description": "The same redirect URI the authorize request used. It is compared, not merely recorded."
              },
              "client_id": {
                "type": "string",
                "minLength": 1,
                "maxLength": 200,
                "description": "The client making the exchange, as registered."
              },
              "code_verifier": {
                "type": "string",
                "pattern": "^[A-Za-z0-9\\-._~]{43,128}$",
                "description": "The PKCE verifier whose `S256` hash was sent as the challenge at the authorize step. Between `43` and `128` unreserved characters, per RFC 7636 §4.1 — it is compared rather than parsed, so a length nobody checks is a length an attacker chooses. PKCE is mandatory for every client under OAuth 2.1."
              },
              "resource": {
                "description": "The resource the token is requested for, per RFC 8707. It must match the audience the code was authorized for, or the answer is `invalid_target`; omitted, the code's own audience stands. It becomes the token's `aud`, and a resource refuses a token whose audience names something else — which is what keeps a token minted for one app out of another app's endpoint.",
                "type": "string",
                "format": "uri"
              }
            },
            "required": [
              "grant_type",
              "code",
              "redirect_uri",
              "client_id",
              "code_verifier"
            ],
            "description": "RFC 6749 §4.1.3's authorization-code exchange with PKCE, as either MCP authorization server reads it. Sent as `application/x-www-form-urlencoded`, per §4.1.3, though the server accepts a JSON body too."
          },
          {
            "type": "object",
            "properties": {
              "grant_type": {
                "type": "string",
                "const": "refresh_token",
                "description": "`refresh_token`: this request rotates a refresh token into a new access token and a new refresh token. The presented token is consumed; presenting it again revokes the whole session."
              },
              "refresh_token": {
                "type": "string",
                "minLength": 1,
                "maxLength": 500,
                "description": "The refresh token from the last token response. Bound to the client that received it and to one identity space: presented by another client, or at the other MCP server, it is `invalid_grant` and stays unconsumed."
              },
              "client_id": {
                "type": "string",
                "minLength": 1,
                "maxLength": 200,
                "description": "The client the refresh token was issued to, as registered. A refresh token is not transferable between clients."
              },
              "resource": {
                "description": "The resource the new token is for, per RFC 8707. Optional; when named it must be the audience the session was issued for, or the answer is `invalid_target` and the refresh token is left untouched. The successor carries the same audience either way.",
                "type": "string",
                "format": "uri"
              }
            },
            "required": [
              "grant_type",
              "refresh_token",
              "client_id"
            ],
            "description": "RFC 6749 §6's refresh, as either MCP authorization server reads it. Every use rotates: the answer carries a new refresh token and the presented one is dead."
          }
        ],
        "description": "What an MCP token endpoint accepts: the authorization-code exchange, or a refresh. Any other `grant_type` is `unsupported_grant_type`, refused before a lookup happens."
      },
      "oauth-token-response": {
        "type": "object",
        "properties": {
          "access_token": {
            "type": "string",
            "minLength": 1,
            "description": "The bearer token. It is the same token the client login mints — only the envelope differs, because an RFC-compliant client parses this one and knows nothing about Fleetless."
          },
          "token_type": {
            "type": "string",
            "const": "Bearer",
            "description": "`Bearer`. RFC 6749 §5.1 makes the value case-insensitive for a client reading it; this is the spelling this server emits."
          },
          "expires_in": {
            "type": "integer",
            "exclusiveMinimum": 0,
            "maximum": 9007199254740991,
            "description": "How long the access token is valid, in **seconds**, per RFC 6749 §5.1. Not a timestamp, and not milliseconds."
          },
          "refresh_token": {
            "description": "The refresh token. Both MCP token endpoints issue one on every exchange and every refresh; it rotates on every use, lives ninety days from its last use, and dies with the account's sessions — a block, a password change, a withdrawn consent. The console's own OAuth portal issues none.",
            "type": "string",
            "minLength": 1
          },
          "scope": {
            "description": "The scopes the issued token actually carries, space-separated.",
            "type": "string",
            "maxLength": 500
          }
        },
        "required": [
          "access_token",
          "token_type",
          "expires_in"
        ],
        "additionalProperties": false
      },
      "org-firing-alerts-response": {
        "type": "object",
        "properties": {
          "alerts": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                },
                "robot_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                },
                "slug": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 120
                },
                "enabled": {
                  "type": "boolean"
                },
                "severity": {
                  "type": "string",
                  "enum": [
                    "warning",
                    "error"
                  ]
                },
                "condition": {
                  "oneOf": [
                    {
                      "type": "object",
                      "properties": {
                        "kind": {
                          "type": "string",
                          "const": "above"
                        },
                        "threshold": {
                          "type": "number"
                        },
                        "resolve_hysteresis": {
                          "default": 0,
                          "type": "number",
                          "minimum": 0
                        }
                      },
                      "required": [
                        "kind",
                        "threshold",
                        "resolve_hysteresis"
                      ],
                      "additionalProperties": false
                    },
                    {
                      "type": "object",
                      "properties": {
                        "kind": {
                          "type": "string",
                          "const": "below"
                        },
                        "threshold": {
                          "type": "number"
                        },
                        "resolve_hysteresis": {
                          "default": 0,
                          "type": "number",
                          "minimum": 0
                        }
                      },
                      "required": [
                        "kind",
                        "threshold",
                        "resolve_hysteresis"
                      ],
                      "additionalProperties": false
                    },
                    {
                      "type": "object",
                      "properties": {
                        "kind": {
                          "type": "string",
                          "const": "equals"
                        },
                        "value": {
                          "anyOf": [
                            {
                              "type": "number"
                            },
                            {
                              "type": "string"
                            },
                            {
                              "type": "boolean"
                            }
                          ]
                        }
                      },
                      "required": [
                        "kind",
                        "value"
                      ],
                      "additionalProperties": false
                    }
                  ]
                },
                "state": {
                  "type": "string",
                  "enum": [
                    "ok",
                    "firing"
                  ]
                },
                "state_since": {
                  "anyOf": [
                    {
                      "type": "string",
                      "format": "date-time",
                      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "last_value": {
                  "anyOf": [
                    {},
                    {
                      "type": "null"
                    }
                  ]
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                },
                "robot_name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 63
                }
              },
              "required": [
                "id",
                "robot_id",
                "slug",
                "name",
                "enabled",
                "severity",
                "condition",
                "state",
                "state_since",
                "last_value",
                "created_at",
                "robot_name"
              ],
              "additionalProperties": false
            }
          }
        },
        "required": [
          "alerts"
        ],
        "additionalProperties": false
      },
      "org-latency-response": {
        "type": "object",
        "properties": {
          "series": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "robot_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                },
                "buckets": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "bucket_at": {
                        "type": "string",
                        "format": "date-time",
                        "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                      },
                      "min_ms": {
                        "anyOf": [
                          {
                            "type": "number",
                            "minimum": 0
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "avg_ms": {
                        "anyOf": [
                          {
                            "type": "number",
                            "minimum": 0
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "max_ms": {
                        "anyOf": [
                          {
                            "type": "number",
                            "minimum": 0
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "samples": {
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 9007199254740991
                      },
                      "online_ms": {
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 60000
                      }
                    },
                    "required": [
                      "bucket_at",
                      "min_ms",
                      "avg_ms",
                      "max_ms",
                      "samples",
                      "online_ms"
                    ],
                    "additionalProperties": false
                  }
                }
              },
              "required": [
                "robot_id",
                "buckets"
              ],
              "additionalProperties": false
            }
          },
          "from_ms": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "to_ms": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "truncated": {
            "type": "boolean"
          },
          "truncated_by": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "limit",
                  "bytes"
                ]
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "series",
          "from_ms",
          "to_ms",
          "truncated",
          "truncated_by"
        ],
        "additionalProperties": false
      },
      "org-plan": {
        "type": "object",
        "properties": {
          "plan": {
            "type": "string",
            "enum": [
              "basic",
              "plus",
              "pro",
              "enterprise"
            ],
            "description": "The org's current plan."
          },
          "currency": {
            "type": "string",
            "enum": [
              "eur",
              "usd"
            ],
            "description": "The currency the org's prices are shown and billed in."
          },
          "period_ends_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "The end of the organization's current billing period; while no payment period exists yet, the end of the current UTC calendar month. A pending downward plan change normally takes effect at this exact instant — except one chosen while the org was locked, which lands at once, and the platform's own move off the beta, which lands at its switch date instead."
          },
          "addons": {
            "type": "object",
            "properties": {
              "seats": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Extra developer seats, one each."
              },
              "robots": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Extra robots, one each."
              },
              "apps": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Extra apps, one each."
              },
              "app_user_packs": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Packs of five extra app users."
              },
              "live_video_packs": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Packs of 250 extra hours of app-user live video per month."
              }
            },
            "required": [
              "seats",
              "robots",
              "apps",
              "app_user_packs",
              "live_video_packs"
            ],
            "additionalProperties": false,
            "description": "The add-ons the org has bought. All zero on a plan without the `addons` feature."
          },
          "limits": {
            "type": "object",
            "properties": {
              "seats": {
                "anyOf": [
                  {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Developers, owners included, plus pending team invitations."
              },
              "robots": {
                "anyOf": [
                  {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Robots in the org."
              },
              "apps": {
                "anyOf": [
                  {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Apps in the org."
              },
              "app_users": {
                "anyOf": [
                  {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "App users across every app of the org, plus pending app-user invitations."
              },
              "live_video_ms_per_month": {
                "anyOf": [
                  {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Live video watched by app users in one UTC calendar month, in milliseconds, across the org. Console sessions do not count."
              },
              "asset_bytes_per_robot": {
                "anyOf": [
                  {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Asset storage per robot, in bytes (decimal: 1 GB = 1,000,000,000). The org stores up to robots × this value in total."
              },
              "history_days": {
                "anyOf": [
                  {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Days the org's robot history is kept."
              },
              "audit_days": {
                "anyOf": [
                  {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Days the org's audit log is kept."
              }
            },
            "required": [
              "seats",
              "robots",
              "apps",
              "app_users",
              "live_video_ms_per_month",
              "asset_bytes_per_robot",
              "history_days",
              "audit_days"
            ],
            "additionalProperties": false,
            "description": "The org's effective limits: the plan's, raised by its add-ons, or an operator's override in their place. `null` means unlimited for a count, and 90 days for `history_days` and `audit_days`."
          },
          "features": {
            "type": "object",
            "properties": {
              "app_mcp": {
                "type": "boolean",
                "description": "An app's own MCP endpoint, for its app users."
              },
              "two_factor": {
                "type": "boolean",
                "description": "Two-factor sign-in for developers."
              },
              "require_two_factor": {
                "type": "boolean",
                "description": "An owner may require two-factor sign-in for every developer of the org."
              },
              "app_oidc": {
                "type": "boolean",
                "description": "An app may let its users sign in through an OpenID Connect identity provider."
              },
              "hosted_logo": {
                "type": "boolean",
                "description": "An app's hosted pages show its logo and accent colour, not only its name."
              },
              "audit_export": {
                "type": "boolean",
                "description": "The audit log can be exported as CSV."
              },
              "addons": {
                "type": "boolean",
                "description": "Add-ons can be bought on top of the plan."
              }
            },
            "required": [
              "app_mcp",
              "two_factor",
              "require_two_factor",
              "app_oidc",
              "hosted_logo",
              "audit_export",
              "addons"
            ],
            "additionalProperties": false,
            "description": "What the org's plan unlocks."
          },
          "usage": {
            "type": "object",
            "properties": {
              "seats": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Developers, owners included, plus pending team invitations."
              },
              "robots": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Robots in the org."
              },
              "apps": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Apps in the org."
              },
              "app_users": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "App users across every app, plus pending app-user invitations."
              },
              "live_video_ms_this_month": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Live video watched by app users in the current UTC calendar month, in milliseconds. Console sessions do not count."
              },
              "asset_bytes": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Bytes the assets of every robot of the org occupy, together."
              }
            },
            "required": [
              "seats",
              "robots",
              "apps",
              "app_users",
              "live_video_ms_this_month",
              "asset_bytes"
            ],
            "additionalProperties": false,
            "description": "What the org uses now, counted the way each limit counts it."
          },
          "pending_change": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "target_plan": {
                    "type": "string",
                    "enum": [
                      "basic",
                      "plus",
                      "pro",
                      "enterprise"
                    ],
                    "description": "The plan the org moves to."
                  },
                  "reason": {
                    "type": "string",
                    "enum": [
                      "downgrade",
                      "cancel",
                      "migration",
                      "lock"
                    ],
                    "description": "Why the change is pending: `downgrade`, `cancel`, `migration` or `lock`."
                  },
                  "effective_at": {
                    "anyOf": [
                      {
                        "type": "string",
                        "format": "date-time",
                        "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "When the change takes effect. `null` only for a move off the beta whose date is not set yet."
                  },
                  "keep": {
                    "anyOf": [
                      {
                        "type": "object",
                        "properties": {
                          "robots": {
                            "type": "array",
                            "items": {
                              "type": "string",
                              "format": "uuid",
                              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                            },
                            "description": "The robots that stay, by id. Every other robot is deleted when the change takes effect."
                          },
                          "apps": {
                            "type": "array",
                            "items": {
                              "type": "string",
                              "format": "uuid",
                              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                            },
                            "description": "The apps that stay, by id. Every other app is deleted when the change takes effect."
                          },
                          "app_users": {
                            "type": "array",
                            "items": {
                              "type": "string",
                              "format": "uuid",
                              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                            },
                            "description": "The app users that stay, by id, across every app. Every other app user is deleted when the change takes effect."
                          },
                          "developers": {
                            "type": "array",
                            "items": {
                              "type": "string",
                              "format": "uuid",
                              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                            },
                            "description": "The developers that stay, by user id, owners never among them: every owner stays. Every other developer is removed from the org when the change takes effect."
                          }
                        },
                        "required": [
                          "robots",
                          "apps",
                          "app_users",
                          "developers"
                        ],
                        "additionalProperties": false
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "What stays. `null` when the org already fits the target plan and nothing is deleted."
                  },
                  "history_days_after": {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991,
                    "description": "Days of history and audit log the org keeps on the target plan."
                  },
                  "chosen_by": {
                    "type": "string",
                    "format": "uuid",
                    "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                    "description": "The user id of the owner who chose the change, or the nil UUID when Fleetless queued it."
                  },
                  "chosen_at": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                    "description": "When the change was chosen."
                  }
                },
                "required": [
                  "target_plan",
                  "reason",
                  "effective_at",
                  "keep",
                  "history_days_after",
                  "chosen_by",
                  "chosen_at"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "description": "A move to a lower plan that has not taken effect yet, or `null`."
          },
          "lock": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "enum": [
                      "payment",
                      "migration"
                    ],
                    "description": "`payment`: a payment is missing. `migration`: the org did not choose what stays when the beta ended."
                  },
                  "since": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                    "description": "When the org was locked."
                  }
                },
                "required": [
                  "reason",
                  "since"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "description": "Why and since when the org is locked, or `null` when it is not."
          },
          "switch": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "at": {
                    "anyOf": [
                      {
                        "type": "string",
                        "format": "date-time",
                        "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "When the org moves from the beta onto its plan. `null` while the date is not set."
                  },
                  "needs_choice": {
                    "type": "boolean",
                    "description": "Whether the org uses more than Basic allows, so an owner has to choose what stays before `at`."
                  }
                },
                "required": [
                  "at",
                  "needs_choice"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "description": "Set only while the org is still on the beta; `null` for every other org."
          }
        },
        "required": [
          "plan",
          "currency",
          "period_ends_at",
          "addons",
          "limits",
          "features",
          "usage",
          "pending_change",
          "lock",
          "switch"
        ],
        "additionalProperties": false
      },
      "org-quota-usage": {
        "type": "object",
        "properties": {
          "quotas": {
            "type": "object",
            "properties": {
              "max_robots": {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991
              },
              "max_apps": {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991
              },
              "max_end_users": {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991
              },
              "max_retention_bytes": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "max_retention_writes_per_minute": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "max_realtime_connections": {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991
              }
            },
            "required": [
              "max_robots",
              "max_apps",
              "max_end_users",
              "max_retention_bytes",
              "max_retention_writes_per_minute",
              "max_realtime_connections"
            ],
            "additionalProperties": false
          },
          "usage": {
            "type": "object",
            "properties": {
              "max_robots": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "max_apps": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "max_end_users": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "max_retention_bytes": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "max_retention_writes_per_minute": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "max_realtime_connections": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              }
            },
            "additionalProperties": false
          }
        },
        "required": [
          "quotas",
          "usage"
        ],
        "additionalProperties": false
      },
      "org-usage-response": {
        "type": "object",
        "properties": {
          "rows": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "app_id": {
                  "anyOf": [
                    {
                      "type": "string",
                      "format": "uuid",
                      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "app_name": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "metric": {
                  "type": "string",
                  "enum": [
                    "api_calls",
                    "live_session_ms",
                    "retention_bytes",
                    "asset_bytes",
                    "robot_online_ms"
                  ]
                },
                "day": {
                  "type": "string",
                  "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
                },
                "value": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 9007199254740991
                }
              },
              "required": [
                "app_id",
                "app_name",
                "metric",
                "day",
                "value"
              ],
              "additionalProperties": false
            }
          },
          "from_day": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
          },
          "to_day": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
          }
        },
        "required": [
          "rows",
          "from_day",
          "to_day"
        ],
        "additionalProperties": false
      },
      "password-change-request": {
        "type": "object",
        "properties": {
          "current_password": {
            "type": "string",
            "minLength": 1,
            "description": "The password in use right now. It is required even though the session already proves identity: it is what makes a stolen *session* insufficient to take the *account*."
          },
          "new_password": {
            "type": "string",
            "minLength": 12,
            "maxLength": 256,
            "description": "The replacement password. Every other session is revoked when it is accepted, while the session that made the change survives — logging somebody out of the tab they just used is indistinguishable from the change having failed."
          }
        },
        "required": [
          "current_password",
          "new_password"
        ]
      },
      "patch-app-oidc-provider-request": {
        "type": "object",
        "properties": {
          "name": {
            "description": "A new display name for the provider.",
            "type": "string",
            "minLength": 1,
            "maxLength": 80
          },
          "issuer": {
            "description": "A new issuer URL. Changing it re-runs discovery; identities linked under the old one keep their `(provider, subject)` key.",
            "type": "string",
            "maxLength": 500,
            "format": "uri"
          },
          "client_id": {
            "description": "A new client id.",
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "client_secret": {
            "description": "A replacement client secret. **Absent means keep the stored one**, so a routine edit need not put the secret back on the wire; it is never echoed back by any route.",
            "type": "string",
            "minLength": 16,
            "maxLength": 500
          },
          "scopes": {
            "description": "A replacement scope list. A replace, not a merge.",
            "minItems": 1,
            "maxItems": 20,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 60
            }
          },
          "link_verified_emails": {
            "description": "Whether a federated login may join an existing app user by verified address.",
            "type": "boolean"
          },
          "enabled": {
            "description": "Turn the provider off or back on without deleting it or its linked identities.",
            "type": "boolean"
          }
        },
        "additionalProperties": false
      },
      "patch-app-user-request": {
        "type": "object",
        "properties": {
          "display_name": {
            "description": "The user's display name. Absent leaves it alone; an explicit `null` clears it.",
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ]
          },
          "role_id": {
            "description": "The role the user holds from now on. A role belonging to another app is `404 not_found`, the same refusal a role that never existed gets; re-roling closes the user's live subscriptions.",
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "blocked"
            ],
            "description": "Block the account or let it back in. **`pending_verification` cannot be set here**: it is reached only by self-registration and left only by spending the mailed verification token, so a developer setting it would strand the account in a state nothing re-mails them out of."
          }
        },
        "additionalProperties": false
      },
      "patch-auth-me-request": {
        "type": "object",
        "properties": {
          "display_name": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "display_name"
        ],
        "additionalProperties": false
      },
      "patch-fleetless-user-request": {
        "type": "object",
        "properties": {
          "display_name": {
            "description": "The member's display name. Absent leaves it alone; an explicit `null` clears it.",
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "additionalProperties": false
      },
      "patch-org-request": {
        "type": "object",
        "properties": {
          "name": {
            "description": "The organisation's new display name. Absent leaves it alone.",
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "require_two_factor": {
            "description": "Whether every member must have a second factor. Turning it on signs nobody out: each member without one sets it up at their next sign-in. Absent leaves it alone.",
            "type": "boolean"
          }
        },
        "additionalProperties": false
      },
      "patch-org-response": {
        "type": "object",
        "properties": {
          "org": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                "description": "The organisation. Every developer route is scoped to the caller's org already, so a client rarely has to send this anywhere."
              },
              "name": {
                "type": "string",
                "minLength": 1,
                "maxLength": 120,
                "description": "The organisation's display name. Free text, changed through `PATCH /api/org`."
              },
              "require_two_factor": {
                "type": "boolean",
                "description": "Whether every member must have a second factor — a passkey or an authenticator app. A member without one sets it up at their next sign-in, before any session exists; nobody is signed out when it is switched on. It covers the console and the central MCP endpoint; server keys and robot bridges are not people and are not affected. Owners change it through `PATCH /api/org`."
              },
              "created_at": {
                "type": "string",
                "format": "date-time",
                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                "description": "When the organisation was created, as an ISO 8601 timestamp."
              }
            },
            "required": [
              "id",
              "name",
              "require_two_factor",
              "created_at"
            ],
            "additionalProperties": false,
            "description": "The organisation as it now stands, after the patch. The whole resource comes back, not only the changed fields."
          }
        },
        "required": [
          "org"
        ],
        "additionalProperties": false
      },
      "patch-robot-request": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 63
          }
        },
        "required": [
          "name"
        ],
        "additionalProperties": false
      },
      "patch-robot-response": {
        "type": "object",
        "properties": {
          "robot": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                "description": "The robot, and what every robot-scoped route takes as its `:id`."
              },
              "name": {
                "type": "string",
                "minLength": 1,
                "maxLength": 63,
                "description": "The robot's display name, at most 63 characters. Free text, changed through `PATCH /api/robots/:id`."
              },
              "created_at": {
                "type": "string",
                "format": "date-time",
                "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                "description": "When the robot was created, as an ISO 8601 timestamp."
              }
            },
            "required": [
              "id",
              "name",
              "created_at"
            ],
            "additionalProperties": false,
            "description": "The robot as it now stands, after the patch. The whole resource comes back, not only the changed fields."
          }
        },
        "required": [
          "robot"
        ],
        "additionalProperties": false
      },
      "payment-method-change-request": {
        "type": "object",
        "properties": {
          "method": {
            "type": "string",
            "enum": [
              "card",
              "paypal",
              "applepay"
            ],
            "description": "The new mandate's method. Offer what `billingView.payment_method_options` lists: `applepay` may be refused with `400 validation_error` `{ field: 'method', rule: 'applepay_needs_open_invoice' }` unless an invoice is open."
          }
        },
        "required": [
          "method"
        ],
        "additionalProperties": false
      },
      "pending-team-invite-list-response": {
        "type": "object",
        "properties": {
          "invitations": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The invitation, as revoked and re-issued by the team."
                },
                "email": {
                  "type": "string",
                  "format": "email",
                  "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
                  "description": "The address the invitation was addressed to."
                },
                "tier": {
                  "type": "string",
                  "enum": [
                    "owner",
                    "developer"
                  ],
                  "description": "The tier the invitee would hold. Visible so an owner can spot an owner-tier invitation they did not authorise."
                },
                "expires_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the token stops working."
                }
              },
              "required": [
                "id",
                "email",
                "tier",
                "expires_at"
              ],
              "additionalProperties": false
            },
            "description": "Pending invitations only. An accepted invitation is history, not something to revoke."
          }
        },
        "required": [
          "invitations"
        ],
        "additionalProperties": false
      },
      "plan-change-request": {
        "type": "object",
        "properties": {
          "target_plan": {
            "type": "string",
            "enum": [
              "basic",
              "plus",
              "pro",
              "enterprise"
            ],
            "description": "The lower plan to move to; `basic` cancels."
          },
          "keep": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "robots": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "uuid",
                      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                    },
                    "description": "The robots that stay, by id. Every other robot is deleted when the change takes effect."
                  },
                  "apps": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "uuid",
                      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                    },
                    "description": "The apps that stay, by id. Every other app is deleted when the change takes effect."
                  },
                  "app_users": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "uuid",
                      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                    },
                    "description": "The app users that stay, by id, across every app. Every other app user is deleted when the change takes effect."
                  },
                  "developers": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "uuid",
                      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                    },
                    "description": "The developers that stay, by user id, owners never among them: every owner stays. Every other developer is removed from the org when the change takes effect."
                  }
                },
                "required": [
                  "robots",
                  "apps",
                  "app_users",
                  "developers"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "description": "What stays. `null` when the org already fits the target plan, so nothing is deleted."
          }
        },
        "required": [
          "target_plan",
          "keep"
        ],
        "additionalProperties": false
      },
      "protected-resource-metadata": {
        "type": "object",
        "properties": {
          "resource": {
            "type": "string",
            "format": "uri",
            "description": "The resource identifier this document describes, per RFC 9728. A token whose audience names something else is rejected here rather than merely noted."
          },
          "authorization_servers": {
            "minItems": 1,
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri"
            },
            "description": "The authorization servers that may issue tokens for this resource. There is always at least one."
          },
          "bearer_methods_supported": {
            "type": "array",
            "items": {
              "type": "string",
              "const": "header"
            },
            "description": "How a token may be presented: in the `Authorization` header only, never in a query parameter or a form field."
          },
          "scopes_supported": {
            "description": "The scopes this resource understands, where it publishes a list.",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "resource",
          "authorization_servers",
          "bearer_methods_supported"
        ],
        "additionalProperties": false
      },
      "publish-config-response": {
        "type": "object",
        "properties": {
          "version": {
            "type": "integer",
            "exclusiveMinimum": 0,
            "maximum": 9007199254740991
          },
          "published_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
          }
        },
        "required": [
          "version",
          "published_at"
        ],
        "additionalProperties": false
      },
      "publish-request": {
        "type": "object",
        "properties": {
          "message": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {},
            "description": "The values to publish, keyed by the **parameter names** the publisher declares — the same flat form an invoke takes for `params`. They are checked against the declared bounds in the cloud before anything reaches the robot, and a slug another caller is still holding is refused with the remaining wait."
          }
        },
        "required": [
          "message"
        ]
      },
      "put-app-auth-look-request": {
        "type": "object",
        "properties": {
          "hosted_accent": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^#[0-9a-f]{6}$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The accent colour of the hosted pages, `#rrggbb` in lowercase, or `null` for the neutral shell's own."
          }
        },
        "required": [
          "hosted_accent"
        ],
        "additionalProperties": false
      },
      "put-app-auth-mcp-request": {
        "type": "object",
        "properties": {
          "mcp_enabled": {
            "type": "boolean",
            "description": "Whether this app serves an MCP endpoint at `/mcp/<identifier>`. Off refuses the whole OAuth surface for the app, not merely the tool calls, and is re-read on every request rather than cached off a token."
          }
        },
        "required": [
          "mcp_enabled"
        ],
        "additionalProperties": false
      },
      "put-app-auth-registration-request": {
        "type": "object",
        "properties": {
          "self_registration": {
            "type": "boolean",
            "description": "Whether a stranger may create an account in this app. Off refuses `POST /api/client/register` with `403 registration_closed`, and refuses an unknown identity at an OIDC callback with the same reasoning — one switch for one decision, whichever door the person arrives at."
          },
          "allowed_domains": {
            "maxItems": 50,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 253,
              "pattern": "^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$"
            },
            "description": "The email domains self-registration accepts, lowercase. An empty list means no domain restriction, not \"nobody\" — the switch above is what closes the door. **An invitation always bypasses this**, by password and through a provider alike."
          },
          "allowed_origins": {
            "maxItems": 20,
            "type": "array",
            "items": {
              "type": "string",
              "maxLength": 200
            },
            "description": "The origins the client auth API answers CORS for, and the only origins an OIDC `redirect_uri` may name. Bare origins: scheme, host and port, with no path — a browser sends nothing longer, so an entry carrying one could never match."
          }
        },
        "required": [
          "self_registration",
          "allowed_domains",
          "allowed_origins"
        ],
        "additionalProperties": false
      },
      "put-app-auth-sign-in-request": {
        "type": "object",
        "properties": {
          "sign_in_methods": {
            "type": "object",
            "properties": {
              "password": {
                "type": "boolean",
                "description": "Whether app users may sign in with a password. Off refuses `POST /api/client/login` with `method_not_allowed`, and registration and invitations then take no password."
              },
              "email_code": {
                "type": "boolean",
                "description": "Whether app users may sign in with a six-digit code mailed to them, valid ten minutes. A code needs no URL, so it works in local development and in an app with no web UI."
              }
            },
            "required": [
              "password",
              "email_code"
            ],
            "additionalProperties": false,
            "description": "Which sign-in methods the app offers: password, emailed code, or both — at least one. Identity providers stay on top of either. The default is password only."
          },
          "two_factor": {
            "type": "string",
            "enum": [
              "off",
              "optional",
              "required"
            ],
            "description": "Whether the app asks for an authenticator code: `off` (the default), `optional` or `required`. A person with a confirmed authenticator is asked at every sign-in whatever the policy; a sign-in through an identity provider is never asked."
          }
        },
        "required": [
          "sign_in_methods",
          "two_factor"
        ],
        "additionalProperties": false
      },
      "put-app-auth-urls-request": {
        "type": "object",
        "properties": {
          "app_url": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 500,
                "format": "uri"
              },
              {
                "type": "null"
              }
            ],
            "description": "The app's own home page, linked as `Open <app>` when a hosted flow is done. `null` makes the hosted done page say `You can close this tab`."
          },
          "invite_url": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 500
              },
              {
                "type": "null"
              }
            ],
            "description": "The page in the developer's app that accepts an invitation, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
          },
          "verify_url": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 500
              },
              {
                "type": "null"
              }
            ],
            "description": "The page that confirms a new address, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
          },
          "reset_url": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 500
              },
              {
                "type": "null"
              }
            ],
            "description": "The page that takes a new password, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
          },
          "mcp_login_url": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 500
              },
              {
                "type": "null"
              }
            ],
            "description": "The page an MCP authorization redirects to, with `{interaction}` where the interaction id goes. Not a token: the id names a pending request the server already holds, and the app authenticates the user itself before approving it. `null` means the hosted MCP sign-in in `hosted_pages` is used."
          }
        },
        "required": [
          "app_url",
          "invite_url",
          "verify_url",
          "reset_url",
          "mcp_login_url"
        ],
        "additionalProperties": false
      },
      "put-app-mail-template-request": {
        "type": "object",
        "properties": {
          "subject": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "The subject line, a Liquid template. Bounded because a subject is rendered into a header."
          },
          "text": {
            "type": "string",
            "minLength": 1,
            "maxLength": 20000,
            "description": "The plain-text body, a Liquid template. Required even when an HTML part is given: a mail with no text part is unreadable to a client that refuses HTML."
          },
          "html": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 100000
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "subject",
          "text"
        ],
        "additionalProperties": false
      },
      "put-config-draft-request": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string",
            "maxLength": 1000000
          }
        },
        "required": [
          "source"
        ]
      },
      "put-robot-details-request": {
        "type": "object",
        "properties": {
          "details": {
            "type": "object",
            "propertyNames": {
              "type": "string",
              "pattern": "^[a-z][a-z0-9_-]{0,63}$"
            },
            "additionalProperties": {
              "anyOf": [
                {
                  "type": "string",
                  "maxLength": 4096
                },
                {
                  "type": "number"
                },
                {
                  "type": "boolean"
                },
                {
                  "type": "array",
                  "items": {}
                },
                {
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                }
              ]
            }
          }
        },
        "required": [
          "details"
        ]
      },
      "put-robot-details-response": {
        "type": "object",
        "properties": {
          "details": {
            "type": "object",
            "propertyNames": {
              "type": "string",
              "pattern": "^[a-z][a-z0-9_-]{0,63}$"
            },
            "additionalProperties": {
              "anyOf": [
                {
                  "type": "string",
                  "maxLength": 4096
                },
                {
                  "type": "number"
                },
                {
                  "type": "boolean"
                },
                {
                  "type": "array",
                  "items": {}
                },
                {
                  "type": "object",
                  "propertyNames": {
                    "type": "string"
                  },
                  "additionalProperties": {}
                }
              ]
            },
            "description": "The stored `robot_details` document, which is the one that was just sent — this route **replaces** the document rather than merging into it. Keys are the developer's own, lowercase and at most 64 characters; a value is a string of at most 4096 characters, a number, a boolean, an array or an object."
          }
        },
        "required": [
          "details"
        ],
        "additionalProperties": false
      },
      "recovery-codes-response": {
        "type": "object",
        "properties": {
          "recovery_codes": {
            "minItems": 10,
            "maxItems": 10,
            "type": "array",
            "items": {
              "type": "string",
              "pattern": "^[a-z2-7]{5}-[a-z2-7]{5}$"
            },
            "description": "The ten new recovery codes, lowercase, shown once. Every earlier code is void."
          }
        },
        "required": [
          "recovery_codes"
        ],
        "additionalProperties": false
      },
      "refresh-request": {
        "type": "object",
        "properties": {
          "refresh_token": {
            "type": "string",
            "minLength": 1
          }
        },
        "required": [
          "refresh_token"
        ]
      },
      "rename-passkey-request": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "The new name."
          }
        },
        "required": [
          "name"
        ],
        "additionalProperties": false
      },
      "rename-slug-request": {
        "type": "object",
        "properties": {
          "from": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
          },
          "to": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
          }
        },
        "required": [
          "from",
          "to"
        ],
        "additionalProperties": false
      },
      "rename-slug-response": {
        "type": "object",
        "properties": {
          "rewritten_grants": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "history_moved": {
            "type": "boolean"
          },
          "requires_publish": {
            "type": "boolean",
            "const": true
          }
        },
        "required": [
          "rewritten_grants",
          "history_moved",
          "requires_publish"
        ],
        "additionalProperties": false
      },
      "resource-health-list-response": {
        "type": "object",
        "properties": {
          "resources": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "robot_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "camera"
                  ]
                },
                "ref": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 64
                },
                "facet": {
                  "type": "string",
                  "enum": [
                    "source",
                    "publish"
                  ]
                },
                "state": {
                  "type": "string",
                  "enum": [
                    "ok",
                    "unreachable",
                    "auth_failed",
                    "unreadable_credential",
                    "credential_missing",
                    "stopped_by_config_change",
                    "publish_failed",
                    "unknown"
                  ]
                },
                "reason": {
                  "anyOf": [
                    {
                      "type": "string",
                      "maxLength": 200
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "changed_at_ms": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 9007199254740991
                }
              },
              "required": [
                "robot_id",
                "kind",
                "ref",
                "facet",
                "state",
                "reason",
                "changed_at_ms"
              ],
              "additionalProperties": false
            }
          }
        },
        "required": [
          "resources"
        ],
        "additionalProperties": false
      },
      "robot-deletion-summary": {
        "type": "object",
        "properties": {
          "slug_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "sample_rows": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "bytes_freed": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "cameras": {
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 2,
              "maxLength": 63,
              "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
            }
          },
          "asset_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "asset_bytes_freed": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "What the robot's store gives back: every distinct mesh or texture blob it holds, counted once, URDF excluded; a blob another robot also references stays in the object store but is still credited here, because each robot's counter carries it."
          },
          "job_run_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "had_live_session": {
            "type": "boolean"
          },
          "had_unpublished_draft": {
            "type": "boolean"
          }
        },
        "required": [
          "slug_count",
          "sample_rows",
          "bytes_freed",
          "cameras",
          "asset_count",
          "asset_bytes_freed",
          "job_run_count",
          "had_live_session",
          "had_unpublished_draft"
        ],
        "additionalProperties": false
      },
      "robot-detail-response": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The robot, and what every robot-scoped route takes as its `:id`."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 63,
            "description": "The robot's display name, at most 63 characters. Free text, changed through `PATCH /api/robots/:id`."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the robot was created, as an ISO 8601 timestamp."
          },
          "bridge_state": {
            "type": "object",
            "properties": {
              "online": {
                "type": "boolean"
              },
              "latency_ms": {
                "anyOf": [
                  {
                    "type": "number",
                    "minimum": 0
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "low_bandwidth": {
                "type": "boolean",
                "description": "Whether the bridge is in its low-bandwidth mode: datapoints capped, cameras reduced or stopped. Bridge-reported."
              }
            },
            "required": [
              "online",
              "latency_ms",
              "low_bandwidth"
            ],
            "additionalProperties": false
          },
          "exposes": {
            "type": "object",
            "properties": {
              "datapoints": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "actions": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "services": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "publishers": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              "cameras": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              }
            },
            "required": [
              "datapoints",
              "actions",
              "services",
              "publishers",
              "cameras"
            ],
            "additionalProperties": false
          },
          "protocol_status": {
            "description": "Where this robot's bridge stands against the protocol window: `current`, `deprecated` (still served, sunset date on the detail), or `refused` (its last hello was refused for its version; offline until upgraded). Absent from a cloud older than 0.21.0; read absence as `current`.",
            "type": "string",
            "enum": [
              "current",
              "deprecated",
              "refused"
            ]
          },
          "bridge_version": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1
              },
              {
                "type": "null"
              }
            ]
          },
          "protocol_version": {
            "description": "The protocol version the bridge announced in its last accepted hello; `null` before the first. Absent from a cloud older than 0.21.0.",
            "anyOf": [
              {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ]
          },
          "protocol": {
            "description": "The window verdict for `protocol_version`.",
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "current",
                  "deprecated",
                  "refused"
                ],
                "description": "Same values as `protocol_status`."
              },
              "sunset_at": {
                "anyOf": [
                  {
                    "type": "string",
                    "format": "date",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "ISO date the announced version stops being served; `null` when current or unknown."
              }
            },
            "required": [
              "status",
              "sunset_at"
            ],
            "additionalProperties": false
          },
          "last_hello_error": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "code": {
                    "type": "string",
                    "minLength": 1
                  },
                  "message": {
                    "type": "string",
                    "minLength": 1
                  },
                  "at": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                  }
                },
                "required": [
                  "code",
                  "message",
                  "at"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ]
          },
          "config": {
            "type": "object",
            "properties": {
              "published_version": {
                "anyOf": [
                  {
                    "type": "integer",
                    "exclusiveMinimum": 0,
                    "maximum": 9007199254740991
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "published_at": {
                "anyOf": [
                  {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "draft_updated_at": {
                "anyOf": [
                  {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "applied_version": {
                "anyOf": [
                  {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "applied_ok": {
                "anyOf": [
                  {
                    "type": "boolean"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "applied_errors": {
                "anyOf": [
                  {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "slug": {
                          "type": "string"
                        },
                        "kind": {
                          "type": "string",
                          "enum": [
                            "datapoint",
                            "action",
                            "service",
                            "publisher",
                            "camera",
                            "low_bandwidth"
                          ]
                        },
                        "code": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 40
                        },
                        "message": {
                          "type": "string",
                          "minLength": 1
                        },
                        "details": {
                          "type": "object",
                          "propertyNames": {
                            "type": "string"
                          },
                          "additionalProperties": {}
                        }
                      },
                      "required": [
                        "slug",
                        "kind",
                        "code",
                        "message"
                      ],
                      "additionalProperties": false
                    }
                  },
                  {
                    "type": "null"
                  }
                ]
              }
            },
            "required": [
              "published_version",
              "published_at",
              "draft_updated_at",
              "applied_version",
              "applied_ok",
              "applied_errors"
            ],
            "additionalProperties": false
          }
        },
        "required": [
          "id",
          "name",
          "created_at",
          "bridge_state",
          "exposes",
          "bridge_version",
          "last_hello_error",
          "config"
        ],
        "additionalProperties": false
      },
      "robot-jobs-response": {
        "type": "object",
        "properties": {
          "jobs": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The job's id, minted by the cloud when the invocation is accepted. Informative — state is observed by slug; a cancel names this id to stop one specific job rather than whatever is running."
                },
                "robot_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The robot this job is running on."
                },
                "slug": {
                  "type": "string",
                  "minLength": 2,
                  "maxLength": 63,
                  "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
                  "description": "The action or service this job is running, as the published configuration exposes it. One slug carries one job at a time, so every observer of that slug sees the same one."
                },
                "state": {
                  "type": "string",
                  "enum": [
                    "running",
                    "unknown",
                    "succeeded",
                    "failed",
                    "cancelled",
                    "lost"
                  ],
                  "description": "Where the job stands: `running`, `unknown`, `succeeded`, `failed`, `cancelled` or `lost`. `unknown` is not an outcome — the robot went offline or silent and the cloud does not know yet; the slug stays occupied and the bridge's next statement resolves it, `error` naming why the cloud lost sight of it. `lost` is final: the bridge stated it does not know the job and nothing else runs on its action, or the action server vanished mid-goal."
                },
                "origin": {
                  "type": "string",
                  "enum": [
                    "fleetless",
                    "external"
                  ],
                  "description": "Who started this job. `fleetless` for everything minted by the cloud; `external` for a goal the bridge found active on a published action without having sent it — no parameters, no starter, never written to `job_runs`."
                },
                "started_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the cloud minted this job, as an ISO 8601 timestamp. For a job adopted from a reconnecting bridge, this is **adoption time**, not the real start — the cloud never minted it."
                },
                "updated_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When this job last changed, as an ISO 8601 timestamp."
                },
                "seq": {
                  "type": "integer",
                  "exclusiveMinimum": 0,
                  "maximum": 9007199254740991,
                  "description": "A monotonic counter ascending in mint order, and the named tiebreaker for any listing that claims one — `started_at` alone is not a total order. Scoped per cloud process and per run: job state lives in memory, so this restarts with the registry it orders."
                },
                "result": {
                  "anyOf": [
                    {},
                    {
                      "type": "null"
                    }
                  ],
                  "description": "What the call returned once it succeeded, shaped by the ROS action or service itself. `null` until then, and for a job that did not succeed."
                },
                "error": {
                  "anyOf": [
                    {
                      "type": "object",
                      "properties": {
                        "code": {
                          "type": "string",
                          "minLength": 1,
                          "description": "A machine-readable code for the failure, such as `job_queue_full`, where one exists for it."
                        },
                        "message": {
                          "type": "string",
                          "minLength": 1,
                          "description": "A human-readable sentence saying what went wrong."
                        },
                        "details": {
                          "description": "The structured payload belonging to `code`, for the codes that document one — `job_queue_full` carries its `limit` and its `queued` count here. Absent for a failure with nothing structured to add, which is most of them."
                        }
                      },
                      "required": [
                        "code",
                        "message"
                      ],
                      "additionalProperties": false
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "Why the job failed, or why the cloud does not know how it stands: a human `message`, a `code` where one exists, and `details` for the codes that carry a documented payload. Set on `failed` and `lost`, and on `unknown` — where `code` is `bridge_disconnected` or `bridge_timeout`, the cloud's own reason for not knowing, cleared when the bridge reports the job running again."
                }
              },
              "required": [
                "id",
                "robot_id",
                "slug",
                "state",
                "origin",
                "started_at",
                "updated_at",
                "seq",
                "result",
                "error"
              ],
              "additionalProperties": false
            },
            "description": "At most one entry per slug — the current job there — ordered newest **known** first, and never `null`: a robot doing nothing answers an empty array. This is not a history endpoint. For an adopted job `started_at` is adoption time, so a job that has been running for an hour can sit above one started a minute ago."
          }
        },
        "required": [
          "jobs"
        ],
        "additionalProperties": false
      },
      "robot-list-response": {
        "type": "object",
        "properties": {
          "robots": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The robot, and what every robot-scoped route takes as its `:id`."
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 63,
                  "description": "The robot's display name, at most 63 characters. Free text, changed through `PATCH /api/robots/:id`."
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the robot was created, as an ISO 8601 timestamp."
                },
                "bridge_state": {
                  "type": "object",
                  "properties": {
                    "online": {
                      "type": "boolean"
                    },
                    "latency_ms": {
                      "anyOf": [
                        {
                          "type": "number",
                          "minimum": 0
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "low_bandwidth": {
                      "type": "boolean",
                      "description": "Whether the bridge is in its low-bandwidth mode: datapoints capped, cameras reduced or stopped. Bridge-reported."
                    }
                  },
                  "required": [
                    "online",
                    "latency_ms",
                    "low_bandwidth"
                  ],
                  "additionalProperties": false
                },
                "exposes": {
                  "type": "object",
                  "properties": {
                    "datapoints": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991
                    },
                    "actions": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991
                    },
                    "services": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991
                    },
                    "publishers": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991
                    },
                    "cameras": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 9007199254740991
                    }
                  },
                  "required": [
                    "datapoints",
                    "actions",
                    "services",
                    "publishers",
                    "cameras"
                  ],
                  "additionalProperties": false
                },
                "protocol_status": {
                  "description": "Where this robot's bridge stands against the protocol window: `current`, `deprecated` (still served, sunset date on the detail), or `refused` (its last hello was refused for its version; offline until upgraded). Absent from a cloud older than 0.21.0; read absence as `current`.",
                  "type": "string",
                  "enum": [
                    "current",
                    "deprecated",
                    "refused"
                  ]
                }
              },
              "required": [
                "id",
                "name",
                "created_at",
                "bridge_state",
                "exposes"
              ],
              "additionalProperties": false
            }
          }
        },
        "required": [
          "robots"
        ],
        "additionalProperties": false
      },
      "robot-token-rotate-response": {
        "type": "object",
        "properties": {
          "token": {
            "type": "string",
            "pattern": "^frt_[0-9a-f]{32}$",
            "description": "The robot's new bridge token. Returned exactly once; the previous token stops working at the bridge's next hello."
          }
        },
        "required": [
          "token"
        ],
        "additionalProperties": false
      },
      "role": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The role, and what an app user's `role_id` and an app's `default_role_id` refer to."
          },
          "app_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The app this role belongs to. Roles are never shared between apps, so a role id from another app reads as `not_found`."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60,
            "description": "The role's name, shown wherever a user's access is chosen. The two roles every app starts with are named `observe` and `operate`."
          },
          "builtin": {
            "type": "boolean",
            "description": "`true` for the two roles every app starts with. Their **rights may be re-scoped** exactly like a custom role's, through `PUT /api/apps/:id/roles/:roleId/permissions` — the flag exists so the console can explain where they came from, not to protect them. Built-in roles can be renamed and deleted like any other; the flag only records that the cloud seeded them."
          }
        },
        "required": [
          "id",
          "app_id",
          "name",
          "builtin"
        ],
        "additionalProperties": false
      },
      "role-list-response": {
        "type": "object",
        "properties": {
          "roles": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The role, and what an app user's `role_id` and an app's `default_role_id` refer to."
                },
                "app_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The app this role belongs to. Roles are never shared between apps, so a role id from another app reads as `not_found`."
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 60,
                  "description": "The role's name, shown wherever a user's access is chosen. The two roles every app starts with are named `observe` and `operate`."
                },
                "builtin": {
                  "type": "boolean",
                  "description": "`true` for the two roles every app starts with. Their **rights may be re-scoped** exactly like a custom role's, through `PUT /api/apps/:id/roles/:roleId/permissions` — the flag exists so the console can explain where they came from, not to protect them. Built-in roles can be renamed and deleted like any other; the flag only records that the cloud seeded them."
                }
              },
              "required": [
                "id",
                "app_id",
                "name",
                "builtin"
              ],
              "additionalProperties": false
            },
            "description": "The app's roles, built-in and custom alike, ordered by `created_at` and then by `name`. The tie-break is not cosmetic — the two built-in roles are inserted in one statement and share a creation time to the microsecond, so never read a role by position."
          }
        },
        "required": [
          "roles"
        ],
        "additionalProperties": false
      },
      "role-permissions": {
        "type": "object",
        "properties": {
          "role_id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
          },
          "grants": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "robot_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                },
                "slugs": {
                  "type": "array",
                  "items": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 63,
                    "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
                  }
                }
              },
              "required": [
                "robot_id",
                "slugs"
              ]
            }
          },
          "capabilities": {
            "type": "object",
            "properties": {
              "action_history": {
                "type": "boolean"
              },
              "presence": {
                "type": "boolean"
              },
              "assets": {
                "type": "boolean"
              }
            },
            "required": [
              "action_history",
              "presence",
              "assets"
            ]
          }
        },
        "required": [
          "role_id",
          "grants",
          "capabilities"
        ]
      },
      "role-rename-request": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60,
            "description": "The new name, trimmed, 1 to 60 characters. Unique per app: another role of this app with the same name answers `409 role_name_taken`. The role's users keep it under its new name."
          }
        },
        "required": [
          "name"
        ],
        "additionalProperties": false
      },
      "server-key-list-response": {
        "type": "object",
        "properties": {
          "server_keys": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The key row, and what the rotate and delete routes address. It is not the key: this shape never carries the secret."
                },
                "app_id": {
                  "type": "string",
                  "format": "uuid",
                  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
                  "description": "The app whose full rights this key carries. A key is never shared between apps."
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 120,
                  "description": "A label the developer chose, so a key can be recognised before it is rotated or deleted."
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time",
                  "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
                  "description": "When the key was minted, as an ISO 8601 timestamp. `GET /api/apps/:id/server-keys` orders by this field."
                },
                "last_used_at": {
                  "anyOf": [
                    {
                      "type": "string",
                      "format": "date-time",
                      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "description": "When this key last authenticated a request, or `null` if it never has — the cheapest way to spot a key nobody needs."
                }
              },
              "required": [
                "id",
                "app_id",
                "name",
                "created_at",
                "last_used_at"
              ],
              "additionalProperties": false
            },
            "description": "The app's server keys as metadata, oldest first by `created_at`. The raw secret is not here and never will be: it exists once, in the response that created or rotated the key."
          }
        },
        "required": [
          "server_keys"
        ],
        "additionalProperties": false
      },
      "session-tokens": {
        "type": "object",
        "properties": {
          "access_token": {
            "type": "string",
            "minLength": 1,
            "description": "The token to send as `Authorization: Bearer <token>` on every call. Short-lived: read `expires_in` rather than assuming a lifetime."
          },
          "refresh_token": {
            "type": "string",
            "minLength": 1,
            "description": "The token that buys the next access token. It rotates on every use, so a value presented twice is detectable theft and ends the whole family."
          },
          "expires_in": {
            "type": "integer",
            "exclusiveMinimum": 0,
            "maximum": 9007199254740991,
            "description": "How long the access token stays valid, in **seconds** from now. Not a timestamp, and not milliseconds."
          }
        },
        "required": [
          "access_token",
          "refresh_token",
          "expires_in"
        ],
        "additionalProperties": false
      },
      "slug-usage-response": {
        "type": "object",
        "properties": {
          "grant_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "app_identifiers": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "has_recorded_history": {
            "type": "boolean"
          },
          "alert_count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "grant_count",
          "app_identifiers",
          "has_recorded_history",
          "alert_count"
        ],
        "additionalProperties": false
      },
      "snapshot-meta-response": {
        "type": "object",
        "properties": {
          "slug": {
            "type": "string",
            "minLength": 2,
            "maxLength": 63,
            "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
            "description": "The camera this snapshot belongs to."
          },
          "timestamp_ms": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ],
            "description": "When the stored frame was captured, as a unix timestamp in milliseconds. `null` means nothing has been captured yet, which is an answer rather than an error."
          },
          "age_ms": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ],
            "description": "How old the stored frame is right now, in milliseconds; `null` when there is none. Snapshots are deliberately cheap and therefore deliberately old, and a cached frame served without its age is indistinguishable from a live one."
          },
          "width": {
            "anyOf": [
              {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ],
            "description": "Width of the stored frame in pixels, or `null` when nothing has been captured yet."
          },
          "height": {
            "anyOf": [
              {
                "type": "integer",
                "exclusiveMinimum": 0,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ],
            "description": "Height of the stored frame in pixels, or `null` when nothing has been captured yet."
          },
          "mime": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "The media type of the stored frame, such as `image/jpeg`, or `null` when nothing has been captured yet."
          }
        },
        "required": [
          "slug",
          "timestamp_ms",
          "age_ms",
          "width",
          "height",
          "mime"
        ],
        "additionalProperties": false
      },
      "team-invite": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
            "description": "The invitation, as listed and revoked by the team."
          },
          "email": {
            "type": "string",
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
            "description": "The address the invitation was addressed to."
          },
          "tier": {
            "type": "string",
            "enum": [
              "owner",
              "developer"
            ],
            "description": "The tier the invitee holds on acceptance, fixed when the invitation was created."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
            "description": "When the token stops working. Seven days from issue; an expired token answers exactly as an unknown one does."
          },
          "accept_url": {
            "type": "string",
            "maxLength": 500,
            "format": "uri",
            "description": "The link to give the invitee, on the Fleetless auth portal. Bounded like `idpIssuer`: an unbounded URL on a shape that gets mailed, logged and rendered is a size nobody chose. Unlike an app invitation's link this is never `null` — the portal is a page Fleetless does serve."
          },
          "mail": {
            "type": "string",
            "enum": [
              "sent",
              "not_requested",
              "not_configured",
              "failed"
            ],
            "description": "What happened to the mail. `not_configured` is an expected state and not a failure; the link above is the primary path."
          }
        },
        "required": [
          "id",
          "email",
          "tier",
          "expires_at",
          "accept_url",
          "mail"
        ],
        "additionalProperties": false
      },
      "tier-change-request": {
        "type": "object",
        "properties": {
          "tier": {
            "type": "string",
            "enum": [
              "owner",
              "developer"
            ]
          }
        },
        "required": [
          "tier"
        ],
        "additionalProperties": false
      },
      "totp-confirm-request": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "pattern": "^\\d{6}$",
            "description": "A code the new authenticator shows now. It proves the secret was copied correctly before anything depends on it."
          }
        },
        "required": [
          "code"
        ],
        "additionalProperties": false
      },
      "totp-confirm-response": {
        "type": "object",
        "properties": {
          "recovery_codes": {
            "anyOf": [
              {
                "minItems": 10,
                "maxItems": 10,
                "type": "array",
                "items": {
                  "type": "string",
                  "pattern": "^[a-z2-7]{5}-[a-z2-7]{5}$"
                }
              },
              {
                "type": "null"
              }
            ],
            "description": "The ten recovery codes, shown once, when this is the account's first second factor; `null` otherwise."
          }
        },
        "required": [
          "recovery_codes"
        ],
        "additionalProperties": false
      },
      "two-factor-setup-response": {
        "type": "object",
        "properties": {
          "secret": {
            "type": "string",
            "minLength": 1,
            "description": "The shared secret, base32, for an authenticator app that cannot scan a QR code. Shown once; the cloud stores it encrypted."
          },
          "otpauth_url": {
            "type": "string",
            "pattern": "^otpauth:\\/\\/totp\\/.*",
            "description": "The same secret as an `otpauth://totp/` URL, to render as a QR code. It carries the secret: never log it."
          }
        },
        "required": [
          "secret",
          "otpauth_url"
        ],
        "additionalProperties": false
      },
      "types-response": {
        "type": "object",
        "properties": {
          "types": {
            "type": "array",
            "items": {
              "oneOf": [
                {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$"
                    },
                    "kind": {
                      "type": "string",
                      "const": "msg"
                    },
                    "fields": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/types-response--__schema0"
                      }
                    }
                  },
                  "required": [
                    "name",
                    "kind",
                    "fields"
                  ],
                  "additionalProperties": false
                },
                {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$"
                    },
                    "kind": {
                      "type": "string",
                      "const": "srv"
                    },
                    "request": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/types-response--__schema0"
                      }
                    },
                    "response": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/types-response--__schema0"
                      }
                    }
                  },
                  "required": [
                    "name",
                    "kind",
                    "request",
                    "response"
                  ],
                  "additionalProperties": false
                },
                {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "maxLength": 255,
                      "pattern": "^[a-z][a-z0-9_]*\\/(?:msg|srv|action)\\/[A-Za-z][A-Za-z0-9]*$"
                    },
                    "kind": {
                      "type": "string",
                      "const": "action"
                    },
                    "goal": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/types-response--__schema0"
                      }
                    },
                    "result": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/types-response--__schema0"
                      }
                    },
                    "feedback": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/types-response--__schema0"
                      }
                    }
                  },
                  "required": [
                    "name",
                    "kind",
                    "goal",
                    "result",
                    "feedback"
                  ],
                  "additionalProperties": false
                }
              ]
            }
          }
        },
        "required": [
          "types"
        ],
        "additionalProperties": false
      },
      "types-response--__schema0": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 128
          },
          "type": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "array": {
            "type": "boolean"
          },
          "fields": {
            "anyOf": [
              {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/types-response--__schema0"
                }
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "name",
          "type",
          "array",
          "fields"
        ],
        "additionalProperties": false
      },
      "update-app-request": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "robot_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
            }
          },
          "default_role_id": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "additionalProperties": false
      },
      "vat-id-check-request": {
        "type": "object",
        "properties": {
          "country": {
            "type": "string",
            "pattern": "^[A-Z]{2}$",
            "description": "The VAT ID's country."
          },
          "vat_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 20,
            "description": "The VAT ID as typed; normalized before the VIES lookup (`normalizeVatId`)."
          }
        },
        "required": [
          "country",
          "vat_id"
        ],
        "additionalProperties": false
      },
      "vat-id-check-response": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "valid",
              "unverified",
              "invalid"
            ],
            "description": "VIES's answer."
          },
          "vat_id": {
            "type": "string",
            "description": "The normalized VAT ID that was checked."
          },
          "name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "The registered holder, when VIES named one; `null` otherwise."
          }
        },
        "required": [
          "status",
          "vat_id",
          "name"
        ],
        "additionalProperties": false
      },
      "waitlist-request": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "maxLength": 254,
            "format": "email",
            "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
          }
        },
        "required": [
          "email"
        ]
      },
      "webauthn-options-response": {
        "type": "object",
        "properties": {
          "options": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {},
            "description": "The `PublicKeyCredentialCreationOptionsJSON` or `PublicKeyCredentialRequestOptionsJSON` to pass to the browser. Its challenge is single-use and short-lived."
          }
        },
        "required": [
          "options"
        ],
        "additionalProperties": false
      }
    }
  }
}
