openapi: 3.1.0

info:
  title: prorm control plane
  version: "1.0.0"
  summary: Provision and run databases and data stores.
  description: |
    The API behind prod-orm.io: accounts, provisioning, the long-running cloud
    actions that follow, and the billing and teams around them.

    Two conventions run through the whole surface.

    **Nothing takes SQL.** A query is described — `select`, `insert`, `update`,
    `delete` — and a schema change is a list of operations. The ORM compiles
    those for whichever engine they are going to, which is why the same
    definition works on sqlite and postgres, and why no statement a client sends
    can reach an engine.

    **Anything that creates something takes `Idempotency-Key`.** Send one and a
    retry replays the first response instead of creating a second thing. The key
    comes back on the response either way.

    **Signing in.** A route that takes a bearer token also takes a klock v2
    device-signed request (`Authorization: Klock <grant>` plus an RFC 9421
    signature; see the `Klock` security scheme). What a caller may do is a set
    of capabilities; the one each operation needs is in its description and in
    `x-prorm-capability` (`needs`, the enforcement `area`, and whether it needs
    a fresh `stepUp`). Whether a missing capability is refused, only audited,
    or let through is the area's enforcement mode (`CAPABILITY_ENFORCEMENT`);
    a team member's role is always checked.

    **Refusals.** The klock v2 routes, and every route added with them, answer
    `{ "error": "<code>", "message": "…" }`, the code from the klock error
    catalog (`KlockErrorCode`). Older routes answer `{ "error": "<sentence>" }`.
  contact:
    name: prorm
    url: https://prod-orm.io

servers:
  # api.prod-orm.io serves at its root (API_BASE_PATH is empty there); a local
  # run keeps the /api prefix by default.
  - url: https://api.prod-orm.io
    description: production
  - url: http://localhost:3000/api
    description: a machine you are sitting at

tags:
  - name: Service
    description: Liveness and this description.
  - name: Sign-up and verification
    description: Creating an account, signing in with a password, proving an address is yours, and the guardian consent a younger account needs.
  - name: Profile
    description: The signed-in account, its addresses, and the capabilities its session carries.
  - name: Family
    description: 'The accounts this account is guardian of: who they are and the restrictions on them. Their activity is under Audit log.'
  - name: Sessions
    description: 'Signed-in sessions: listing and ending them, CLI sign-in and hand-off — a person''s own, a team''s members'' (for a role with `session:manage-members`), and staff''s.'
  - name: MFA and passwords
    description: TOTP factors, and resetting a forgotten password.
  - name: Single sign-on
    description: SAML/OIDC sign-in, linking an identity, and a team's SSO connection and claimed domains.
  - name: Klock
    description: 'klock v2 device-bound sessions: opening one, refreshing its grant, stepping up, rotating the device key, freezing it; the guest grant; the revocation feed; and the public keyset and configuration under `/.well-known`.'
  - name: Devices
    description: The devices an account has enrolled and pairing a new one — a person's own, a team's members' (for a role with `device:manage-members`), and staff's.
  - name: Access rules and behaviour
    description: Allow and block rules — by address, country, device, time — on an account, a team or a resource, and behaviour profiles with the findings they raise. A team's rules are mandatory for its members.
  - name: Recovery and lockdown
    description: An account's emergency switch, the way back in when locked out, and locking a team member down.
  - name: Kerberos
    description: A team's Kerberos realm.
  - name: MCP tokens
    description: Delegated MCP clients and their narrow tokens, and the forward the MCP Worker calls.
  - name: Audit log
    description: 'What happened, and who did it: an account''s audit trail, a user''s (staff), and a child''s activity (guardians).'
  - name: Metrics and events
    description: A database's metrics and its event history.
  - name: Operations
    description: 'The work that takes longer than a request: listed, followed, cancelled and retried — per database, and (staff) across the platform.'
  - name: Databases
    description: Creating, changing and deleting databases, querying them, and the engines and regions on offer.
  - name: Data stores
    description: 'Key-value and object stores: creating, sizing and connecting to them.'
  - name: Connections and credentials
    description: API keys, engine users, SSH keys, network access and connection strings.
  - name: Passwordless
    description: 'Passwordless database sessions: v1 signed grants, and v2 service sessions bound to a host''s device key.'
  - name: Database roles
    description: Roles on a database, the grants between them, and the keys issued under them.
  - name: Schema and objects
    description: Tables, views, triggers, indexes and extensions, described rather than written.
  - name: Migrations
    description: Planning, previewing and applying schema migrations.
  - name: Backups and replicas
    description: Backups, restores and read replicas.
  - name: Lifecycle
    description: Resizing, pausing, resuming and moving a database.
  - name: Policies
    description: One hundred and fifty controls a database can be held to — "ten refused credentials in five minutes rotates the credentials" — and what is switched on for which database. The catalog is code; the assignment is data.
  - name: Pools
    description: Connection pools in front of a database.
  - name: Transfers
    description: 'Moving data between databases: strategies, quotes, jobs, the events a runner reports, and what a local run is metered at.'
  - name: Data plane
    description: What a client library talks to. Mounted at /proxy, outside the control plane's base path, because that address should not move when the prefix does. API keys only.
  - name: Teams
    description: Creating teams, changing and leaving them, and handing one over.
  - name: Members
    description: A team's members and invitations.
  - name: Projects
    description: A team's projects and the resources in them.
  - name: Plans and pricing
    description: Plans, rates, quotes and estimates.
  - name: Subscriptions and invoices
    description: Your subscription, invoices, checkout and the billing portal, and the Stripe webhook.
  - name: Team billing
    description: 'A team''s billing account: its plan, cards, invoices and contact, and who pays for its transfers.'
  - name: Support chat
    description: Conversations — one to one, a team channel, a team and support — over HTTP, with a socket for the moment.
  - name: Users
    description: 'Staff: finding users, and changing their role, plan, verification and lock. Everything under Administration is for an account with the admin role and, route by route, the admin *scope* it needs: `dashboard`, `users`, `resources`, `operations`, `billing`, `logs`, `support`, `email`, `dev`, `grant-admin`, `assume`. A refusal names the missing scope, and everything here is audited. A klock v2 session''s `admin:*` capabilities are dormant until a fresh step-up: without one, an admin route answers 401 `step_up_required` (`WWW-Authenticate: Klock error="step_up_required"`). With enforcement E3 on (`CAPABILITY_E3`), an admin with no confirmed TOTP second factor is answered 403 `mfa_required`, which names the enrolment routes, and an `admin:*` capability on an unsafe method needs a fresh step-up for a browser session too.'
  - name: Staff and permissions
    description: 'Staff: who the admins are and what each may do.'
  - name: Team administration
    description: 'Staff: any team, its members and its ownership.'
  - name: Assumed identities
    description: 'Staff: acting as a user, with their consent and on the record.'
  - name: Support inbox
    description: 'Staff, behind the `email` scope: the addresses the platform answers mail at, routing, messages, notes and replies.'
  - name: Platform
    description: 'Staff: every resource and platform-wide statistics.'

# Redoc, Scalar and most other renderers show the sidebar in these groups.
x-tagGroups:
  - name: Getting started
    tags:
      - Service
  - name: Account
    tags:
      - Sign-up and verification
      - Profile
      - Family
  - name: Security
    tags:
      - Sessions
      - MFA and passwords
      - Single sign-on
      - Klock
      - Devices
      - Access rules and behaviour
      - Recovery and lockdown
      - Kerberos
      - MCP tokens
  - name: Logging and monitoring
    tags:
      - Audit log
      - Metrics and events
      - Operations
  - name: Databases
    tags:
      - Databases
      - Data stores
      - Connections and credentials
      - Passwordless
      - Database roles
      - Schema and objects
      - Migrations
      - Backups and replicas
      - Lifecycle
      - Policies
      - Pools
      - Transfers
      - Data plane
  - name: Teams
    tags:
      - Teams
      - Members
      - Projects
  - name: Billing
    tags:
      - Plans and pricing
      - Subscriptions and invoices
      - Team billing
  - name: Support
    tags:
      - Support chat
  - name: Administration
    tags:
      - Users
      - Staff and permissions
      - Team administration
      - Assumed identities
      - Support inbox
      - Platform

security:
  - bearerAuth: []
  - Klock: []

paths:
  /health:
    servers:
      - url: https://api.prod-orm.io
        description: at the host root, outside the API's base path
    get:
      tags: [Service]
      summary: Is it up
      security: []
      responses:
        "200":
          description: It is
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }

  /openapi.yml:
    servers:
      - url: https://api.prod-orm.io
        description: at the host root, outside the API's base path
    get:
      tags: [Service]
      summary: This description
      description: |
        The OpenAPI document, exactly as it is in the repository. It is
        generated into the Worker bundle (`spec.ts`) by `bun run
        templates:build`, because a Worker has no filesystem to read it from.
      security: []
      responses:
        "200":
          description: The spec
          content:
            text/yaml:
              schema: { type: string }

  /signup:
    post:
      tags: [Sign-up and verification]
      summary: Create an account
      description: |
        Answers 201 with no token. The address is unproven until it receives the
        code and something answers with it, so signing up does not sign you in.
      security: []
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SignupRequest" }
      responses:
        "201":
          description: >-
            Created. For 16 and over, a code is on its way and `verification`
            says where. Under 16 nothing is sent yet: `next` is `guardian` and
            `continuation` is the token POST /guardian/nominate takes.
          headers:
            Idempotency-Key: { $ref: "#/components/headers/IdempotencyKey" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  user: { $ref: "#/components/schemas/User" }
                  next: { type: string, enum: [guardian], description: Present only under 16 }
                  continuation: { type: string, description: Present only under 16; valid for an hour }
                  nominateAt: { type: string }
                  verification:
                    type: object
                    properties:
                      sentTo: { type: string, format: email }
                      expiresIn: { type: integer, description: seconds }
                      verifyAt: { type: string }
                      resendAt: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/Conflict" }

  /signin:
    post:
      tags: [Sign-up and verification]
      summary: Exchange credentials for a token
      description: |
        Takes an email address, a username, or a `login` field holding either.
        Eight failures against one identifier in fifteen minutes and it stops
        answering for a while.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SigninRequest" }
      responses:
        "200":
          description: Signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Token" }
        "401":
          description: Wrong credentials, an unknown account, or too many attempts
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: |
            The account has not verified its address yet, is waiting on a
            parent, is paused — or belongs to a team that requires single
            sign-on, in which case `sso.startAt` is where to go instead.

  /verify:
    post:
      tags: [Sign-up and verification]
      summary: Verify with the emailed code
      description: Verifying is the proof signin would have asked for, so it also signs you in.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string, pattern: "^[0-9]{6}$" }
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Token" }
        "400": { description: "The code is wrong, expired, or there is none outstanding" }
        "429": { description: Too many wrong codes; ask for a new one }
    get:
      tags: [Sign-up and verification]
      summary: Verify by clicking the emailed link
      security: []
      parameters:
        - name: token
          in: query
          required: true
          schema: { type: string }
          description: The signed token from the email. Single use.
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Token" }
        "401": { description: "The link is invalid, expired, or already used" }

  /guardian/nominate:
    post:
      tags: [Sign-up and verification]
      summary: Name the parent or guardian
      description: |
        The parental-information step, after signup and before anything is
        sent. Takes the `continuation` from signup (or from a refused signin),
        the parent's address and username, and what they are to the child.

        Only then do two emails leave together: the child's verification code,
        and the request to the parent. A parent who already has an account at
        that address AND username is asked to approve; one who does not is
        asked to create an account first, and told the username so the request
        finds them.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [continuation, guardianEmail, guardianUsername]
              properties:
                continuation: { type: string }
                guardianEmail: { type: string, format: email }
                guardianUsername: { type: string, minLength: 3, maxLength: 30 }
                relationship: { type: string, enum: [parent, guardian, other], default: guardian }
      responses:
        "200":
          description: Both emails sent (or which one failed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  guardian:
                    type: object
                    properties:
                      email: { type: string, format: email }
                      username: { type: string }
                      relationship: { type: string }
                      hasAccount: { type: boolean }
                      emailFailed: { type: boolean }
                      resendAt: { type: string }
                  verification:
                    type: object
                    properties:
                      sentTo: { type: string, format: email }
                      emailFailed: { type: boolean }
                      verifyAt: { type: string }
                      resendAt: { type: string }
        "400": { description: "Expired continuation, the child's own details, or an account that does not need one" }

  /guardian/consent:
    get:
      tags: [Sign-up and verification]
      summary: What a consent link is about
      description: >-
        Unauthenticated, because the parent arrives from an email and has not
        signed in yet. Says as little as it can — a first name, the address the
        child signed up with, and which address must approve.
      security: []
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: The request
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    properties:
                      childName: { type: string }
                      childEmail: { type: string, format: email }
                      guardianEmail: { type: string, format: email }
                      approved: { type: boolean }
                      approveAs: { type: string, format: email, description: The address a session must hold to approve }
        "400": { description: "The token is not valid, or has expired" }
        "404": { description: No such request }
    post:
      tags: [Sign-up and verification]
      summary: Give consent for a child's account
      description: >-
        Authenticated on purpose: the link identifies the child, and being the
        guardian is proved by a session on an account that is 18 or over and at
        the address the child named. Idempotent — approving twice changes
        nothing. Records the relationship and makes the approver a `parent`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties: { token: { type: string } }
      responses:
        "200": { description: Approved }
        "400": { description: The token is not valid }
        "401": { description: Sign in first }
        "403": { description: "Not the named guardian, not an adult, or the child itself" }

  /guardian/resend:
    post:
      tags: [Sign-up and verification]
      summary: Ask the parent or guardian again
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties: { email: { type: string, format: email, description: The CHILD's address } }
      responses:
        "202": { description: The same answer whether or not anything was sent }

  /family/children:
    get:
      tags: [Family]
      summary: Everyone this account is responsible for
      description: >-
        Only accounts the caller is recorded as a parent or guardian of, from
        consent having been given. A parent of somebody is not a parent of
        everybody.
      responses:
        "200":
          description: The children, with a summary of what each holds
          content:
            application/json:
              schema:
                type: object
                properties:
                  children:
                    type: array
                    items: { $ref: "#/components/schemas/ChildSummary" }

  /family/children/{childId}:
    get:
      tags: [Family]
      summary: One child, in detail
      description: >-
        Resources as the child would see them described — never a credential.
        Oversight is not impersonation; nothing here lets the parent act as the
        child rather than about them.
      parameters: [{ $ref: "#/components/parameters/ChildId" }]
      responses:
        "200":
          description: The account, its resources, and its running cost
          content:
            application/json:
              schema:
                type: object
                properties:
                  relation: { $ref: "#/components/schemas/Relation" }
                  child: { $ref: "#/components/schemas/User" }
                  databases: { type: array, items: { $ref: "#/components/schemas/Database" } }
                  stores: { type: array, items: { type: object } }
                  estimate: { type: object }
                  restrictions: { $ref: "#/components/schemas/Restrictions" }
        "404": { description: Not a child of the caller. Deliberately not 403 }

  /family/children/{childId}/activity:
    get:
      tags: [Audit log]
      summary: What has been done to the account
      description: >-
        The audit log — signins, keys created, databases provisioned. Not the
        query log: what a child ran against their own database is their data.
      parameters:
        - { $ref: "#/components/parameters/ChildId" }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
      responses:
        "200": { description: "Audit entries, newest first" }
        "404": { description: Not a child of the caller }

  /family/children/{childId}/restrictions:
    put:
      tags: [Family]
      summary: Turn a limit on or off
      description: >-
        Both switches only ever restrict. There is no route that creates,
        deletes or spends on the child's behalf.
      parameters: [{ $ref: "#/components/parameters/ChildId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                provisioning: { type: boolean, description: May the account create databases and stores }
                signin: { type: boolean, description: May the account be signed in to at all }
      responses:
        "200":
          description: The restrictions as they now stand
          content:
            application/json:
              schema:
                type: object
                properties:
                  restrictions: { $ref: "#/components/schemas/Restrictions" }
        "400": { description: Neither switch was sent }
        "404": { description: Not a child of the caller }

  /verify/resend:
    post:
      tags: [Sign-up and verification]
      summary: Send a new code and link
      description: Answers the same whether or not the address has an account waiting.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "202": { description: Accepted }

  /me:
    get:
      tags: [Profile]
      summary: Who am I
      responses:
        "200":
          description: The signed-in account
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
    put:
      tags: [Profile]
      summary: Change the account
      description: |
        Every field is optional, but at least one of `firstname`, `lastname`,
        `email` or `password` must be sent. Changing the address or the
        password additionally requires `current-password`, because a bearer
        token alone should not be enough to take an account over. The account
        must be verified.

        A new address must be proved before it becomes the sign-in address. One
        already confirmed on the account switches at once (200), and both
        addresses are told. Any other is added to the account's addresses
        unconfirmed, marked as the requested sign-in address, and a code is
        mailed to it; the answer is 202 with `emailVerification`, and the
        sign-in address stays as it was until the code is confirmed at
        `/me/emails/{id}/verify` — which completes the change. Only one change
        is pending at a time: a later `PUT /me` with another address replaces
        it, and one with the current address (or an already-confirmed one)
        withdraws it.

        A new password ends every other session of the account (service
        sessions stay); the session making the change is handed a fresh token.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateMeRequest" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UpdateMeResult" }
        "202":
          description: Any other changes are made; the new address waits for its code
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/UpdateMeResult"
                  - type: object
                    required: [emailVerification]
                    properties:
                      emailVerification:
                        type: object
                        properties:
                          address: { type: string, format: email }
                          sent: { type: boolean, description: False when the code could not be sent }
                          reason: { type: string, description: "Why it was not sent, when codes for the address are rationed" }
                          pending: { type: boolean, enum: [true], description: The change waits for the code }
                          message: { type: string }
                          verifyAt: { type: string, example: /api/me/emails/12/verify }
                          resendAt: { type: string, example: /api/me/emails/12/resend }
        "400":
          description: |
            A field failed its rules (`current-password` missing when changing
            the address or password, passwords that do not match, a password
            under 8 characters, an address already held by another account), or
            nothing to change was sent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationErrors" }
        "401": { description: "No valid session, or `Current password is incorrect`" }
        "403": { description: The account is not verified yet }
        "409":
          description: The address was taken by another account between the check and the save
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationErrors" }

  /me/emails:
    get:
      tags: [Profile]
      summary: Every address on the account
      responses:
        "200":
          description: The addresses
          content:
            application/json:
              schema:
                type: object
                properties:
                  emails:
                    type: array
                    items: { $ref: "#/components/schemas/Address" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
    post:
      tags: [Profile]
      summary: Add an address
      description: |
        It is not the address you sign in with until it is promoted, and it
        counts for nothing — it cannot be promoted, does not reserve the
        address, and is not an identity single sign-on links to — until the
        six-digit code mailed to it is sent to `/me/emails/{id}/verify`. The
        account's own address is told it was added. The account must be
        verified.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "201":
          description: Added
          headers:
            Idempotency-Key: { $ref: "#/components/headers/IdempotencyKey" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  email: { $ref: "#/components/schemas/Address" }
                  verification:
                    type: object
                    properties:
                      sentTo: { type: string, format: email }
                      sent: { type: boolean, description: False when the code could not be sent }
                      reason: { type: string, description: "Why it was not sent, when codes for the address are rationed" }
                      verifyAt: { type: string }
                      resendAt: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { description: The account is not verified yet }
        "409":
          description: The address is already on this account (a racing duplicate)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationErrors" }

  /me/emails/{id}/verify:
    post:
      tags: [Profile]
      summary: Confirm an address with the code mailed to it
      description: |
        An address that is already confirmed answers 200 with `That address is
        already confirmed`.

        When the address was asked for as the sign-in address with `PUT /me`
        (within the last day), confirming it makes it the sign-in address: the
        answer then carries `user` and `emails` as well, and both addresses
        are told. Otherwise the account's address is told it was added.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, pattern: '^\d{6}$' }
      responses:
        "200":
          description: Confirmed (or already confirmed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  email: { $ref: "#/components/schemas/Address" }
                  user:
                    allOf: [{ $ref: "#/components/schemas/User" }]
                    description: Only when this confirmed a pending sign-in address change
                  emails:
                    type: array
                    items: { $ref: "#/components/schemas/Address" }
                    description: Only when this confirmed a pending sign-in address change
        "400":
          description: Not six digits; or a wrong, expired or spent code, or none waiting (five wrong guesses spend a code)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  resendAt: { type: string }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { description: The account is not verified yet }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Another account holds the address now }
        "429":
          description: Too many wrong codes for this address today, across every account asking for it; or the strict rate-limit tier
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  resendAt: { type: string }

  /me/emails/{id}/resend:
    post:
      tags: [Profile]
      summary: Mail a fresh code to an unconfirmed address
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "202":
          description: On its way
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { description: The account is not verified yet }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Already confirmed }
        "429":
          description: A code went to this address too recently, or it has had its codes for the day; or the strict rate-limit tier
          headers:
            Retry-After: { schema: { type: integer }, description: seconds }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /me/emails/{id}/primary:
    put:
      tags: [Profile]
      summary: Make an address primary
      description: |
        This changes the address the account signs in with, so it asks for the
        password like every other change to a credential. It also withdraws a
        pending `PUT /me` address change. Both addresses are told.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current-password]
              properties:
                current-password: { type: string, format: password }
      responses:
        "200":
          description: Promoted (or it already was); the previous primary stays on the account
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  user: { $ref: "#/components/schemas/User" }
                  emails:
                    type: array
                    items: { $ref: "#/components/schemas/Address" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { description: The current password is wrong }
        "403": { description: The account is not verified yet }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "The address has not been confirmed yet (the body has `resendAt`)" }

  /me/emails/{id}:
    delete:
      tags: [Profile]
      summary: Remove an address
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { description: The account is not verified yet }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: It is the primary one; promote another first }

  /me/capabilities:
    get:
      tags: [Profile]
      summary: What this caller may do
      description: |
        The caller's tier, the live capability ceiling, what a step-up would
        unlock (`dormant`), their teams and roles, admin scopes, the plan and
        its entitlements, the session limits by kind, and the session asking.

        Any credential — a bearer, a klock-signed request (a guest grant too),
        an api key — or none, in which case the answer is the guest set
        (`source: guest`). An api key answers its own capabilities with
        `resourceLock`. `?team=` resolves the answer in that team's context.
        Answered `Cache-Control: private, no-store`.
      security:
        - {}
        - bearerAuth: []
        - Klock: []
        - apiKeyAuth: []
      x-prorm-capability:
        needs: null
        checkedIn: route
        note: Public; any credential or none (optionalAuth). Reports capabilities, checks none.
      parameters:
        - name: team
          in: query
          required: false
          description: A team's id, slug or uuid the caller is an active member of
          schema: { type: string }
      responses:
        "200":
          description: The capabilities
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilitiesView" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404":
          description: "`team_not_found` — the caller is not an active member of that team"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: team_not_found, message: You are not an active member of that team }
        "503":
          description: "`capabilities_unavailable` — a bearer was accepted but its capabilities could not be resolved; or `klock_v2_unavailable`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: capabilities_unavailable, message: Could not resolve capabilities right now }

  /provision/databases:
    get:
      tags: [Databases]
      summary: Every database on the account
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "provision", "needs": "resource:read", "area": "resource", "resource": "database"}
      responses:
        "200":
          description: The databases
          content:
            application/json:
              schema:
                type: object
                properties:
                  databases:
                    type: array
                    items: { $ref: "#/components/schemas/Database" }
    post:
      tags: [Databases]
      summary: Provision a database
      description: |
        A managed `sqlite` database is built on Cloudflare D1 during the request,
        and comes back `active` with somewhere to connect. Every other engine is
        recorded as `provisioning` until something builds it.

        **Capability:** `resource:provision` (area `resource`, on the database). With a team context: resource:provision in that team.
      x-prorm-capability: {"router": "provision", "needs": "resource:provision", "area": "resource", "resource": "database", "note": "with a team context: resource:provision in that team"}
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewDatabase" }
      responses:
        "201":
          description: Created
          headers:
            Idempotency-Key: { $ref: "#/components/headers/IdempotencyKey" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  database: { $ref: "#/components/schemas/Database" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "402": { $ref: "#/components/responses/PlanLimit" }
        "409": { description: "Already on the largest plan, and over its allowance" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /provision/databases/{id}:
    patch:
      tags: [Databases]
      summary: Change a database that is already running
      description: |
        Only what is ours to change without touching the engine: its name, what
        it is for, where we think it is, what it says about itself. Size is not
        here — growing a volume is work, and work is an operation. Nor is the
        engine: that is not a field.

        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "provision", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 200 }
                description: { type: string, nullable: true }
                region: { type: string }
                version: { type: string, nullable: true }
                roles: { type: array, items: { type: object } }
                localPoint: { $ref: "#/components/schemas/GeoPoint" }
                primaryPoint: { $ref: "#/components/schemas/GeoPoint" }
                managed: { type: boolean }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  database: { $ref: "#/components/schemas/Database" }
        "400": { description: "Nothing to change, or a field that is not an edit" }
    get:
      tags: [Databases]
      summary: One database
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "provision", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The database
          content:
            application/json:
              schema:
                type: object
                properties:
                  database: { $ref: "#/components/schemas/Database" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Databases]
      summary: Deprovision it
      description: |
        The engine is torn down first, then the row is soft-deleted — so what it
        used, and when, is still answerable afterwards.

        **Capability:** `resource:delete` (area `resource`, on the database).
      x-prorm-capability: {"router": "provision", "needs": "resource:delete", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "204": { description: Gone }
        "404": { $ref: "#/components/responses/NotFound" }

  /provision/databases/{id}/query:
    post:
      tags: [Databases]
      summary: Read or write it
      security:
        - bearerAuth: []
        - Klock: []
        - apiKeyAuth: []
      description: |
        Takes a bearer token **or** an api key in `x-api-key`. A key is issued
        for one database and carries a privilege: pointing it at another
        database is a 403, and a `readonly` key may only `select`.

        Send exactly one of `select`, `insert`, `update` or `delete`, in the
        shapes the ORM understands. The dialect compiles the statement and binds
        the values; no SQL crosses this boundary in either direction.

        A `delete` without a `where` is refused: it is far more often a mistake
        than an intention.

        **Capability:** `data:read` or `data:write` (area `resource`, on the database). Data:read for a select, data:write otherwise.
      x-prorm-capability: {"router": "provision", "needs": ["data:read", "data:write"], "area": "resource", "resource": "database", "note": "data:read for a select, data:write otherwise", "perRequest": true}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QueryRequest" }
            examples:
              select:
                summary: A read
                value:
                  select:
                    from: posts
                    attributes: [id, title]
                    where: { authorId: 7 }
                    order: [[createdAt, DESC]]
                    limit: 10
              insert:
                summary: A write
                value:
                  insert:
                    into: posts
                    values: { title: hello, authorId: 7 }
      responses:
        "200":
          description: What the engine answered
          content:
            application/json:
              schema:
                type: object
                properties:
                  results: { type: array, items: { type: object } }
                  meta: { type: object }
                  ran: { type: string, description: The statement the ORM built }
        "400": { description: The request could not be compiled }
        "409": { description: "Not a database we run, so there is nothing to query" }

  /provision/databases/{id}/usage:
    put:
      tags: [Databases]
      summary: Report what it is using
      description: |
        What the storage warnings fire on. Crossing 80% sends one email; hitting
        100% sends a different one, because that is not a warning.

        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "provision", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UsageReport" }
      responses:
        "200": { description: Recorded }
        "404": { $ref: "#/components/responses/NotFound" }

  /provision/stores:
    get:
      tags: [Data stores]
      summary: Every data store on the account
      description: |
        **Capability:** `resource:read` (area `resource`, on the store).
      x-prorm-capability: {"router": "provision", "needs": "resource:read", "area": "resource", "resource": "store"}
      responses:
        "200":
          description: The stores
          content:
            application/json:
              schema:
                type: object
                properties:
                  stores:
                    type: array
                    items: { $ref: "#/components/schemas/DataStore" }
    post:
      tags: [Data stores]
      summary: Provision a data store
      description: |
        **Capability:** `resource:provision` (area `resource`, on the store). With a team context: resource:provision in that team.
      x-prorm-capability: {"router": "provision", "needs": "resource:provision", "area": "resource", "resource": "store", "note": "with a team context: resource:provision in that team"}
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewDataStore" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  store: { $ref: "#/components/schemas/DataStore" }

  /provision/stores/{id}:
    patch:
      tags: [Data stores]
      summary: Change a data store that is already running
      description: |
        **Capability:** `resource:operate` (area `resource`, on the store).
      x-prorm-capability: {"router": "provision", "needs": "resource:operate", "area": "resource", "resource": "store"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string, nullable: true }
                region: { type: string }
                endpoint: { type: string, nullable: true }
                container: { type: string, nullable: true }
                localPoint: { $ref: "#/components/schemas/GeoPoint" }
                primaryPoint: { $ref: "#/components/schemas/GeoPoint" }
                managed: { type: boolean }
      responses:
        "200": { description: Updated }
    get:
      tags: [Data stores]
      summary: One data store
      description: |
        **Capability:** `resource:read` (area `resource`, on the store).
      x-prorm-capability: {"router": "provision", "needs": "resource:read", "area": "resource", "resource": "store"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200": { description: The store }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Data stores]
      summary: Deprovision it
      description: |
        **Capability:** `resource:delete` (area `resource`, on the store).
      x-prorm-capability: {"router": "provision", "needs": "resource:delete", "area": "resource", "resource": "store"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "204": { description: Gone }
        "404": { $ref: "#/components/responses/NotFound" }

  /provision/stores/{id}/usage:
    put:
      tags: [Data stores]
      summary: Report what it is using
      description: |
        **Capability:** `resource:operate` (area `resource`, on the store).
      x-prorm-capability: {"router": "provision", "needs": "resource:operate", "area": "resource", "resource": "store"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UsageReport" }
      responses:
        "200": { description: Recorded }

  /provision/stores/{id}/connect:
    get:
      tags: [Data stores]
      summary: How to reach a data store
      description: |
        The store's answer to a database's `/connect`. A managed store on
        Google Cloud is described by the provisioner that built it —
        Memorystore as a private endpoint reached from the same VPC
        (`transport: vpc`, `host`, `port`, `dsn`), Firestore, BigQuery and
        Cloud Storage as HTTPS services reached through the platform with an
        api key (`transport: https`, `service`, and the `database`, `dataset`
        or `bucket`). A store the customer brought reports the endpoint they
        recorded (`transport: endpoint`).

        **Capability:** `key:read` (area `resource`, on the store).
      x-prorm-capability: {"router": "provision", "needs": "key:read", "area": "resource", "resource": "store"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: How to connect
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StoreConnection" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404":
          description: "`Unknown data store`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Nothing is recorded about how to reach this store
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  next: { type: string, example: Set it at PATCH /provision/stores/3 }

  /provision/databases/{id}/keys:
    get:
      tags: [Connections and credentials]
      summary: The api keys on a database
      description: |
        The secret half is never returned again, by anything.

        **Capability:** `key:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "key:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The keys
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys: { type: array, items: { $ref: "#/components/schemas/ApiKey" } }
    post:
      tags: [Connections and credentials]
      summary: Issue a key
      description: |
        The only moment the secret exists anywhere but in the caller's hands.

        **Capability:** `key:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "key:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 100 }
                privilege: { $ref: "#/components/schemas/DatabasePrivilege" }
                expiresInDays: { type: integer, minimum: 1, maximum: 3650 }
      responses:
        "201":
          description: Issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { $ref: "#/components/schemas/ApiKey" }
                  secret: { type: string, example: "prk_99cbd6a2.sGCbE…" }
                  warning: { type: string }

  /provision/databases/{id}/keys/{keyId}:
    delete:
      tags: [Connections and credentials]
      summary: Revoke a key
      description: |
        Revoked, not deleted — the row answers who had access, and until when.

        **Capability:** `key:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "key:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { name: keyId, in: path, required: true, schema: { type: integer } }
      responses:
        "204": { description: Revoked }
        "404": { $ref: "#/components/responses/NotFound" }

  /provision/databases/{id}/users:
    get:
      tags: [Connections and credentials]
      summary: The engine users we created
      description: |
        **Capability:** `access:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "access:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The users
          content:
            application/json:
              schema:
                type: object
                properties:
                  users: { type: array, items: { $ref: "#/components/schemas/DbUser" } }
    post:
      tags: [Connections and credentials]
      summary: Create a user inside the database
      description: |
        Made with the ORM's own user management, so the privilege becomes real
        grants for that engine. The password is returned once and never stored.

        Engines without users refuse this and say what to use instead — on a
        sqlite database, that is an api key.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username]
              properties:
                username: { type: string, pattern: "^[a-zA-Z_][a-zA-Z0-9_]{0,62}$" }
                privilege: { $ref: "#/components/schemas/DatabasePrivilege" }
                host: { type: string, description: "mysql users are name@host; postgres users are not" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/DbUser" }
                  password: { type: string }
                  warning: { type: string }
        "409": { description: This engine has no users of its own }
        "502": { description: The engine could not be reached; the attempt is recorded }

  /provision/databases/{id}/users/{userId}/rotate:
    post:
      tags: [Connections and credentials]
      summary: Give a user a new password
      description: |
        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { name: userId, in: path, required: true, schema: { type: integer } }
      responses:
        "200": { description: Rotated; the old password stops working immediately }
        "404": { $ref: "#/components/responses/NotFound" }

  /provision/databases/{id}/users/{userId}:
    delete:
      tags: [Connections and credentials]
      summary: Drop a user
      description: |
        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { name: userId, in: path, required: true, schema: { type: integer } }
      responses:
        "204": { description: Dropped }
        "502": { description: The engine could not be reached }

  /provision/databases/{id}/access:
    get:
      tags: [Connections and credentials]
      summary: How the database is reached
      description: |
        Never returns the admin secret — only whether there is one.

        **Capability:** `access:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "access:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The connection details
          content:
            application/json:
              schema:
                type: object
                properties:
                  access: { $ref: "#/components/schemas/Access" }
        "404": { description: Nothing is recorded about how to reach it }
    put:
      tags: [Connections and credentials]
      summary: Set how it is reached
      description: |
        One row per database, so this creates or replaces. `adminPassword` is
        encrypted on the way in and never comes back out; leaving it out means
        "keep the one you have", not "forget it".

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccessRequest" }
      responses:
        "200": { description: Recorded }
        "503": { description: "No SECRETS_KEY, so a credential cannot be stored encrypted" }

  /provision/databases/{id}/objects:
    get:
      tags: [Schema and objects]
      summary: The SQL objects we put in it
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "objects", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The objects
          content:
            application/json:
              schema:
                type: object
                properties:
                  objects: { type: array, items: { $ref: "#/components/schemas/DatabaseObject" } }
    post:
      tags: [Schema and objects]
      summary: Create one, and apply it
      description: |
        Described, not written: a trigger says what it fires on and what it does,
        and the ORM compiles that for the engine it is going to. The same
        definition produces a plpgsql function on postgres and a plain trigger on
        sqlite.

        **Capability:** `migration:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "objects", "needs": "migration:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewDatabaseObject" }
            examples:
              trigger:
                summary: An audit trigger
                value:
                  kind: trigger
                  name: log_deletes
                  definition:
                    table: posts
                    timing: AFTER
                    event: DELETE
                    body: { insertInto: post_audit, values: { postId: 1 } }
              view:
                summary: A view
                value:
                  kind: view
                  name: recent_posts
                  definition: { from: posts, attributes: [id, title], where: { published: true }, limit: 50 }
      responses:
        "201":
          description: Created and applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { $ref: "#/components/schemas/DatabaseObject" }
        "400": { description: "The engine cannot express that definition, in the ORM's own words" }
        "409": { description: A thing of that kind and name is already there }

  /provision/databases/{id}/schema:
    get:
      tags: [Schema and objects]
      summary: What is in the database, as the engine reports it
      description: |
        Tables, columns and indexes read through the dialect's own introspection — the truth on the ground, including what a migration, a client or a person made directly. `/objects` is the platform's own ledger; this is not it.

        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "objects", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The schema
          content:
            application/json:
              schema:
                type: object
                properties:
                  dialect: { type: string }
                  tables:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string }
                        columns:
                          type: array
                          items:
                            type: object
                            properties:
                              name: { type: string }
                              type: { type: string }
                              allowNull: { type: boolean }
                              defaultValue: {}
                              primaryKey: { type: boolean }
                              autoIncrement: { type: boolean }
                        indexes:
                          type: array
                          items:
                            type: object
                            properties:
                              name: { type: string }
                              unique: { type: boolean }
                              primary: { type: boolean }
                              fields: { type: array, items: { type: string } }
        "502": { description: The engine could not be reached }

  /provision/databases/{id}/schema/{table}:
    get:
      tags: [Schema and objects]
      summary: One table
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "objects", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { name: table, in: path, required: true, schema: { type: string, pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" } }
      responses:
        "200": { description: "The table, with its columns and indexes" }
        "400": { description: "Not a table name, or no such table" }

  /provision/databases/{id}/migrations/preview:
    post:
      tags: [Migrations]
      summary: A plan compiled to statements, recording nothing
      description: |
        What `POST /migrations` would run, for a reviewer — or `prorm db push --dry-run` — that wants to see the statements first. Column types may be engine types (`VARCHAR(200)`) or the ORM's own (`STRING(200)`, `BOOLEAN`, `ENUM('a','b')`), which are rendered for this database's engine; that is also true of `POST /migrations` itself.

        **Capability:** `migration:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "migration:write", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operations]
              properties:
                operations: { type: array, items: { $ref: "#/components/schemas/SchemaOperation" } }
      responses:
        "200":
          description: The statements
          content:
            application/json:
              schema:
                type: object
                properties:
                  dialect: { type: string }
                  statements: { type: array, items: { type: string } }
        "400": { description: An operation the engine cannot express }

  /provision/databases/{id}/objects/preview:
    post:
      tags: [Schema and objects]
      summary: What it would run, without running it
      description: |
        **Capability:** `migration:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "objects", "needs": "migration:write", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewDatabaseObject" }
      responses:
        "200":
          description: The statements the ORM would send
          content:
            application/json:
              schema:
                type: object
                properties:
                  dialect: { type: string }
                  statements: { type: array, items: { type: string } }

  /provision/databases/{id}/objects/{objectId}:
    patch:
      tags: [Schema and objects]
      summary: Redefine an object in place
      description: |
        An engine does not edit a trigger: it is dropped and made again. That is
        what happens here, against the same row, so the object keeps its id and
        its history. A definition that will not compile is refused before
        anything is dropped.

        **Capability:** `migration:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "objects", "needs": "migration:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { name: objectId, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [definition]
              properties:
                definition: { type: object }
      responses:
        "200": { description: Redefined }
        "400": { description: The new definition would not compile; the old one is untouched }
    delete:
      tags: [Schema and objects]
      summary: Drop it
      description: |
        **Capability:** `migration:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "objects", "needs": "migration:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { name: objectId, in: path, required: true, schema: { type: integer } }
      responses:
        "204": { description: Dropped; the row stays as a record it was there }
        "404": { $ref: "#/components/responses/NotFound" }

  /provision/databases/{id}/operations:
    get:
      tags: [Operations]
      summary: Everything that has been asked of this database
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ListLimit"
        - $ref: "#/components/parameters/ListOffset"
      responses:
        "200":
          description: The operations, newest first; `Link` names the next page when there is one
          headers:
            Link: { description: '`<…>; rel="next"` when there is another page', schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  operations: { type: array, items: { $ref: "#/components/schemas/Operation" } }
                  page: { $ref: "#/components/schemas/PageWindow" }

  /provision/databases/{id}/operations/{operationId}:
    get:
      tags: [Operations]
      summary: One, with its steps
      description: |
        The view that answers where it failed, rather than that it did.

        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/OperationId" }
      responses:
        "200":
          description: The operation and its steps
          content:
            application/json:
              schema:
                type: object
                properties:
                  operation: { $ref: "#/components/schemas/Operation" }

  /provision/databases/{id}/operations/{operationId}/cancel:
    post:
      tags: [Operations]
      summary: Stop it
      description: |
        A queued operation stops now. A running one is asked to stop at its next
        step boundary — half a restore that was killed mid-write is worse than
        one that finished.

        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/OperationId" }
      responses:
        "200":
          description: cancelled, stopping, or already finished
          content:
            application/json:
              schema:
                type: object
                properties:
                  outcome: { type: string, enum: [cancelled, stopping, finished] }
                  operation: { $ref: "#/components/schemas/Operation" }

  /provision/databases/{id}/operations/{operationId}/retry:
    post:
      tags: [Operations]
      summary: Put a failed one back in the queue
      description: |
        Steps that already succeeded are kept, so a retry resumes rather than restarts.

        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/OperationId" }
      responses:
        "202": { description: Queued again }
        "409": { description: Only a failed operation can be retried }

  /provision/databases/{id}/resize:
    post:
      tags: [Lifecycle]
      summary: Grow or shrink it
      description: |
        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [size]
              properties:
                size: { type: integer, minimum: 1, maximum: 10000 }
      responses:
        "202": { $ref: "#/components/responses/Accepted" }

  /provision/databases/{id}/backup:
    post:
      tags: [Backups and replicas]
      summary: Take a backup
      description: |
        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "202": { $ref: "#/components/responses/Accepted" }

  /provision/databases/{id}/backups:
    get:
      tags: [Backups and replicas]
      summary: The backups it has
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ListLimit"
        - $ref: "#/components/parameters/ListOffset"
      responses:
        "200":
          description: The backups; `Link` names the next page when there is one
          headers:
            Link: { description: '`<…>; rel="next"` when there is another page', schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  backups: { type: array, items: { type: object, properties: { id: { type: integer }, uuid: { type: string }, state: { type: string }, size: { type: integer }, location: { type: string }, startedAt: { type: string, format: date-time }, finishedAt: { type: string, format: date-time }, expiresAt: { type: string, format: date-time } } } }
                  page: { $ref: "#/components/schemas/PageWindow" }

  /provision/databases/{id}/restore:
    post:
      tags: [Backups and replicas]
      summary: Restore one into it
      description: |
        **Capability:** `resource:operate` and `data:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": ["resource:operate", "data:write"], "area": "resource", "resource": "database", "all": true}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [backupId]
              properties:
                backupId: { type: integer }
                pointInTime: { type: string, format: date-time }
      responses:
        "202": { $ref: "#/components/responses/Accepted" }

  /provision/databases/{id}/restores:
    get:
      tags: [Backups and replicas]
      summary: What has been restored into it, and from where
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ListLimit"
        - $ref: "#/components/parameters/ListOffset"
      responses:
        "200":
          description: The restores; `Link` names the next page when there is one
          headers:
            Link: { description: '`<…>; rel="next"` when there is another page', schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  restores: { type: array, items: { type: object } }
                  page: { $ref: "#/components/schemas/PageWindow" }

  /provision/databases/{id}/migrations:
    parameters: [{ $ref: "#/components/parameters/PathId" }]
    get:
      tags: [Migrations]
      summary: The migrations recorded for it
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 50 }, description: Clamped rather than refused }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: A page of migrations; `Link` names the next page when there is one
          headers:
            Link: { schema: { type: string }, description: '`<…?limit=&offset=>; rel="next"` when there is more' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  migrations: { type: array, items: { $ref: "#/components/schemas/Migration" } }
                  page: { $ref: "#/components/schemas/PageWindow" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Migrations]
      summary: Record one
      description: |
        Operations, not SQL. The checksum is taken over the structured
        definition, so the same migration against two engines is one migration —
        and one whose definition changed after being recorded is caught when it
        is applied. Recorded `pending`; nothing runs until
        `POST /provision/databases/{id}/migrations/apply` (or `/migrate`). The
        operations are compiled now as well, so one the engine cannot express
        is a 400 here rather than a failed migration later.

        **Capability:** `migration:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "migration:write", "area": "resource", "resource": "database"}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewMigration" }
            examples:
              createTable:
                value:
                  version: "0001"
                  name: posts
                  operations:
                    - op: createTable
                      table: posts
                      columns:
                        id: { type: INTEGER, primaryKey: true, autoIncrement: true }
                        title: { type: VARCHAR(200) }
                    - op: addIndex
                      table: posts
                      name: posts_title_idx
                      fields: [title]
      responses:
        "201":
          description: Recorded, with the statements it will run
          content:
            application/json:
              schema:
                type: object
                properties:
                  migration: { $ref: "#/components/schemas/Migration" }
                  statements: { type: array, items: { type: string } }
        "400":
          description: The body did not pass its rules, or an operation the engine cannot express
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/ValidationErrors" }
                  - { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: That version is already recorded for this database
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /provision/databases/{id}/migrations/apply:
    post:
      tags: [Migrations]
      summary: Apply everything recorded and not yet run
      description: |
        Enqueues the `migrate` operation — the same as `POST
        /provision/databases/{id}/migrate` — which applies every pending
        migration, oldest version first, and checks each one's checksum
        against what was recorded. Separate from recording on purpose: the
        record hands back the statements so somebody can read them before
        they go near the database.

        **Capability:** `migration:apply` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "migration:apply", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "202": { $ref: "#/components/responses/Accepted" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /provision/databases/{id}/migrate:
    post:
      tags: [Migrations]
      summary: Apply everything pending, oldest version first
      description: |
        **Capability:** `migration:apply` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "migration:apply", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "202": { $ref: "#/components/responses/Accepted" }

  /provision/databases/{id}/replicas:
    get:
      tags: [Backups and replicas]
      summary: Its read copies
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ListLimit"
        - $ref: "#/components/parameters/ListOffset"
      responses:
        "200":
          description: The replicas; `Link` names the next page when there is one
          headers:
            Link: { description: '`<…>; rel="next"` when there is another page', schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  replicas: { type: array, items: { type: object } }
                  page: { $ref: "#/components/schemas/PageWindow" }
    post:
      tags: [Backups and replicas]
      summary: Add one
      description: |
        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [region]
              properties:
                region: { $ref: "#/components/schemas/D1Region" }
                localPoint: { $ref: "#/components/schemas/GeoPoint" }
      responses:
        "202": { $ref: "#/components/responses/Accepted" }

  /provision/databases/{id}/pause:
    post:
      tags: [Lifecycle]
      summary: Stop it accepting connections
      description: |
        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties: { why: { type: string, maxLength: 200 } }
      responses:
        "202": { $ref: "#/components/responses/Accepted" }

  /provision/databases/{id}/resume:
    post:
      tags: [Lifecycle]
      summary: Start it again
      description: |
        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "202": { $ref: "#/components/responses/Accepted" }

  /provision/databases/{id}/move:
    post:
      tags: [Lifecycle]
      summary: Move it to another region
      description: |
        Built there, switched over, then the old one is torn down — in that order.

        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [region]
              properties:
                region: { $ref: "#/components/schemas/D1Region" }
                localPoint: { $ref: "#/components/schemas/GeoPoint" }
      responses:
        "202": { $ref: "#/components/responses/Accepted" }

  /provision/databases/{id}/rotate-credentials:
    post:
      tags: [Connections and credentials]
      summary: Revoke every api key on it
      description: |
        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "202": { $ref: "#/components/responses/Accepted" }

  /provision/databases/{id}/metrics:
    get:
      tags: [Metrics and events]
      summary: Every measurement, for a graph
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ListLimit"
        - $ref: "#/components/parameters/ListOffset"
      responses:
        "200":
          description: The samples; `Link` names the next page when there is one
          headers:
            Link: { description: '`<…>; rel="next"` when there is another page', schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  metrics: { type: array, items: { type: object, properties: { at: { type: string, format: date-time }, used: { type: number }, cpu: { type: number }, memory: { type: number }, connections: { type: integer }, requests: { type: integer } } } }
                  page: { $ref: "#/components/schemas/PageWindow" }

  /provision/databases/{id}/events:
    get:
      tags: [Metrics and events]
      summary: Who did what to it
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "cloud", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/ListLimit"
        - $ref: "#/components/parameters/ListOffset"
      responses:
        "200":
          description: The events; `Link` names the next page when there is one
          headers:
            Link: { description: '`<…>; rel="next"` when there is another page', schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  events: { type: array, items: { type: object, properties: { action: { type: string }, detail: { type: object }, actorId: { type: integer, nullable: true }, at: { type: string, format: date-time, nullable: true } } } }
                  page: { $ref: "#/components/schemas/PageWindow" }

  /policies:
    get:
      tags: [Policies]
      summary: The catalog
      description: |
        Every policy, grouped by category. Needs no session. With any filter the answer is a flat list instead.

        **Capability:** none — public.
      x-prorm-capability: {"router": "catalog", "public": true, "needs": null}
      security: []
      parameters:
        - { name: category, in: query, schema: { $ref: "#/components/schemas/PolicyCategory" } }
        - { name: severity, in: query, schema: { $ref: "#/components/schemas/PolicySeverity" } }
        - { name: framework, in: query, schema: { type: string }, description: "Substring of a framework citation: soc 2, gdpr, pci" }
        - { name: q, in: query, schema: { type: string }, description: "Substring of the id, name or description" }
      responses:
        "200":
          description: The catalog, or the filtered list
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PolicyCatalog"
                  - type: object
                    properties:
                      total: { type: integer }
                      policies: { type: array, items: { $ref: "#/components/schemas/Policy" } }

  /policies/{policyId}:
    get:
      tags: [Policies]
      summary: One policy
      description: |
        **Capability:** none — public.
      x-prorm-capability: {"router": "catalog", "public": true, "needs": null}
      security: []
      parameters: [{ $ref: "#/components/parameters/PolicyId" }]
      responses:
        "200":
          description: The policy
          content:
            application/json:
              schema:
                type: object
                properties:
                  policy: { $ref: "#/components/schemas/Policy" }
        "404": { description: No such policy }

  /provision/databases/{id}/policies:
    get:
      tags: [Policies]
      summary: What this database is held to
      description: |
        **Capability:** `audit:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "policies", "needs": "audit:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The assignments, and the ids of every policy not yet assigned
          content:
            application/json:
              schema:
                type: object
                properties:
                  assigned: { type: array, items: { $ref: "#/components/schemas/PolicyAssignment" } }
                  available: { type: array, items: { type: string } }
        "404": { description: Not your database }

  /provision/databases/{id}/policies/{policyId}:
    put:
      tags: [Policies]
      summary: Switch a policy on, or change its knobs
      description: |
        Idempotent. `params` may only name what the policy declares, within its bounds; anything else is a 400 that says which.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "policies", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/PolicyId" }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                params: { type: object, additionalProperties: true }
                enabled: { type: boolean, default: true }
      responses:
        "200":
          description: The assignment
          content:
            application/json:
              schema:
                type: object
                properties:
                  assignment: { $ref: "#/components/schemas/PolicyAssignment" }
        "400": { description: "A parameter the policy does not declare, or out of range" }
        "404": { description: "No such policy, or not your database" }
    delete:
      tags: [Policies]
      summary: Switch it off
      description: |
        The history under `policy.fired` in the events feed stays.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "policies", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/PolicyId" }
      responses:
        "204": { description: "Off" }
        "404": { description: It was not assigned }

  /provision/databases/{id}/policies/review:
    post:
      tags: [Policies]
      summary: Mark the policies reviewed
      description: |
        What the quarterly-review policies measure from.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "policies", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: Noted
          content:
            application/json:
              schema:
                type: object
                properties:
                  reviewedAt: { type: string, format: date-time }

  /provision/databases/{id}/policies/check:
    post:
      tags: [Policies]
      summary: Evaluate the periodic policies now
      description: |
        The metric, age, schedule and state policies on this database, without waiting for the sweep. Event-driven policies evaluate as events arrive and are not affected.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "policies", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: What fired, if anything
          content:
            application/json:
              schema:
                type: object
                properties:
                  fired: { type: array, items: { $ref: "#/components/schemas/PolicyFiring" } }

  /provision/databases/{id}/policies/trace:
    get:
      tags: [Policies]
      summary: Who is behind a credential
      description: |
        The watermark. Every key is `prefix.secret` and the prefix is what appears in logs and events. Given the whole key or just the prefix, this names the account it was issued to, its privilege and state, and every address it has been used from. Only the prefix is used or echoed. A prefix that belongs to no key on this database is a 404, not a lead.

        **Capability:** `audit:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "policies", "needs": "audit:read", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { name: credential, in: query, required: true, schema: { type: string, minLength: 3, maxLength: 200 } }
      responses:
        "200":
          description: The trace
          content:
            application/json:
              schema:
                type: object
                properties:
                  trace: { $ref: "#/components/schemas/CredentialTrace" }
        "404": { description: No key on this database has that prefix }

  /security/sessions:
    get:
      tags: [Sessions]
      summary: Everywhere this account is signed in
      description: |
        Every session the account holds, of every kind: browsers, CLI tokens,
        klock v2 interactive and device sessions, and service sessions standing
        in for a passwordless session or a local transfer job. Paginated.
      parameters:
        - name: kind
          in: query
          schema: { type: string, enum: [browser, cli-legacy, interactive, device, service, delegated, guest, assumed, handoff] }
          description: Only sessions of this kind. An unknown kind is ignored.
        - name: device
          in: query
          schema: { type: string, pattern: "^[\\w-]{1,64}$" }
          description: Only sessions on this device id
        - name: live
          in: query
          schema: { type: string, enum: ["1", "true"] }
          description: Leave out what is revoked or past its expiry
        - $ref: "#/components/parameters/ListLimit"
        - $ref: "#/components/parameters/ListOffset"
      responses:
        "200":
          description: The sessions; the one asking is marked `current`
          headers:
            Link: { description: '`<…>; rel="next"` when there is another page', schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions: { type: array, items: { $ref: "#/components/schemas/SessionView" } }
                  page: { $ref: "#/components/schemas/PageWindow" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
    delete:
      tags: [Sessions]
      summary: Sign out everywhere else
      description: |
        Revokes every session but the one asking. Service sessions (database
        hosts, CI, agents) are spared unless `includeServices` is true, in the
        body or the query.
      parameters:
        - name: includeServices
          in: query
          schema: { type: string, enum: ["1", "true"] }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                includeServices: { type: boolean, default: false }
      responses:
        "200":
          description: How many were ended
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked: { type: integer }
                  message: { type: string }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { description: The account is not verified yet }

  /security/sessions/{id}:
    delete:
      tags: [Sessions]
      summary: Sign one out
      description: The token keeps verifying; the session behind it does not. Audited.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "204": { description: Ended (or already was) }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { description: The account is not verified yet }
        "404": { $ref: "#/components/responses/NotFound" }

  /security/sessions/cli:
    post:
      tags: [Sessions]
      summary: A token for the CLI on a named machine
      description: |
        The v1 end of `prorm login`. The console, holding the caller's own
        session, asks for a second token and hands it to the CLI's localhost
        listener. The token is a session of its own, labelled
        `prorm-cli (<hostname>)`, revocable on its own, and lives 30 days by
        default (at most 90). Audited.

        **Enforcement E3** (`CAPABILITY_E3`): the login flow no longer mints
        v1 bearers — anything but `purpose: token` is answered
        `410 login_v2_required`, pointing at the klock v2 sign-in. A personal
        token (`purpose: token`, for `--token` and `PRORM_TOKEN`) is still
        issued, for 30 days at most.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hostname]
              properties:
                hostname: { type: string, minLength: 1, maxLength: 80 }
                days: { type: integer, minimum: 1, maximum: 90, default: 30, description: At most 30 under E3 }
                purpose:
                  type: string
                  enum: [login, token]
                  description: '`token` for an explicit personal token; the only purpose E3 still issues'
      responses:
        "201":
          description: The token, once
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  tokenType: { type: string, enum: [Bearer] }
                  expiresIn: { type: string, example: 30d }
                  expiresAt: { type: string, format: date-time }
                  user: { $ref: "#/components/schemas/User" }
        "400": { description: "No hostname, a lifetime outside 1–90 days (1–30 under E3), or an unknown purpose" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { description: The account is not verified yet }
        "410":
          description: |
            `login_v2_required` (E3): legacy CLI tokens are no longer issued at
            sign-in. The body adds `login: { v: 2, configuration, open }` — the
            klock configuration document and the route that opens a session.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ApiError"
                  - type: object
                    properties:
                      login:
                        type: object
                        properties:
                          v: { type: integer, enum: [2] }
                          configuration: { type: string, format: uri }
                          open: { type: string, format: uri }
              example:
                error: login_v2_required
                message: This API no longer issues legacy CLI tokens at sign-in. Update the CLI and run `prorm login` (klock v2). …
                login: { v: 2, configuration: https://api.prod-orm.io/.well-known/klock-configuration, open: https://api.prod-orm.io/security/klock/sessions }

  /chat/conversations:
    get:
      tags: [Support chat]
      summary: My conversations
      description: |
        Every conversation the caller is in, most recent first, each with its
        participants and the caller's unread count. Support staff (admins
        holding `support`) also see every open support conversation. `live`
        says whether sockets deliver on this deployment; without it, poll.
      parameters:
        - { name: kind, in: query, schema: { type: string, enum: [direct, group, support] } }
        - { name: team, in: query, schema: { type: integer } }
      responses:
        "200":
          description: The conversations
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversations: { type: array, items: { $ref: "#/components/schemas/Conversation" } }
                  live: { type: boolean }
    post:
      tags: [Support chat]
      summary: Start a conversation
      description: |
        `direct` with `userId` — a colleague on one of your teams, or admin to
        admin — is one conversation for the two of you, found again if it
        exists. `group` with `teamId`, `title` and optional `userIds` is a
        team channel; any active member may start one, and only members can
        be in it. `support` with `teamId` is the team's one conversation with
        the platform's support staff, every active member in it; `support`
        alone is your own. Needs a verified address.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind: { type: string, enum: [direct, group, support] }
                userId: { type: integer }
                teamId: { type: integer }
                title: { type: string }
                userIds: { type: array, items: { type: integer } }
      responses:
        "200": { description: "Found (direct, support)" }
        "201": { description: Made (group) }
        "403": { description: Not somebody you can message }
        "404": { description: "Unknown team, or not a member" }

  /chat/support:
    get:
      tags: [Support chat]
      summary: Every support conversation, open first
      description: Needs the `support` admin scope.
      responses:
        "200": { description: The conversations }
        "403": { $ref: "#/components/responses/Forbidden" }

  /chat/ticket:
    get:
      tags: [Support chat]
      summary: A ticket for the socket
      description: |
        A browser cannot put a bearer header on a WebSocket and a token in a
        URL ends up in logs, so the socket is opened with a two-minute token
        of its own, presented once as `?ticket=` to `GET /chat/ws?conversation=<id>`
        (a WebSocket upgrade, handled outside these routes). The socket speaks
        `{ event, data }` both ways: `message`, `typing`, `presence`, `read`
        from the server; `typing`, `ping` from the client. Messages themselves
        are sent through POST …/messages.
      responses:
        "200":
          description: The ticket
          content:
            application/json:
              schema:
                type: object
                properties:
                  ticket: { type: string }
                  expiresIn: { type: integer, description: Seconds }
                  live: { type: boolean }

  /chat/conversations/{id}:
    parameters: [{ $ref: "#/components/parameters/PathId" }]
    get:
      tags: [Support chat]
      summary: One conversation, with who is in it and who is connected
      responses:
        "200":
          description: The conversation
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation: { $ref: "#/components/schemas/Conversation" }
                  online: { type: array, items: { type: integer }, description: users.id of everyone connected right now }
                  asStaff: { type: boolean }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Support chat]
      summary: Rename a group; open or close a support conversation
      description: A title needs `chat:manage` on the team, or to have started the group. Open/closed is support staff's; a customer writing again reopens it.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                open: { type: boolean }
      responses:
        "200": { description: "Changed, or not" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /chat/conversations/{id}/messages:
    parameters: [{ $ref: "#/components/parameters/PathId" }]
    get:
      tags: [Support chat]
      summary: The lines, oldest first
      description: The newest 50 by default. `before` pages back from a message id; `after` reads forward from one (what a client that lost its socket asks for).
      parameters:
        - { name: before, in: query, schema: { type: integer } }
        - { name: after, in: query, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, maximum: 200 } }
      responses:
        "200":
          description: The messages
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages: { type: array, items: { $ref: "#/components/schemas/ChatMessage" } }
                  hasMore: { type: boolean }
    post:
      tags: [Support chat]
      summary: Say something
      description: Written first, then fanned out to every open socket. Support staff answering are added to the conversation, so the record shows who answered. Needs a verified address.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 8000 }
      responses:
        "201":
          description: The message
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { $ref: "#/components/schemas/ChatMessage" }
        "404": { $ref: "#/components/responses/NotFound" }

  /chat/conversations/{id}/participants:
    parameters: [{ $ref: "#/components/parameters/PathId" }]
    post:
      tags: [Support chat]
      summary: Add somebody
      description: To a group, a member of its team; to a support conversation, a member (by a member) or support staff (by staff). Idempotent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId]
              properties:
                userId: { type: integer }
      responses:
        "200": { description: The participants }
        "400": { description: Not somebody who can be in it }

  /chat/conversations/{id}/read:
    parameters: [{ $ref: "#/components/parameters/PathId" }]
    put:
      tags: [Support chat]
      summary: Mark read
      description: Up to a message id, or the latest. Never moves backwards.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                upTo: { type: integer }
      responses:
        "200":
          description: The cursor
          content:
            application/json:
              schema:
                type: object
                properties:
                  readUpTo: { type: integer }

  /sso/start:
    get:
      tags: [Single sign-on]
      summary: Begin a single sign-on
      description: |
        Public. Sends the browser to the team's provider with a sealed state
        (which team, a nonce, a PKCE verifier, where to go afterwards). The
        provider sends it back to `/sso/callback`. `next` is kept only when it
        is a console path; anything else lands on `console`.
      security: []
      parameters:
        - { name: team, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 60 }, description: The team's slug }
        - { name: next, in: query, schema: { type: string }, description: "A console path to land on; `console` by default" }
      responses:
        "302": { description: To the provider }
        "400":
          description: No team given; or the provider's authorization endpoint is not a public https URL
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/ValidationErrors" }
                  - { $ref: "#/components/schemas/Error" }
        "404":
          description: That team has no single sign-on switched on
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: The issuer did not answer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /sso/callback:
    get:
      tags: [Single sign-on]
      summary: The provider's return
      description: |
        Public; the provider sends the browser here. The code is exchanged
        (client secret and PKCE verifier both presented), the person is read
        from the userinfo endpoint (the address must be one the provider says
        is verified), and turned into an account (made if unknown, at a
        domain the team has verified), a membership (made or activated) and a
        session. A link started with `POST /sso/link` binds the provider's
        subject to that account, and to nothing else; a new link is mailed to
        the account and audited (`sso-linked`). Ends on the console at
        `#/handoff?token=…&next=…`; any failure ends on its sign-in page with
        `#/signin?sso_error=…`. Audited (`sso-signin`).
      security: []
      parameters:
        - { name: code, in: query, schema: { type: string } }
        - { name: state, in: query, schema: { type: string } }
        - { name: error, in: query, schema: { type: string }, description: The provider's refusal }
        - { name: error_description, in: query, schema: { type: string } }
      responses:
        "302": { description: "To the console, signed in — or to its sign-in page with the reason" }

  /sso/link:
    post:
      tags: [Single sign-on]
      summary: Link the signed-in account to a team's provider
      description: |
        Signed in. How an existing account — a member's included — comes to
        sign in through the team's provider: the person proves the account
        first (this needs a session, which /signin gives only after the
        password and the second factor), then the provider. The answer's
        `url` is where to send the browser; the callback links the provider's
        subject to this account, provided the address the provider gives is
        a confirmed address on it. Refused from an assumed (staff) session.
        Mailed to the account and audited (`sso-linked`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [team]
              properties:
                team: { type: string, minLength: 1, maxLength: 60, description: A slug }
                next: { type: string, description: "A console path to land on; `console` by default" }
      responses:
        "200":
          description: Where to send the browser
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri, description: "The provider's authorization endpoint, with the sealed state" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: Single sign-on cannot be linked from an assumed session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: That team has no single sign-on
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: The issuer did not answer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /sso/discover:
    post:
      tags: [Single sign-on]
      summary: Which team an address signs in to
      description: |
        Public. From an email address (its domain, against the domains teams
        have claimed and proved with a DNS record) or a team slug; only a
        team whose provider is switched on is named. `team: null` for none,
        without saying which half was unknown.
      security: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                team: { type: string, minLength: 1, maxLength: 60, description: A slug }
      responses:
        "200":
          description: The team, or null
          content:
            application/json:
              schema:
                type: object
                properties:
                  team:
                    type: object
                    nullable: true
                    properties: { slug: { type: string }, name: { type: string } }
                  startAt: { type: string, format: uri }
        "400": { $ref: "#/components/responses/ValidationError" }

  /security/sessions/handoff:
    post:
      tags: [Sessions]
      summary: A token for the customer console, from the staff one
      description: |
        The two consoles are separate origins with separate sessions; this is
        how a staff member crosses without signing in again. The token is
        theirs — same account, a browser session's length — and carries
        `handoff: customer`, which every admin route refuses: on the customer
        console an admin is a customer. Carried across in the URL fragment.
        Refused under an assumed identity. Audited.
      responses:
        "201":
          description: The token
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  tokenType: { type: string, enum: [Bearer] }
                  expiresAt: { type: string, format: date-time, nullable: true }
                  user: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { description: "An assumed identity, or an unverified account" }

  /security/cli-logins:
    post:
      tags: [Sessions]
      summary: Park a sign-in for the CLI to collect
      description: |
        The relay for `prorm login`, for browsers that cannot reach the CLI's
        loopback listener. What is parked is stored encrypted under the
        SHA-256 of the one-time state the CLI printed, for five minutes, and
        delivered once at `GET /security/cli-logins/{state}`. A second
        approval for the same state replaces the first.

        - **v2** (`v: 2`): the console parks the device-bound grant it just
          opened at `POST /security/klock/sessions`, with `grant` and
          `sessionId` (and optionally `deviceCert`, `deviceId`, `keyset`). The
          grant must be one this API signed, for a live session of the
          caller's own. Only the device key it names can use it.
        - **v1**: a bearer token — one already minted at
          `/security/sessions/cli` (it must be the caller's own), or a new one.
          Under enforcement E3 this answers `410 login_v2_required`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state, hostname]
              properties:
                state: { type: string, pattern: "^[A-Za-z0-9_-]{16,128}$", description: What `prorm login` printed }
                hostname: { type: string, minLength: 1, maxLength: 80 }
                days: { type: integer, minimum: 1, maximum: 90, description: v1 only; default 30 }
                token: { type: string, minLength: 20, maxLength: 4096, description: "v1: a token the caller already holds for this CLI; must be the caller's own" }
                v: { type: integer, enum: [1, 2] }
                grant: { type: string, minLength: 20, maxLength: 16384, description: "v2: the grant from POST /security/klock/sessions" }
                sessionId: { type: string, pattern: "^ks_[0-9a-f-]{36}$", description: "v2: the session's id" }
                deviceCert: { type: string, maxLength: 8192 }
                deviceId: { type: string, maxLength: 64 }
                keyset: { type: string, maxLength: 16384 }
      responses:
        "202":
          description: Parked for five minutes
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  expiresAt: { type: string, format: date-time }
        "400":
          description: |
            A body that fails validation (`errors`), or for v2: `bad_request`
            (no `grant` or `sessionId`), `grant_invalid` (not a grant this API
            signed).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ValidationErrors"
                  - $ref: "#/components/schemas/ApiError"
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: |
            The v1 token is not the caller's; for v2, `not_yours` — the grant
            is for someone else's session, another session, or a revoked one.
            Also an unverified account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: not_yours, message: That grant is not yours to hand over }
        "410": { $ref: "#/components/responses/LoginV2Required" }
        "503":
          description: No SECRETS_KEY, so nothing can be parked encrypted; or `klock_v2_unavailable` for a v2 grant
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/cli-logins/{state}:
    get:
      tags: [Sessions]
      summary: The CLI collecting its sign-in
      description: |
        No session needed — the state is the secret (at least 16 random
        characters only the CLI and the page it opened ever saw). 202 while
        pending, and for anything unknown or expired; 200 with what was parked
        exactly once — the row is deleted before it is answered.
      security: []
      parameters:
        - { name: state, in: path, required: true, schema: { type: string, pattern: "^[A-Za-z0-9_-]{16,128}$" } }
      responses:
        "202":
          description: Not approved yet (or no such state)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [pending] }
        "200":
          description: The sign-in, once — a v1 bearer token, or a v2 klock grant
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    title: v1
                    properties:
                      token: { type: string }
                      tokenType: { type: string, enum: [Bearer] }
                      expiresAt: { type: string, format: date-time, nullable: true }
                      user: { allOf: [{ $ref: "#/components/schemas/User" }], nullable: true }
                  - type: object
                    title: v2
                    properties:
                      v: { type: integer, enum: [2] }
                      tokenType: { type: string, enum: [Klock] }
                      grant: { type: string }
                      sessionId: { type: string, example: ks_4f9c0d8e-2a61-4b1f-9c3d-7e5a2b1c0d9e }
                      deviceId: { type: string, nullable: true }
                      deviceCert: { type: string, nullable: true }
                      keyset: { type: string, nullable: true }
                      expiresAt: { type: string, format: date-time, nullable: true }
                      user: { allOf: [{ $ref: "#/components/schemas/User" }], nullable: true }
        "400": { $ref: "#/components/responses/ValidationError" }

  /security/audit:
    get:
      tags: [Audit log]
      summary: What has happened to this account
      description: Paginated, newest last as recorded.
      parameters:
        - $ref: "#/components/parameters/ListLimit"
        - $ref: "#/components/parameters/ListOffset"
      responses:
        "200":
          description: The entries
          headers:
            Link: { description: '`<…>; rel="next"` when there is another page', schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries: { type: array, items: { $ref: "#/components/schemas/AuditEntry" } }
                  page: { $ref: "#/components/schemas/PageWindow" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }

  /security/mfa/setup:
    post:
      tags: [MFA and passwords]
      summary: Start enrolling a second factor
      description: |
        Returns the secret and the `otpauth://` uri an authenticator scans,
        once. Nothing is guarded until a code proves the app actually has it.
        Starting again before confirming replaces the unconfirmed secret.

        Under enforcement E3 an admin without a confirmed second factor is
        answered `403 mfa_required` on every admin route, pointing here; this
        route and `/security/mfa/confirm` are the account's own, never
        admin-only, so an admin is never locked out of them.
      responses:
        "200":
          description: A secret to scan
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret: { type: string }
                  uri: { type: string }
                  next: { type: string }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { description: The account is not verified yet }
        "409": { description: There is already a confirmed factor; remove it first }
        "503": { description: "No SECRETS_KEY, so a TOTP secret cannot be stored encrypted" }

  /security/mfa/confirm:
    post:
      tags: [MFA and passwords]
      summary: Prove the app has it
      description: |
        Confirms the factor and hands out ten one-time recovery codes, shown
        once. Wrong codes count against the account's step-up lockout. On a
        factor already in force the code is spent (single use) and a fresh set
        of recovery codes replaces the old one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, pattern: "^[0-9]{6}$" }
      responses:
        "200":
          description: Enabled, with the recovery codes
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  recoveryCodes: { type: array, items: { type: string } }
                  warning: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { description: That code is not right }
        "403": { description: The account is not verified yet }
        "409": { description: No enrolment to confirm; start at /security/mfa/setup }
        "429": { description: Too many attempts; try again in a few minutes }

  /security/mfa:
    delete:
      tags: [MFA and passwords]
      summary: Remove the second factor
      description: |
        A downgrade of the account, so it asks for both factors it guards: the
        password, and a fresh `code` — a six-digit code from the authenticator
        app, or a recovery code — which is spent. Without a code the answer is
        401 with `mfaRequired: true`, so a client knows to ask for one. Wrong
        passwords and wrong codes both count against the step-up lockout. An
        enrolment never confirmed needs only the password. The owner's
        databases' policies hear `owner.mfa-removed`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current-password]
              properties:
                current-password: { type: string, format: password }
                code: { type: string, minLength: 6, maxLength: 64, description: A six-digit TOTP code or a recovery code; required when a second factor is in force }
      responses:
        "204": { description: Removed }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401":
          description: The current password is wrong, or the code is missing or not valid (`mfaRequired`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  mfaRequired: { type: boolean }
        "403": { description: The account is not verified yet }
        "429": { description: Too many attempts; try again in a few minutes }

  /security/password/forgot:
    post:
      tags: [MFA and passwords]
      summary: Ask for a reset link
      description: |
        Answers the same whether or not the address has an account. The link
        is good for thirty minutes. On the strict rate-limit tier.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /security/password/reset:
    post:
      tags: [MFA and passwords]
      summary: Set a new password with the emailed token
      description: |
        One-time and short-lived; every other reset link still open for the
        account dies with it. A successful reset ends every session — whoever
        forced it should not stay signed in on a device the owner does not
        hold — except service sessions, unless `includeServices` is true.
        On the strict rate-limit tier.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password, conf-password]
              properties:
                token: { type: string }
                password: { type: string, format: password, minLength: 8 }
                conf-password: { type: string, format: password, description: Must match password }
                includeServices: { type: boolean, default: false, description: End service sessions too }
      responses:
        "200":
          description: Changed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "400": { description: "The body fails validation, or the link is expired, spent, or unknown" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /security/klock/sessions:
    post:
      tags: [Klock]
      summary: Open a device-bound klock session
      description: |
        Opens a klock v2 session bound to a device key, and hands back its
        first grant and the device certificate. From the console (a browser
        session) during `prorm login` — with the `state` and `confirmCode` the
        terminal printed, both required there — or from an existing klock
        session. A legacy CLI token (`console_only`) or an assumed identity
        (`assumed_identity`) cannot open one.

        - `kind: interactive` (the default) — a signed-in CLI.
        - `kind: device` — a trusted machine, a lasting credential: needs a
          fresh step-up, either already on the caller's session or given here
          as `stepUp: { totp }` (or `{ password }` from a browser on an account
          without a second factor).
        - `kind: service` — a session standing for one resource: a database
          (`resource: { kind: database, id }`, with `role` and `scope`, which
          creates a v2 passwordless session; needs `access:write` on it) or a
          local transfer job (`resource: { kind: transfer-job, id }`; needs
          `resource:operate`). Needs `sealPub` and `sealBinding`. Never admin.

        One live session per device key and kind: signing in again replaces
        it. Past the kind's concurrent limit the least recently seen is
        evicted, or — for kinds that refuse — `409 session_limit` lists them.
        When the klock service is bound it registers the device, and then
        `sealPub` and `sealBinding` are required for every kind.
      x-prorm-capability:
        needs: null
        checkedIn: controller
        note: "kind device: a fresh step-up; kind service: access:write on the database or resource:operate on the transfer job (the caller's role and session ceiling), checked in every mode"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KlockOpenRequest" }
      responses:
        "201":
          description: Opened. The grant is what every request of this session carries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KlockOpenedSession" }
        "400":
          description: |
            `bad_request` (a malformed key, hostname, state, kind or scope),
            `confirm_code_required`, `confirm_code_mismatch`,
            `seal_binding_invalid`, `team_not_active`, `kind_unsupported` (a
            service session with no database or transfer job), and from an
            inline step-up `mfa_not_enrolled`, `totp_required`,
            `password_browser_only`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: confirm_code_mismatch, message: The confirm code does not match the one the terminal printed }
        "401":
          description: |
            The caller was not authenticated (see `KlockUnauthorized`); or
            `step_up_required` — a device session without a fresh step-up; or
            `step_up_failed` — the inline step-up was wrong.
          headers:
            WWW-Authenticate: { description: '`Klock error="<code>"`', schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: step_up_required, message: Opening a device session needs a fresh step-up, needs: step-up }
        "403":
          description: |
            `console_only` (a CLI token), `assumed_identity`,
            `capability_missing` (a service session the caller cannot open
            over that resource), or an unverified account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: console_only, message: "Open a klock session from the console (`prorm login`), not with a CLI token" }
        "404":
          description: '`not_found` — the service session''s database or transfer job is not one the caller reaches'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "409":
          description: |
            `session_limit` — as many sessions of this kind as allowed, on a
            kind that refuses rather than evicts (the body adds `limit` and
            `sessions`; for a database, the bound-service-session limit);
            `already_bound` — the transfer job already has an on-device agent;
            `subject_unavailable` — a finished job has nothing left to report.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ApiError"
                  - type: object
                    properties:
                      limit: { type: integer }
                      sessions: { type: array, items: { $ref: "#/components/schemas/SessionView" } }
                      sessionId: { type: string }
        "429":
          description: '`too_many_attempts` — too many step-up attempts on this account'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "502":
          description: '`klock_service_unavailable` — the klock service could not register the device'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "503":
          description: '`klock_v2_unavailable` — this API cannot sign grants; or `secrets_key_missing` for a database service session'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/klock/sessions/{id}/refresh:
    post:
      tags: [Klock]
      summary: Check in, for the next grant
      description: |
        The session that signed the request gets a fresh grant, with the
        capabilities re-resolved from what the account may do now. An expired
        grant may still ask: the signature and the live session row prove the
        rest. Only the session itself may refresh (`not_this_session`).
      security:
        - Klock: []
      parameters: [{ $ref: "#/components/parameters/KlockSessionRef" }]
      responses:
        "200":
          description: The next grant
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessionId: { type: string }
                  grant: { type: string, description: Compact JWS }
                  grantSeq: { type: integer }
                  checkInBy: { type: integer, description: Unix seconds; check in again before this }
                  exp: { type: integer, description: Unix seconds }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: '`not_this_session` — a session may only refresh itself'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "404":
          description: '`not_found` — no such session of the caller''s'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "423": { $ref: "#/components/responses/SessionFrozen" }
        "503": { $ref: "#/components/responses/KlockUnavailable" }

  /security/klock/sessions/{id}/step-up:
    post:
      tags: [Klock]
      summary: Step up the session asking
      description: |
        Proves a second factor now, which wakes the session's dormant
        capabilities (the `admin:*` ones, among others) for the kind's step-up
        window. Send `{ totp }`; a browser session on an account without a
        second factor may send `{ password }` instead. The code is single-use,
        and failures count against the account's step-up lockout. Only the
        session itself may step up. A klock session gets a new grant carrying
        `adminUntil`; a bearer session gets the window.
      parameters: [{ $ref: "#/components/parameters/KlockSessionRef" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KlockStepUpRequest" }
      responses:
        "200":
          description: Stepped up
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    title: klock session
                    properties:
                      sessionId: { type: string }
                      stepUpAt: { type: integer, description: Unix seconds }
                      grant: { type: string }
                      adminUntil: { type: integer, nullable: true }
                      caps: { type: array, items: { type: string } }
                      dormant: { type: array, items: { type: string } }
                  - type: object
                    title: bearer session
                    properties:
                      sessionId: { type: integer }
                      stepUpAt: { type: integer }
                      adminUntil: { type: integer, nullable: true }
                      stepUpTtlSec: { type: integer }
        "400":
          description: '`bad_request` (neither `totp` nor `password`), `mfa_not_enrolled`, `totp_required`, `password_browser_only`'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: totp_required, message: "This account has a second factor: step up with a TOTP code" }
        "401":
          description: Not authenticated (see `KlockUnauthorized`), or `step_up_failed` — the code or password is wrong
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403":
          description: '`not_this_session`, `assumed_identity`, or an unverified account'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "404":
          description: '`not_found` — no such session of the caller''s'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "429":
          description: '`too_many_attempts` — the step-up lockout'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/klock/sessions/{id}/rotate:
    post:
      tags: [Klock]
      summary: Rotate the device key
      description: |
        Signed by the old key; `proof` is a JWS by the new key over
        `{sessionId, from, to}` (the two thumbprints). A new session carries on
        with the same kind, device, team and expiry, and a new device
        certificate; the old one lives five more minutes, then is gone. A
        session rotates once. Only the session itself may rotate.
      security:
        - Klock: []
      parameters: [{ $ref: "#/components/parameters/KlockSessionRef" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [devicePub, proof]
              properties:
                devicePub: { type: string, pattern: "^[A-Za-z0-9_-]{43}$", description: "The new Ed25519 public key, b64url" }
                proof: { type: string, description: 'A JWS (typ `klock-rotate+jws`) by the new key over {sessionId, from, to}' }
                sealPub: { type: string, pattern: "^[A-Za-z0-9_-]{43}$" }
                sealBinding: { type: string, description: Required with sealPub }
      responses:
        "201":
          description: Rotated
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessionId: { type: string, description: The new session }
                  rotatedFrom: { type: string }
                  grant: { type: string }
                  grantSeq: { type: integer }
                  deviceCert: { type: string }
                  checkInBy: { type: integer }
                  exp: { type: integer }
                  oldSessionEndsAt: { type: string, format: date-time }
        "400":
          description: '`bad_request` (a malformed key, or the new key is the old one), `rotation_proof_invalid`, `seal_binding_invalid`'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: '`not_this_session`'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "404":
          description: '`not_found`'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "409":
          description: '`already_rotated`'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "423": { $ref: "#/components/responses/SessionFrozen" }
        "503": { $ref: "#/components/responses/KlockUnavailable" }

  /security/klock/sessions/{id}/freeze:
    post:
      tags: [Klock]
      summary: Freeze a session
      description: |
        Any of the caller's sessions. A frozen session cannot authenticate
        (`423 session_frozen`) until it is unfrozen; its device is nudged to
        sync. Audited as an alert.
      parameters: [{ $ref: "#/components/parameters/KlockSessionRef" }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 120, default: manual, description: Cut to 120 characters }
      responses:
        "200":
          description: Frozen
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  frozen: { type: boolean, enum: [true] }
                  reason: { type: string }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: '`assumed_identity`, or an unverified account'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "404":
          description: '`not_found`'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "409":
          description: '`session_revoked` — it is already revoked'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/klock/sessions/{id}/unfreeze:
    post:
      tags: [Klock]
      summary: Unfreeze a session
      description: |
        From another of the caller's sessions (a frozen one cannot
        authenticate), with a fresh step-up — already on the caller's session,
        or given here as `{ totp }` (or `{ password }` from a browser on an
        account without a second factor).
      x-prorm-capability:
        needs: null
        checkedIn: controller
        note: a fresh step-up
      parameters: [{ $ref: "#/components/parameters/KlockSessionRef" }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KlockStepUpRequest" }
      responses:
        "200":
          description: Unfrozen
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  frozen: { type: boolean, enum: [false] }
        "400":
          description: '`bad_request`, `mfa_not_enrolled`, `totp_required`, `password_browser_only`'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401":
          description: Not authenticated, `step_up_required` (no fresh step-up and none given), or `step_up_failed`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403":
          description: '`assumed_identity`, or an unverified account'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "404":
          description: '`not_found`'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "409":
          description: '`session_revoked`'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "429":
          description: '`too_many_attempts`'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/klock/guest:
    post:
      tags: [Klock]
      summary: A guest grant
      description: |
        An anonymous device key gets the public capability set, with no
        account and no session row — so the API itself never accepts it for an
        account route. It is for the MCP Worker and for public reads such as
        `GET /me/capabilities`. The request is signed by the key in the body,
        with `Authorization: Klock device=<thumbprint of devicePub>`. Five per
        address an hour, one per device a day.
      security:
        - Klock: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [devicePub]
              properties:
                devicePub: { type: string, pattern: "^[A-Za-z0-9_-]{43}$", description: "Ed25519 public key, b64url" }
      responses:
        "201":
          description: The grant
          content:
            application/json:
              schema:
                type: object
                properties:
                  grant: { type: string }
                  sessionId: { type: string, description: Names no row }
                  tier: { type: string, enum: [guest] }
                  caps: { type: array, items: { type: string } }
                  exp: { type: integer }
                  checkInBy: { type: integer }
                  keyset: { type: string, description: The root-signed keyset (compact JWS) }
                  keysetSeq: { type: integer }
        "400":
          description: '`bad_request` — `devicePub` is not a b64url 32-byte key'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401":
          description: '`signature_invalid` (the header does not name `devicePub`, or the signature fails), `clock_skew` (with `Klock-Server-Time`), `nonce_replayed`'
          headers:
            WWW-Authenticate: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "429":
          description: '`rate_limited` — five per address an hour, one per device a day'
          headers:
            Retry-After: { schema: { type: integer }, description: seconds }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "503": { $ref: "#/components/responses/KlockUnavailable" }

  /security/klock/revocations:
    get:
      tags: [Klock]
      summary: The revocation feed
      description: |
        Sessions, devices and certificates revoked after `since`, as a compact
        JWS (typ `klock-revocations+jws`) signed with the grant key, so a
        client verifies it against the keyset. Up to 500 entries a page; `more`
        says there is another — ask again with the last `seq`. Public, and
        cacheable for 30 seconds.
      security: []
      parameters:
        - name: since
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
          description: The last `seq` you saw
      responses:
        "200":
          description: |
            A compact JWS whose payload is `{ seq, entries: [{ seq, kind:
            session|device|cert, ref, at, reason? }], iat, more? }`.
          headers:
            Cache-Control: { schema: { type: string, example: "public, max-age=30" } }
          content:
            application/jose:
              schema: { type: string }
        "400":
          description: '`bad_request` — `since` is not a non-negative integer'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "503": { $ref: "#/components/responses/KlockUnavailable" }

  /security/devices:
    get:
      tags: [Devices]
      summary: Your devices, or a team's
      description: |
        The caller's klock devices, the one asking marked `current`. With
        `?team=`, the devices of that team's members (and any enrolled for the
        team) instead. Checked in the route in every enforcement mode:
        `device:read` for your own; with `team`, the caller's live role in that
        team must carry `device:manage-members`. Not for an api key or a
        service session.

        **Capability:** none from the table; any signed-in account, about itself. Device:read (or device:manage-members with ?team=), checked in the route.
      x-prorm-capability: {"router": "devices", "self": true, "needs": null, "note": "device:read (or device:manage-members with ?team=), checked in the route"}
      parameters:
        - name: all
          in: query
          required: false
          description: "`1` or `true` includes revoked devices (your own list only)"
          schema: { type: string, enum: ["1", "true"] }
        - $ref: "#/components/parameters/DeviceTeamQuery"
      responses:
        "200":
          description: The devices
          content:
            application/json:
              schema:
                type: object
                properties:
                  team:
                    allOf: [{ $ref: "#/components/schemas/DeviceTeamRef" }]
                    description: With `?team=` only
                  devices:
                    type: array
                    items: { $ref: "#/components/schemas/DeviceListed" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/DeviceCapabilityMissing" }
        "404": { $ref: "#/components/responses/UnknownTeam" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /security/devices/pairings/approve:
    post:
      tags: [Devices]
      summary: Approve a new device's pairing code
      description: |
        A new device shows a pairing code; approving it here opens the new
        device's klock session (limits and eviction as for a sign-in, the
        oldest session of that kind evicted or `session_limit` refused), signs
        an enrolment ticket for its key, and has klock enrol it. The new device
        then collects its grant from the klock service.

        Checked in the route in every mode: `device:manage`, not under an
        assumed identity, and only from the console or a klock device — never
        a CLI bearer (`console_only`). `kind` must match what the new device
        asked for. A `device` pairing is a lasting credential and needs a
        fresh step-up, or the proof in this body (`stepUp`, or flat `totp` /
        `password`).

        **Capability:** none from the table; any signed-in account, about itself. Device:manage, and a fresh step-up for a device kind, checked in the route.
      x-prorm-capability: {"router": "devices", "self": true, "needs": null, "note": "device:manage, and a fresh step-up for a device kind, checked in the route"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, pattern: '^[A-Za-z2-7]{4}-?[A-Za-z2-7]{4}$', example: KXQ7-M2PD }
                kind: { type: string, enum: [device, interactive], default: device }
                label: { type: string, maxLength: 120, description: Defaults to the device's hostname }
                stepUp: { $ref: "#/components/schemas/StepUpProof" }
                totp: { type: string, description: The same as `stepUp.totp` }
                password: { type: string, format: password, description: "The same as `stepUp.password` (browser only, no second factor)" }
      responses:
        "200":
          description: Approved and enrolled
          content:
            application/json:
              schema:
                type: object
                properties:
                  approved: { type: boolean, enum: [true] }
                  deviceId: { type: string, example: dev_7fK2mQ }
                  kind: { type: string, enum: [device, interactive] }
                  label: { type: string, nullable: true }
                  hostname: { type: string, nullable: true }
                  platform: { type: string, nullable: true }
                  existing: { type: boolean, description: The key was already enrolled as this device }
        "400":
          description: "`bad_request` (a malformed code or kind, or no step-up proof), `mfa_not_enrolled`, `totp_required`, `password_browser_only`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401":
          description: "Not signed in (`unauthenticated`), a refused klock request, `step_up_failed`, or — for a klock session — `step_up_required`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403":
          description: "`capability_missing` (no `device:manage`), `assumed_identity`, `console_only`, or — for a bearer — `step_up_required`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
              example: { error: console_only, message: "Approve a pairing from the console or a signed-in klock device, not with a CLI token" }
        "404":
          description: "`pairing_not_found` — no pairing has that code"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "409":
          description: "`pairing_used`, `pairing_kind_mismatch`, `session_limit`, `device_key_in_use`, `device_key_revoked`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "410":
          description: "`pairing_expired` — the pairing code has expired"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "429":
          description: "`too_many_attempts` (step-up) or `rate_limited` (klock)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /security/devices/{id}:
    parameters:
      - $ref: "#/components/parameters/DeviceIdPath"
    get:
      tags: [Devices]
      summary: One device
      description: |
        The device in full, the sessions on it, and — for its owner — its last
        50 audit lines. `device:read` for your own; with `team`, a member's
        through `device:manage-members`. A device the caller may not see
        answers 404 `device_not_found`, the same as one that does not exist.

        **Capability:** none from the table; any signed-in account, about itself. Device:read, checked in the route.
      x-prorm-capability: {"router": "devices", "self": true, "needs": null, "note": "device:read, checked in the route"}
      parameters:
        - $ref: "#/components/parameters/DeviceTeamQuery"
      responses:
        "200":
          description: The device
          content:
            application/json:
              schema:
                type: object
                properties:
                  device: { $ref: "#/components/schemas/Device" }
                  sessions:
                    type: array
                    items: { $ref: "#/components/schemas/ManagedSession" }
                  audit:
                    type: array
                    description: The owner's view only
                    items: { $ref: "#/components/schemas/DeviceAuditLine" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/DeviceCapabilityMissing" }
        "404": { $ref: "#/components/responses/DeviceNotFound" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }
    patch:
      tags: [Devices]
      summary: Rename a device
      description: |
        `device:manage` for your own (not under an assumed identity); with
        `team`, a member's through `device:manage-members` (a `reason` is
        optional here).

        **Capability:** none from the table; any signed-in account, about itself. Device:manage, checked in the route.
      x-prorm-capability: {"router": "devices", "self": true, "needs": null, "note": "device:manage, checked in the route"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label: { type: string, minLength: 1, maxLength: 120 }
                team: { type: string, description: "A team's id, uuid or slug, to act on a member's device" }
                reason: { type: string, maxLength: 500 }
      responses:
        "200":
          description: Renamed
          content:
            application/json:
              schema:
                type: object
                properties:
                  device: { $ref: "#/components/schemas/Device" }
        "400":
          description: "`bad_request` — a label is 1–120 characters; `reason_required` — a reason over 500 characters"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/DeviceCapabilityMissing" }
        "404": { $ref: "#/components/responses/DeviceNotFound" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }
    delete:
      tags: [Devices]
      summary: Revoke a device
      description: |
        Final. The device's sessions end at once, it goes on the revocation
        feed, then klock revokes it and its ledger credentials (which go on
        the feed as `cert`). `device:manage` for your own; with `team`, a
        member's through `device:manage-members` — then a `reason` is
        required, the action is audited on the member's trail and they are
        emailed. The team owner's devices are theirs alone.

        **Capability:** none from the table; any signed-in account, about itself. Device:manage (or device:manage-members with team), checked in the route.
      x-prorm-capability: {"router": "devices", "self": true, "needs": null, "note": "device:manage (or device:manage-members with team), checked in the route"}
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceActionRequest" }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  device: { $ref: "#/components/schemas/Device" }
                  sessionsEnded: { type: integer }
                  credentialsRevoked: { type: integer }
        "400": { $ref: "#/components/responses/ReasonRequired" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/DeviceCapabilityMissing" }
        "404": { $ref: "#/components/responses/DeviceNotFound" }
        "409": { $ref: "#/components/responses/DeviceInvalidState" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /security/devices/{id}/suspend:
    post:
      tags: [Devices]
      summary: Suspend a device
      description: |
        Klock stops issuing it grants at sync, and its sessions are frozen so
        its current grant stops working at the API too. Who may: as for
        revoking (a `reason` required on a team member's device).

        **Capability:** none from the table; any signed-in account, about itself. Device:manage (or device:manage-members with team), checked in the route.
      x-prorm-capability: {"router": "devices", "self": true, "needs": null, "note": "device:manage (or device:manage-members with team), checked in the route"}
      parameters: [{ $ref: "#/components/parameters/DeviceIdPath" }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceActionRequest" }
      responses:
        "200":
          description: Suspended
          content:
            application/json:
              schema:
                type: object
                properties:
                  device: { $ref: "#/components/schemas/Device" }
                  sessionsFrozen: { type: integer }
        "400": { $ref: "#/components/responses/ReasonRequired" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/DeviceCapabilityMissing" }
        "404": { $ref: "#/components/responses/DeviceNotFound" }
        "409": { $ref: "#/components/responses/DeviceInvalidState" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /security/devices/{id}/reinstate:
    post:
      tags: [Devices]
      summary: Reinstate a suspended device
      description: |
        The reverse of a suspension: sessions frozen by it thaw. Who may: as
        for revoking (a `reason` required on a team member's device).

        **Capability:** none from the table; any signed-in account, about itself. Device:manage (or device:manage-members with team), checked in the route.
      x-prorm-capability: {"router": "devices", "self": true, "needs": null, "note": "device:manage (or device:manage-members with team), checked in the route"}
      parameters: [{ $ref: "#/components/parameters/DeviceIdPath" }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceActionRequest" }
      responses:
        "200":
          description: Reinstated
          content:
            application/json:
              schema:
                type: object
                properties:
                  device: { $ref: "#/components/schemas/Device" }
                  sessionsThawed: { type: integer }
        "400": { $ref: "#/components/responses/ReasonRequired" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/DeviceCapabilityMissing" }
        "404": { $ref: "#/components/responses/DeviceNotFound" }
        "409": { $ref: "#/components/responses/DeviceInvalidState" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /security/devices/{id}/wipe:
    post:
      tags: [Devices]
      summary: Wipe a device
      description: |
        Its sessions end, it is suspended, and an API-signed `wipe` command is
        queued for its next sync — best effort, and klock material only. A
        revoked or wiped device cannot be wiped (`invalid_state`). Who may: as
        for revoking (a `reason` required on a team member's device).

        **Capability:** none from the table; any signed-in account, about itself. Device:manage (or device:manage-members with team), checked in the route.
      x-prorm-capability: {"router": "devices", "self": true, "needs": null, "note": "device:manage (or device:manage-members with team), checked in the route"}
      parameters: [{ $ref: "#/components/parameters/DeviceIdPath" }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceActionRequest" }
      responses:
        "200":
          description: The wipe is queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  device: { $ref: "#/components/schemas/Device" }
                  sessionsEnded: { type: integer }
                  command: { $ref: "#/components/schemas/DeviceCommand" }
        "400": { $ref: "#/components/responses/ReasonRequired" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/DeviceCapabilityMissing" }
        "404": { $ref: "#/components/responses/DeviceNotFound" }
        "409": { $ref: "#/components/responses/DeviceInvalidState" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /security/devices/{id}/rotate-key:
    post:
      tags: [Devices]
      summary: Ask a device to rotate its key
      description: |
        Queues an API-signed `rotate-key` command for its next sync. Only an
        `active` device (`invalid_state` otherwise). `device:manage` for your
        own; with `team`, a member's through `device:manage-members`.

        **Capability:** none from the table; any signed-in account, about itself. Device:manage, checked in the route.
      x-prorm-capability: {"router": "devices", "self": true, "needs": null, "note": "device:manage, checked in the route"}
      parameters: [{ $ref: "#/components/parameters/DeviceIdPath" }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceActionRequest" }
      responses:
        "200":
          description: The command is queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  device: { $ref: "#/components/schemas/Device" }
                  command: { $ref: "#/components/schemas/DeviceCommand" }
        "400": { $ref: "#/components/responses/ReasonRequired" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/DeviceCapabilityMissing" }
        "404": { $ref: "#/components/responses/DeviceNotFound" }
        "409": { $ref: "#/components/responses/DeviceInvalidState" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /security/access-rules:
    get:
      tags: [Access rules and behaviour]
      summary: The access rules that bear on you
      description: |
        With no `scope`, every rule on the account, its devices (any device
        the account has a session on), its own databases and stores, and the
        teams it is an active member of — oldest first. With `scope`, only
        that scope's rules, after checking the caller may read it.

        Checked in the controller in every enforcement mode: the session holds
        `access-rule:read` (an api key never does), and for a database or
        store scope the caller's access to it carries `access:read`. An
        unknown scope and one you may not read are the same 404.

        **Capability:** `access-rule:read`, checked in the route or controller in every mode. Access-rule:read, and read access to the scope, checked in the controller.
      x-prorm-capability: {"router": "accessRules", "self": true, "needs": null, "checks": "access-rule:read", "note": "access-rule:read, and read access to the scope, checked in the controller"}
      parameters:
        - name: scope
          in: query
          required: false
          schema: { type: string, example: "team:acme" }
          description: |
            `account`, `team:<id|uuid|slug>`, `database:<id|uuid|name>` (or
            `db:`), `store:<id|uuid|name>`, or `device:<id>`.
      responses:
        "200":
          description: The rules
          content:
            application/json:
              schema:
                type: object
                properties:
                  rules: { type: array, items: { $ref: "#/components/schemas/AccessRule" } }
        "400":
          description: "`invalid_rule` — the scope is not one of the forms above"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: invalid_rule, message: "scope is account, team:<t>, database:<d>, store:<s> or device:<id>" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404":
          description: "`not_found` — no such scope, or none you may read"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
    post:
      tags: [Access rules and behaviour]
      summary: Add an access rule
      description: |
        A rule allows or blocks requests by one dimension — client address
        (`ip`, an address or CIDR), `country`, `asn`, `device`, client
        certificate (`cert`), `principal`, `time` (a JSON time window),
        `client` (`cli`, `mcp`, `mcp:name=…`, `browser`, `api`, `agent`) or
        `api-key` — at one scope. Block beats allow; per dimension the most
        specific scope's allowlist decides, and broader `mandatory`
        allowlists intersect with it. A scope holds at most 200 rules.

        A new rule reports for its first 24 hours (`mode: report`, with
        `enforceAt`), then enforces. Send `mode: report` to keep it reporting,
        or `mode: enforce` / `enforceNow: true` to enforce at once. A team
        rule is `mandatory` unless told otherwise.

        **The lock-out guard.** A rule that would refuse your own current
        request is answered 409 `would_lock_you_out` with the reasons. Send
        `force: true` to save it anyway: that needs a fresh step-up (or a
        `totp`, or from the browser a `password`, in this body) and a
        verified email address, which is how recovery reaches you.

        Checked in the controller in every enforcement mode:
        `access-rule:write` for the account, a device or a resource (and the
        resource's `access:write`), `access-rule:team` for a team — which the
        caller's role in that team must also carry. A verified account.

        **Capability:** `access-rule:write`, checked in the route or controller in every mode. Access-rule:write (access-rule:team for a team); the lock-out guard; force needs a step-up.
      x-prorm-capability: {"router": "accessRules", "self": true, "needs": null, "checks": "access-rule:write", "note": "access-rule:write (access-rule:team for a team); the lock-out guard; force needs a step-up"}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccessRuleRequest" }
      responses:
        "201":
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  rule: { $ref: "#/components/schemas/AccessRule" }
                  forced: { type: boolean, description: Present (true) when the lock-out guard was overridden with `force` }
        "400":
          description: |
            `invalid_rule` — the rule does not validate (`problems` lists
            why), the scope is incomplete, or the scope is full. A step-up
            sent with `force` can also answer `mfa_not_enrolled`,
            `totp_required`, `password_browser_only` or `bad_request`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: invalid_rule, message: "That rule is not valid: effect is allow or block", problems: ["effect is allow or block"] }
        "401":
          description: |
            Not authenticated (see `KlockUnauthorized`); or, with `force`,
            `step_up_required` (no fresh step-up and none in the body) or
            `step_up_failed` (the code or password was wrong).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403":
          description: |
            `capability_missing` — the session lacks the capability, it is an
            api key, or the team role cannot change the team's rules; or the
            account is not verified yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "404":
          description: "`not_found` — no such team, resource or device, or none you may write"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "409": { $ref: "#/components/responses/WouldLockYouOut" }
        "429":
          description: "`too_many_attempts` — too many failed step-ups"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/access-rules/context:
    get:
      tags: [Access rules and behaviour]
      summary: This request, as the rules see it
      description: |
        What a rule would be matched against for the request making this
        call — useful before writing one. `country` and `asn` are only filled
        where the edge set them (on Workers); anywhere else they are null.
        Needs `access-rule:read`, checked in the route in every mode.

        **Capability:** `access-rule:read`, checked in the route or controller in every mode. Access-rule:read, checked in the route.
      x-prorm-capability: {"router": "accessRules", "self": true, "needs": null, "checks": "access-rule:read", "note": "access-rule:read, checked in the route"}
      responses:
        "200":
          description: The request's context
          content:
            application/json:
              schema:
                type: object
                properties:
                  ip: { type: string, nullable: true }
                  country: { type: string, nullable: true, example: GB }
                  asn: { type: integer, nullable: true }
                  deviceId: { type: string, nullable: true }
                  client: { type: string, nullable: true, example: cli }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }

  /security/access-rules/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
        description: The rule's uuid.
    get:
      tags: [Access rules and behaviour]
      summary: One access rule
      description: |
        Needs `access-rule:read` and read access to the rule's scope, checked
        in the controller in every mode.

        **Capability:** `access-rule:read`, checked in the route or controller in every mode. Access-rule:read, and read access to the rule's scope.
      x-prorm-capability: {"router": "accessRules", "self": true, "needs": null, "checks": "access-rule:read", "note": "access-rule:read, and read access to the rule's scope"}
      responses:
        "200":
          description: The rule
          content:
            application/json:
              schema:
                type: object
                properties:
                  rule: { $ref: "#/components/schemas/AccessRule" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404":
          description: "`not_found` — no such rule, or none you may read"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
    delete:
      tags: [Access rules and behaviour]
      summary: Remove an access rule
      description: |
        Runs the same lock-out guard as a create — removing an allow can shut
        an allowlist on you — and takes `force` (in the body or the query)
        with the same step-up. A lockdown is not removed here: end it with
        `DELETE /security/lockdown`.

        Needs `access-rule:write` on the rule's scope (`access-rule:team`, and
        a role that carries it, for a team), checked in the controller in
        every mode. A verified account.

        **Capability:** `access-rule:write`, checked in the route or controller in every mode. Access-rule:write on the rule's scope; the lock-out guard.
      x-prorm-capability: {"router": "accessRules", "self": true, "needs": null, "checks": "access-rule:write", "note": "access-rule:write on the rule's scope; the lock-out guard"}
      parameters:
        - name: force
          in: query
          required: false
          schema: { type: boolean }
          description: Remove it even though it would lock you out (needs a step-up).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                force: { type: boolean }
                totp: { type: string, description: "A step-up, when forcing without a fresh one" }
                password: { type: string, format: password, description: "The browser's step-up, on an account without a second factor" }
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  removed: { type: boolean, enum: [true] }
                  rule: { $ref: "#/components/schemas/AccessRule" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404":
          description: "`not_found` — no such rule, or none you may change"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "409":
          description: |
            `would_lock_you_out` (see `WouldLockYouOut`), or `invalid_rule`
            when the rule is a lockdown.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/behaviour:
    get:
      tags: [Access rules and behaviour]
      summary: The behaviour profiles that bear on you
      description: |
        A behaviour profile says what normal looks like at a scope and what to
        do when a request is not normal (`log`, `alert`, `step-up`,
        `rate-limit`, `freeze`, `revoke`). With no `scope`, every profile on
        the account, its devices, its sessions and service sessions, and its
        teams. Needs `behaviour:read`, checked in the controller in every
        mode.

        **Capability:** `behaviour:read`, checked in the route or controller in every mode. Behaviour:read, checked in the controller.
      x-prorm-capability: {"router": "behaviour", "self": true, "needs": null, "checks": "behaviour:read", "note": "behaviour:read, checked in the controller"}
      parameters:
        - name: scope
          in: query
          required: false
          schema: { type: string, example: account }
          description: "`account`, `team:<id|uuid|slug>`, `device:<id>`, `session:<jti|id>` or `service:<jti|id>`."
      responses:
        "200":
          description: The profiles
          content:
            application/json:
              schema:
                type: object
                properties:
                  profiles: { type: array, items: { $ref: "#/components/schemas/BehaviourProfile" } }
        "400":
          description: "`invalid_profile` — the scope is not one of the forms above"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404":
          description: "`not_found` — no such scope, or none you may read"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
    post:
      tags: [Access rules and behaviour]
      summary: Add or change a behaviour profile
      description: |
        Saves by scope and name: a profile of that name at that scope is
        changed (its `version` goes up, 200), otherwise one is created (201).
        The spec is validated by the ORM's engine, `extends` included; give a
        `preset` alone and the spec is that preset. A new profile reports
        (records findings, applies nothing but `log`) unless `mode: enforce`.

        Changing a profile that is enforcing needs a fresh step-up (or a
        `totp`, or from the browser a `password`, in this body).

        Checked in the controller in every mode: `behaviour:write`
        (`behaviour:team`, and a role that carries it, for a team). A
        verified account.

        **Capability:** `behaviour:write`, checked in the route or controller in every mode. Behaviour:write (behaviour:team for a team); weakening an enforcing profile needs a step-up.
      x-prorm-capability: {"router": "behaviour", "self": true, "needs": null, "checks": "behaviour:write", "note": "behaviour:write (behaviour:team for a team); weakening an enforcing profile needs a step-up"}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BehaviourProfileRequest" }
      responses:
        "200":
          description: Changed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BehaviourProfileSaved" }
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BehaviourProfileSaved" }
        "400":
          description: |
            `invalid_profile` — no spec and no preset, an unknown preset, a
            spec that does not validate, no name, a bad `mode`, or a scope
            that is incomplete or not a service session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401":
          description: Not authenticated; or `step_up_required` / `step_up_failed` when changing an enforcing profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404":
          description: "`not_found` — no such team, device or session, or none you may write"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/behaviour/presets:
    get:
      tags: [Access rules and behaviour]
      summary: The profile presets
      description: |
        The ORM's built-in presets, each with its full spec, to save as they
        are or to `extends`. Needs `behaviour:read`, checked in the route.

        **Capability:** `behaviour:read`, checked in the route or controller in every mode. Behaviour:read, checked in the route.
      x-prorm-capability: {"router": "behaviour", "self": true, "needs": null, "checks": "behaviour:read", "note": "behaviour:read, checked in the route"}
      responses:
        "200":
          description: The presets
          content:
            application/json:
              schema:
                type: object
                properties:
                  presets:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, example: ci-service }
                        description: { type: string }
                        spec: { type: object, nullable: true, additionalProperties: true }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }

  /security/behaviour/findings:
    get:
      tags: [Access rules and behaviour]
      summary: Your behaviour findings
      description: |
        What your profiles caught, newest first, at most 500. Only the
        unacknowledged ones unless `all=1`. Needs `behaviour:read`, checked
        in the controller.

        **Capability:** `behaviour:read`, checked in the route or controller in every mode. The caller's own findings.
      x-prorm-capability: {"router": "behaviour", "self": true, "needs": null, "checks": "behaviour:read", "note": "the caller's own findings"}
      parameters:
        - name: all
          in: query
          required: false
          schema: { type: string, enum: ["1", "true"] }
          description: Include acknowledged findings.
      responses:
        "200":
          description: The findings
          content:
            application/json:
              schema:
                type: object
                properties:
                  findings: { type: array, items: { $ref: "#/components/schemas/BehaviourFinding" } }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }

  /security/behaviour/findings/{id}/ack:
    post:
      tags: [Access rules and behaviour]
      summary: Acknowledge a finding
      description: |
        One of your own findings. Acknowledging twice changes nothing. Needs
        `behaviour:read`, checked in the controller; a verified account.

        **Capability:** `behaviour:read`, checked in the route or controller in every mode. The caller's own findings.
      x-prorm-capability: {"router": "behaviour", "self": true, "needs": null, "checks": "behaviour:read", "note": "the caller's own findings"}
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Acknowledged
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BehaviourFindingAck" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404":
          description: "`not_found` — no such finding, or not yours"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/behaviour/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
        description: The profile's uuid.
    get:
      tags: [Access rules and behaviour]
      summary: One behaviour profile
      description: |
        Needs `behaviour:read` and read access to the profile's scope, checked
        in the controller in every mode.

        **Capability:** `behaviour:read`, checked in the route or controller in every mode. Behaviour:read, and read access to the profile's scope.
      x-prorm-capability: {"router": "behaviour", "self": true, "needs": null, "checks": "behaviour:read", "note": "behaviour:read, and read access to the profile's scope"}
      responses:
        "200":
          description: The profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  profile: { $ref: "#/components/schemas/BehaviourProfile" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404":
          description: "`not_found` — no such profile, or none you may read"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
    delete:
      tags: [Access rules and behaviour]
      summary: Remove a behaviour profile
      description: |
        Removes the profile and what it had learned. Removing one that is
        enforcing needs a fresh step-up (or a `totp`, or from the browser a
        `password`, in the body). Needs `behaviour:write` (`behaviour:team`
        for a team), checked in the controller; a verified account.

        **Capability:** `behaviour:write`, checked in the route or controller in every mode. Behaviour:write; removing an enforcing profile needs a step-up.
      x-prorm-capability: {"router": "behaviour", "self": true, "needs": null, "checks": "behaviour:write", "note": "behaviour:write; removing an enforcing profile needs a step-up"}
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StepUpProof" }
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Removed" }
        "401":
          description: Not authenticated; or `step_up_required` / `step_up_failed` for an enforcing profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404":
          description: "`not_found` — no such profile, or none you may change"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/recovery/start:
    post:
      tags: [Recovery and lockdown]
      summary: Ask for a recovery link
      description: |
        For an account its own access rules (or a lockdown) lock out. Emails
        a one-time link to the console (`#/recover?code=…`), good for 30
        minutes. The answer is the same whether or not the address has an
        account. At most 10 requests an hour from one client address (429),
        and — silently, so as not to say the address exists — 3 links an
        hour per account.

        **Capability:** none from the table; the credential is its own (see above). An email address; rate-limited; the same answer for every address.
      x-prorm-capability: {"router": "recovery", "external": true, "needs": null, "note": "an email address; rate-limited; the same answer for every address"}
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "202":
          description: If the address has an account, a link is on its way
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "429":
          description: "`rate_limited` — too many requests from this address (Retry-After: 3600)"
          headers:
            Retry-After: { schema: { type: integer }, description: seconds }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/recovery/complete:
    post:
      tags: [Recovery and lockdown]
      summary: Recover access with the emailed code
      description: |
        Takes the code from the link, the password, and — when the account
        has a second factor — a `totp` or a `recoveryCode`. Failures count
        against the same windowed lockout as sign-in, per account and per
        client address.

        Success spends the code and answers a one-hour bearer token carrying
        `recovery: true`. Account-scope access rules do not apply to it (team,
        device and resource rules still do); the account's block rules go to
        report mode for review, a lockdown in force ends, it is audited at
        `alert`, and the account is emailed.

        **Capability:** none from the table; the credential is its own (see above). The emailed code + password + MFA when enrolled.
      x-prorm-capability: {"router": "recovery", "external": true, "needs": null, "note": "the emailed code + password + MFA when enrolled"}
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, password]
              properties:
                code: { type: string, maxLength: 200 }
                password: { type: string, format: password }
                totp: { type: string, description: Required (or recoveryCode) when the account has a second factor }
                recoveryCode: { type: string }
      responses:
        "200":
          description: A one-hour recovery session
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  tokenType: { type: string, enum: [Bearer] }
                  recovery: { type: boolean, enum: [true] }
                  expiresIn: { type: integer, example: 3600, description: seconds }
                  expiresAt: { type: string, format: date-time }
                  disabledRules: { type: integer, description: Block rules switched to report mode }
                  message: { type: string }
                  user: { $ref: "#/components/schemas/User" }
        "400":
          description: "`recovery_failed` — the code is unknown, spent or expired; or the body did not pass its rules"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: recovery_failed, message: That recovery link or its details are not valid }
        "401":
          description: |
            `recovery_failed` — the password, TOTP or recovery code is wrong;
            `totp_required` — the account has a second factor and none was sent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "429":
          description: "`too_many_attempts` — too many failures for this account or from this address"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/lockdown:
    get:
      tags: [Recovery and lockdown]
      summary: Is the account locked down
      description: |
        Needs `session:read`, checked in the route in every mode; not under
        an assumed identity.

        **Capability:** `session:read`, checked in the route or controller in every mode. Session:read, checked in the route.
      x-prorm-capability: {"router": "lockdown", "self": true, "needs": null, "checks": "session:read", "note": "session:read, checked in the route"}
      responses:
        "200":
          description: The state
          content:
            application/json:
              schema:
                type: object
                properties:
                  lockedDown: { type: boolean }
                  lockdownUntil: { type: string, format: date-time, nullable: true }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: "`capability_missing`, or `assumed_identity` under an assumed identity"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
    post:
      tags: [Recovery and lockdown]
      summary: Lock the account down
      description: |
        The emergency switch. Signs out every interactive, device, delegated,
        browser and CLI session of the account — this one too — and, with
        `includeServices`, its service sessions (databases, CI, agents); asks
        the klock service to suspend every device; and engages an
        account-scope rule that refuses every request for 15 minutes (423
        `lockdown`), so a sign-in made with stolen credentials in that window
        gets a token it cannot use. Only a recovery session gets through. It
        is audited at `alert` and the account is emailed.

        Needs `session:manage` and a step-up, checked in the route in every
        mode: a fresh one, or a `totp` (or from the browser a `password`) in
        the body. A klock session on an account with no second factor needs
        neither — its device signature is the proof. Not under an assumed
        identity.

        **Capability:** `session:manage`, checked in the route or controller in every mode. Session:manage and a step-up, checked in the route.
      x-prorm-capability: {"router": "lockdown", "self": true, "needs": null, "checks": "session:manage", "note": "session:manage and a step-up, checked in the route"}
      requestBody:
        required: false
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/StepUpProof"
                - type: object
                  properties:
                    includeServices: { type: boolean, default: false }
      responses:
        "200":
          description: Locked down
          content:
            application/json:
              schema:
                type: object
                properties:
                  lockedDown: { type: boolean, enum: [true] }
                  revoked: { type: integer, description: Sessions signed out }
                  suspended: { type: integer, description: "Devices suspended (or, without the klock service, devices whose sessions were signed out)" }
                  devices: { type: array, items: { type: string } }
                  lockdownUntil: { type: string, format: date-time }
        "400":
          description: "The step-up in the body is the wrong kind: `mfa_not_enrolled`, `totp_required`, `password_browser_only`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401":
          description: "Not authenticated; `step_up_required` (none fresh and none sent) or `step_up_failed`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403":
          description: "`capability_missing`, or `assumed_identity`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "429":
          description: "`too_many_attempts` — too many failed step-ups"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
    delete:
      tags: [Recovery and lockdown]
      summary: Release a lockdown
      description: |
        Needs a fresh sign-in — a session opened within the last 15 minutes —
        and, when the account has a second factor, its `totp` now; or a
        recovery session, which needs neither. Ends the lockdown rule;
        devices stay suspended until reinstated one by one
        (`POST /security/devices/{id}/reinstate`). Audited at `alert`, and the
        account is emailed.

        Needs `session:manage`, checked in the route in every mode; not under
        an assumed identity.

        **Capability:** `session:manage`, checked in the route or controller in every mode. Session:manage, a fresh sign-in and MFA (or a recovery session).
      x-prorm-capability: {"router": "lockdown", "self": true, "needs": null, "checks": "session:manage", "note": "session:manage, a fresh sign-in and MFA (or a recovery session)"}
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StepUpProof" }
      responses:
        "200":
          description: Released
          content:
            application/json:
              schema:
                type: object
                properties:
                  lockedDown: { type: boolean, enum: [false] }
                  released: { type: boolean, description: Whether there was a lockdown to end }
        "400":
          description: "The step-up in the body is the wrong kind: `mfa_not_enrolled`, `totp_required`, `password_browser_only`, `bad_request`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401":
          description: |
            Not authenticated; `fresh_signin_required` — the session is older
            than 15 minutes; `step_up_required` — the account has a second
            factor and no `totp` was sent; `step_up_failed`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: fresh_signin_required, message: "Releasing a lockdown needs a fresh sign-in: sign in again, then release within 15 minutes" }
        "403":
          description: "`capability_missing`, or `assumed_identity`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "429":
          description: "`too_many_attempts` — too many failed step-ups"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/mcp/clients:
    post:
      tags: [MCP tokens]
      summary: Make a delegated token for one MCP client
      description: |
        A token (`prorm_dt_…`) a third-party MCP client holds: a subset of the
        caller's capabilities, optionally locked to one database or store,
        expiring in at most 30 days, never admin. The token is in this
        response and nowhere else — only its hash is kept.

        Checked in the route, in every enforcement mode: the caller is a
        person's own session (bearer or Klock — not an api key, a guest or an
        assumed identity) holding `mcp:clients`, and holds every capability it
        delegates. Five live tokens per account (or the session kind's
        `concurrent` limit): making another revokes the least recently used.

        **Capability:** none from the table; any signed-in account, about itself. Mcp:clients, checked in the route.
      x-prorm-capability: {"router": "mcpClients", "self": true, "needs": null, "note": "mcp:clients, checked in the route"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [clientName, caps]
              properties:
                clientName: { type: string, minLength: 1, maxLength: 80, description: Printable ASCII }
                label: { type: string, maxLength: 120, nullable: true, description: Cut to 120 characters }
                caps:
                  type: array
                  minItems: 1
                  maxItems: 64
                  items: { type: string, example: data:read }
                  description: |
                    Capability ids (a group expands). Each must exist, be
                    delegable, and be held by the caller. `*` and `admin:*` are refused.
                resource:
                  type: object
                  nullable: true
                  description: Lock the token to one database or store the caller reaches.
                  required: [kind, id]
                  properties:
                    kind: { type: string, enum: [database, store] }
                    id: { type: integer }
                expiresInDays: { type: integer, minimum: 1, maximum: 30, default: 30 }
                teamUuid: { type: string, format: uuid, nullable: true, description: A team the caller is a member of }
      responses:
        "201":
          description: Made. `token` is shown once.
          headers:
            Cache-Control: { schema: { type: string, enum: [no-store] } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string, example: prorm_dt_8Qw… }
                  client: { $ref: "#/components/schemas/McpClient" }
        "400":
          description: |
            `bad_request` (clientName, caps, expiresInDays or resource malformed),
            `admin_not_delegable`, `unknown_capability` (with `unknown`),
            `not_delegable` (with `notDelegable`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: admin_not_delegable, message: Admin capabilities are never delegated }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: |
            `capability_missing` — the session lacks `mcp:clients`, or does not
            hold a capability it tried to delegate (`needs` lists them);
            `assumed_identity`; or the account is not verified yet (older `{error}` shape).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "404":
          description: "`not_found` — the resource or the team is not one the caller reaches"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
    get:
      tags: [MCP tokens]
      summary: The account's delegated tokens
      description: |
        Newest first; revoked ones stay listed for 30 days. Never the token.
        Needs `mcp:clients`, checked in the route in every mode.

        **Capability:** none from the table; any signed-in account, about itself. Mcp:clients, checked in the route.
      x-prorm-capability: {"router": "mcpClients", "self": true, "needs": null, "note": "mcp:clients, checked in the route"}
      responses:
        "200":
          description: The tokens
          content:
            application/json:
              schema:
                type: object
                properties:
                  clients:
                    type: array
                    items: { $ref: "#/components/schemas/McpClient" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: "`capability_missing` (`mcp:clients`), `assumed_identity`, or an unverified account"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }

  /security/mcp/clients/{id}:
    delete:
      tags: [MCP tokens]
      summary: Revoke a delegated token
      description: |
        At once: the next call with it is refused, and the revoke goes onto
        the revocation feed so the MCP Worker drops its cached view. Needs
        `mcp:clients`, checked in the route in every mode.

        **Capability:** none from the table; any signed-in account, about itself. Mcp:clients, checked in the route.
      x-prorm-capability: {"router": "mcpClients", "self": true, "needs": null, "note": "mcp:clients, checked in the route"}
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: The token's `id` from the listing
      responses:
        "200":
          description: Revoked (revoking again is not an error)
          content:
            application/json:
              schema:
                type: object
                properties:
                  client: { $ref: "#/components/schemas/McpClient" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: "`capability_missing` (`mcp:clients`), `assumed_identity`, or an unverified account"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "404":
          description: "`not_found` — no such token on this account"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/mcp/capabilities:
    get:
      tags: [MCP tokens]
      summary: What a delegated token may do now
      description: |
        The token's scope intersected with what its owner may do right now
        (re-resolved on every call), and the MCP tools that leaves. The MCP
        Worker filters `tools/list` with it. Takes only a delegated token.

        **Capability:** none from the table; the credential is its own (see above). A delegated MCP client token.
      x-prorm-capability: {"router": "mcpClients", "external": true, "needs": null, "note": "a delegated MCP client token"}
      security:
        - delegatedBearer: []
      responses:
        "200":
          description: The token's live view
          headers:
            Cache-Control: { schema: { type: string, enum: [no-store] } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/McpDelegatedView" }
        "401":
          description: |
            `token_invalid`, `token_revoked`, or `token_expired` (past its
            expiry, or unused past the idle limit). `WWW-Authenticate: Bearer error="<code>"`.
          headers:
            WWW-Authenticate: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: token_expired, message: That MCP client token has expired }
        "423":
          description: "`session_frozen` — the token is frozen"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /security/mcp/call:
    post:
      tags: [MCP tokens]
      summary: Run one MCP tool with a delegated token
      description: |
        The tool is looked up in the shared MCP registry, and the HTTP call it
        stands for is derived from the registry — the arguments only fill its
        path segments and body. The call is then run through this API as the
        token's owner, with the capabilities narrowed to what was checked, so
        the target route's own authentication, ownership and capability checks
        still apply. The answer is that route's answer, with an
        `X-Prorm-Mcp-Tool` header.

        Checked here: the tool's `needs` against the token's live capabilities
        (and `data:read` / `data:write` by a query's intent), the resource
        lock, no admin tools, a confirmation for a destructive tool, and a
        fresh step-up for a `stepUp` tool — which a delegated token can never
        make. An unknown tool and a forbidden one get the same 404.

        **Capability:** none from the table; the credential is its own (see above). A delegated MCP client token; the tool's needs, re-derived from the registry.
      x-prorm-capability: {"router": "mcpClients", "external": true, "needs": null, "note": "a delegated MCP client token; the tool's needs, re-derived from the registry"}
      security:
        - delegatedBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/McpToolCall" }
      responses:
        "200":
          description: The derived route's answer (its own status and body)
          headers:
            X-Prorm-Mcp-Tool: { schema: { type: string }, description: The tool that ran }
        "400":
          description: "`bad_arguments` — arguments not an object, or a required one missing"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401":
          description: |
            `token_invalid`, `token_revoked`, `token_expired`; or
            `step_up_required` for a tool that needs a step-up.
          headers:
            WWW-Authenticate: { schema: { type: string }, description: '`Bearer error="<code>"`' }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403":
          description: "`capability_missing` — a query's intent the token cannot carry (`needs` is `data:read` or `data:write`)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "404":
          description: "`tool_not_found` — no such tool, or not one this token may use"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: tool_not_found, message: Unknown tool }
        "423":
          description: "`session_frozen`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "428":
          description: |
            `confirmation_required` — a destructive tool. Call again with the
            same arguments plus `confirm` set to the token given, within
            `expiresIn` seconds.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ApiError"
                  - type: object
                    properties:
                      tool: { type: string }
                      confirm: { type: string, minLength: 32, maxLength: 32 }
                      expiresIn: { type: integer, example: 120 }
        "503":
          description: "`mcp_unavailable` — the tool registry on this API is out of date, or confirmation is not configured"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /teams:
    get:
      tags: [Teams]
      summary: The teams this account is on
      description: |
        **Capability:** none from the table; any signed-in account, about itself.
      x-prorm-capability: {"router": "teams", "self": true, "needs": null}
      responses:
        "200":
          description: The teams, each with your role and what it can do
          content:
            application/json:
              schema:
                type: object
                properties:
                  teams: { type: array, items: { $ref: "#/components/schemas/Team" } }
    post:
      tags: [Teams]
      summary: Start one
      description: |
        The creator is its owner, and that role is not granted to anybody else.

        **Capability:** `team:create` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:create", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, slug]
              properties:
                name: { type: string, maxLength: 100 }
                slug: { type: string, pattern: "^[a-z0-9][a-z0-9-]{1,59}$" }
                description: { type: string, maxLength: 500 }
      responses:
        "201": { description: Created }
        "409": { description: That slug is taken }

  /teams/roles:
    get:
      tags: [Teams]
      summary: The twelve roles, and what each may do
      description: |
        Roles are capability sets, not a ladder: `billing` is not "less than"
        `developer`, it is a different job.

        **Capability:** none from the table; any signed-in account, about itself.
      x-prorm-capability: {"router": "teams", "self": true, "needs": null}
      responses:
        "200":
          description: Every role with its capabilities
          content:
            application/json:
              schema:
                type: object
                properties:
                  roles:
                    type: array
                    items:
                      type: object
                      properties:
                        role: { $ref: "#/components/schemas/TeamRole" }
                        capabilities: { type: array, items: { type: string } }

  /teams/{teamId}:
    patch:
      tags: [Teams]
      summary: Rename a team
      description: |
        Not the slug — that is an address, and things point at it.

        **Capability:** `team:manage-members` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:manage-members", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 100 }
                description: { type: string, nullable: true }
      responses:
        "200": { description: Updated }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Teams]
      summary: Close a team
      description: |
        Owner only, and asks for the password: closing takes the projects with
        it, which is not something an admin should be able to do to the person
        paying. The databases themselves are not touched.

        **Capability:** `team:read` (area `team`). And the team owner, with their password.
      x-prorm-capability: {"router": "teams", "needs": "team:read", "area": "team", "note": "and the team owner, with their password"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current-password]
              properties: { current-password: { type: string, format: password } }
      responses:
        "204": { description: Closed }
        "403": { description: Only the owner can close a team }
    get:
      tags: [Teams]
      summary: One team
      description: |
        A team you are not on answers 404 — whether it exists is not yours to know.

        **Capability:** `team:read` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:read", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      responses:
        "200": { description: The team }
        "404": { $ref: "#/components/responses/NotFound" }

  /teams/{teamId}/sso:
    parameters:
      - { $ref: "#/components/parameters/TeamId" }
    get:
      tags: [Single sign-on]
      summary: The team's single sign-on, as a member sees it
      description: |
        Any member (`team:read`). The client secret is never returned;
        `hasSecret` says one is set. With each claimed domain's state and the
        DNS record that proves it (`domainClaims`), the callback URL the
        provider has to be told, and the address a sign-in starts at.

        **Capability:** `team:read` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:read", "area": "team"}
      responses:
        "200":
          description: "The provider, or `sso: null`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TeamSsoResponse" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Single sign-on]
      summary: Set the team's OpenID Connect provider up, or change it
      description: |
        Needs `sso:manage` (owner, admin, security) and a verified account.
        The issuer's discovery document is read at once, so a wrong URL is
        this request's error. The issuer is https (a loopback issuer may be
        http) with no query or fragment. The secret is required the first
        time, sealed, and never returned; leave it out on a change to keep the
        one set. Identities are bound to the issuer that made them: after the
        issuer changes, no subject linked under the old one signs anybody in,
        and members link again (accounts that linked from their own session do
        so with POST /sso/link; accounts the team's sign-in created, in a role
        without `sso:manage`, are re-linked by their confirmed address at a
        verified domain).

        `domains` are the email domains whose people join the team on first
        sign-in, with `defaultRole`. Each is a **claim**, `pending` until the
        team proves it with a DNS TXT record (`POST
        /teams/{teamId}/sso/domains/{domain}/verify`); a pending claim admits
        nobody and discovery does not name it. `enforced` refuses members'
        passwords at sign-in (the owner is exempt) and needs `enabled`.
        Audited.

        **Capability:** `sso:manage` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "sso:manage", "area": "team"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [issuer, clientId]
              properties:
                issuer: { type: string, format: uri, description: "The issuer as the provider states it, https" }
                clientId: { type: string, minLength: 1, maxLength: 255 }
                clientSecret: { type: string, minLength: 1, maxLength: 2000, description: Required the first time }
                domains:
                  type: array
                  maxItems: 20
                  items: { type: string, pattern: '^[a-z0-9.-]+\.[a-z]{2,}$' }
                  description: Claimed email domains, e.g. example.com
                defaultRole: { type: string, description: A team role other than owner and guest; viewer by default }
                enabled: { type: boolean }
                enforced: { type: boolean }
      responses:
        "200":
          description: Changed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TeamSsoResponse" }
        "201":
          description: Set up
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TeamSsoResponse" }
        "400":
          description: The body did not pass its rules; not an OpenID Connect issuer (or one at a non-public address); no secret the first time; or enforced without enabled
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/ValidationErrors" }
                  - { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502":
          description: The issuer did not answer, or has no readable discovery document
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Single sign-on]
      summary: Remove the provider
      description: |
        Needs `sso:manage` and a verified account. Its linked identities and
        domain claims go with it. Members keep their accounts and memberships
        and sign in with passwords again. Audited.

        **Capability:** `sso:manage` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "sso:manage", "area": "team"}
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: "`Unknown team`, or `This team has no single sign-on`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /teams/{teamId}/sso/domains:
    get:
      tags: [Single sign-on]
      summary: The team's domain claims and the DNS record each needs
      description: |
        A domain listed in the provider's `domains` is a claim, `pending`
        until a TXT record at `challenge.name`
        (`_prorm-challenge.<domain>`) carries `challenge.value`
        (`prorm-domain-verification=<token>`) and the claim is verified; only
        a `verified` domain admits new people or is named by discovery. Any
        member (`team:read`).

        **Capability:** `team:read` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:read", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      responses:
        "200":
          description: The claims
          content:
            application/json:
              schema:
                type: object
                properties:
                  domains: { type: array, items: { $ref: "#/components/schemas/TeamSsoDomain" } }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /teams/{teamId}/sso/domains/{domain}/verify:
    post:
      tags: [Single sign-on]
      summary: Prove a claimed domain
      description: |
        Looks up the TXT record at `_prorm-challenge.<domain>` and, when it
        carries this claim's value, moves the claim from pending to verified.
        One already verified answers 200 at once. A domain another team has
        verified is refused, record or no record. Needs `sso:manage` and a
        verified account. Audited (`team-sso-domain-verified`,
        `team-sso-domain-check-failed`).

        **Capability:** `sso:manage` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "sso:manage", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { name: domain, in: path, required: true, schema: { type: string, pattern: '^[a-z0-9.-]+\.[a-z]{2,}$' } }
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain: { $ref: "#/components/schemas/TeamSsoDomain" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: "`Unknown team`, or the team has not claimed that domain"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Another team has already verified it
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Still pending; no TXT record says the value yet
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain: { $ref: "#/components/schemas/TeamSsoDomain" }
                  error: { type: string }
        "502":
          description: DNS could not be asked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /teams/{teamId}/members:
    get:
      tags: [Members]
      summary: Who is on it
      description: |
        **Capability:** `team:read` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:read", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      responses:
        "200":
          description: The members
          content:
            application/json:
              schema:
                type: object
                properties:
                  members: { type: array, items: { $ref: "#/components/schemas/TeamMember" } }
    post:
      tags: [Members]
      summary: Invite somebody
      description: |
        Needs `team:manage-members`. Ownership is transferred, never granted.

        **Capability:** `team:manage-members` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:manage-members", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role: { $ref: "#/components/schemas/TeamRole" }
      responses:
        "201": { description: Invited }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: No account here uses that address yet }
        "409": { description: Already on the team }

  /teams/{teamId}/members/accept:
    post:
      tags: [Members]
      summary: Accept an invitation
      description: |
        **Capability:** none from the table; any signed-in account, about itself.
      x-prorm-capability: {"router": "teams", "self": true, "needs": null}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      responses:
        "200": { description: Joined }
        "409": { description: The invitation is already accepted }

  /teams/{teamId}/members/{memberId}:
    put:
      tags: [Members]
      summary: Change somebody's role
      description: |
        **Capability:** `team:manage-members` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:manage-members", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { name: memberId, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties: { role: { $ref: "#/components/schemas/TeamRole" } }
      responses:
        "200": { description: Changed }
        "409": { description: The owner's role cannot be changed }
    delete:
      tags: [Members]
      summary: Remove somebody
      description: |
        **Capability:** `team:manage-members` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:manage-members", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { name: memberId, in: path, required: true, schema: { type: integer } }
      responses:
        "204": { description: Removed }
        "409": { description: The owner cannot be removed from their own team }

  /teams/{teamId}/projects:
    get:
      tags: [Projects]
      summary: The projects a team runs
      description: |
        **Capability:** `project:read` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "project:read", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      responses:
        "200": { description: The projects }
    post:
      tags: [Projects]
      summary: Start one
      description: |
        Needs `project:write`.

        **Capability:** `project:write` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "project:write", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 100 }
                description: { type: string, maxLength: 500 }
                environment: { type: string, example: production }
      responses:
        "201": { description: Created }
        "403": { $ref: "#/components/responses/Forbidden" }

  /teams/{teamId}/projects/{projectId}:
    patch:
      tags: [Projects]
      summary: Rename a project, or move its environment
      description: |
        **Capability:** `project:write` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "project:write", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { $ref: "#/components/parameters/ProjectId" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string, nullable: true }
                environment: { type: string }
      responses:
        "200": { description: Updated }
    delete:
      tags: [Projects]
      summary: Delete a project
      description: |
        The grouping goes; the databases in it do not. Deleting a resource is a
        different request — dropping a folder should not drop contents that cost
        money and hold data.

        **Capability:** `project:write` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "project:write", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { $ref: "#/components/parameters/ProjectId" }
      responses:
        "204": { description: Deleted }
    get:
      tags: [Projects]
      summary: One project, and what is in it
      description: |
        **Capability:** `project:read` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "project:read", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { $ref: "#/components/parameters/ProjectId" }
      responses:
        "200": { description: The project and its resources }

  /teams/{teamId}/projects/{projectId}/resources:
    post:
      tags: [Projects]
      summary: Put a database or store in it
      description: |
        **Capability:** `project:write` (area `team`). And ownership of the resource, one team per resource.
      x-prorm-capability: {"router": "teams", "needs": "project:write", "area": "team", "note": "and ownership of the resource, one team per resource"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { $ref: "#/components/parameters/ProjectId" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, id]
              properties:
                kind: { type: string, enum: [database, store] }
                id: { type: integer }
                part: { type: string, example: primary }
      responses:
        "201": { description: Added }
        "409": { description: Already in this project }

    get:
      tags: [Projects]
      summary: What is in a project
      description: |
        **Capability:** `project:read` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "project:read", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { $ref: "#/components/parameters/ProjectId" }
      responses:
        "200": { description: The resources }
  /teams/{teamId}/projects/{projectId}/resources/{resourceId}:
    delete:
      tags: [Projects]
      summary: Take one out
      description: |
        **Capability:** `project:write` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "project:write", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { $ref: "#/components/parameters/ProjectId" }
        - { name: resourceId, in: path, required: true, schema: { type: integer } }
      responses:
        "204": { description: Removed }

  /billing/plans:
    get:
      tags: [Plans and pricing]
      summary: What the plans allow
      security: []
      responses:
        "200":
          description: The catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  plans: { type: array, items: { $ref: "#/components/schemas/Plan" } }

  /billing/rates:
    get:
      tags: [Plans and pricing]
      summary: The rate card
      description: >-
        What each unit costs per month, in integer cents. Databases are billed
        at cost per resource, so this is the whole price list; never a Stripe
        Price id.
      security: []
      responses:
        "200":
          description: Rates, the free allowance, and what membership costs
          content:
            application/json:
              schema:
                type: object
                properties:
                  currency: { type: string, example: usd }
                  rates: { type: object, additionalProperties: { type: integer } }
                  free: { type: object }
                  membership: { type: object }
                  billable: { type: boolean }

  /billing/quote:
    post:
      tags: [Plans and pricing]
      summary: What one database would cost
      description: >-
        Prices a database before it is created, or a resize before it is
        applied. Returns the itemisation rather than a number, so a customer
        can see what each line was computed from.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                size: { type: integer, description: "Reserved storage, MB" }
                cpu: { type: integer, description: vCPU }
                memory: { type: integer, description: "Reserved memory, MB" }
                used: { type: integer, description: "Storage actually used, MB" }
                requests: { type: integer }
                dedicated: { type: boolean, description: A machine of its own }
      responses:
        "200":
          description: The quote, itemised
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }

  /billing/estimate:
    get:
      tags: [Plans and pricing]
      summary: What this account is paying for, itemised
      responses:
        "200":
          description: Every database priced, plus whether membership would pay for itself
          content:
            application/json:
              schema:
                type: object
                properties:
                  estimate: { type: object }
                  membership: { type: object }

  /billing/upcoming:
    get:
      tags: [Subscriptions and invoices]
      summary: What this month's invoice will say
      description: >-
        The same walk the invoice.created webhook makes, without creating
        anything — so a customer can read their bill before Stripe sends it.
      responses:
        "200":
          description: The lines that would be charged
          content:
            application/json:
              schema:
                type: object
                properties:
                  lines:
                    type: array
                    items:
                      type: object
                      properties:
                        key: { type: string, example: "db:1:vcpu" }
                        description: { type: string }
                        amount: { type: integer, description: Cents }
                  total: { type: integer, description: "Cents, across every database" }
                  membership: { type: integer, description: Cents }
                  currency: { type: string }
                  member: { type: boolean }

  /billing/subscription:
    get:
      tags: [Subscriptions and invoices]
      summary: What this account is on
      responses:
        "200": { description: "The plan, and the subscription behind it if there is one" }

  /billing/invoices:
    get:
      tags: [Subscriptions and invoices]
      summary: What it has been charged
      responses:
        "200": { description: The invoices }

  /billing/checkout:
    post:
      tags: [Subscriptions and invoices]
      summary: Start a Stripe Checkout session
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                membership:
                  type: boolean
                  default: false
                  description: >-
                    Add the membership item to the subscription. Every checkout
                    includes the zero-cost usage price, which is the monthly
                    cycle at-cost charges are invoiced against and the thing
                    that collects the card; membership is a second item on the
                    same subscription and is a discount on every rate rather
                    than a tier with allowances.
      responses:
        "200":
          description: Where to send the customer
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
                  id: { type: string }
        "503": { description: Billing is not configured on this deployment }

  /billing/portal:
    post:
      tags: [Subscriptions and invoices]
      summary: Open the customer portal
      description: Upgrades, downgrades, cancellation and card changes are all Stripe's to host.
      responses:
        "200": { description: Where to send the customer }

  /billing/webhook:
    post:
      tags: [Subscriptions and invoices]
      summary: Stripe's events
      description: |
        Verified against `STRIPE_WEBHOOK_SECRET` over the raw body — a body that
        has been parsed and re-serialised no longer verifies. Not for clients.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: Received }
        "400": { description: The signature did not check out }

  /billing/pools:
    post:
      tags: [Subscriptions and invoices]
      summary: Buy more connection pools
      description: |
        Raises the account's pool allowance at once (`extraPools`), so a
        client refused for want of a pool can retry immediately; the charge is
        an invoice item on the next invoice, at the pool price (less
        membership). Needs a verified account; a child's account cannot buy
        one. A team's pools come with the team's plan instead.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                count: { type: integer, minimum: 1, maximum: 50, default: 1 }
      responses:
        "201":
          description: Bought
          headers:
            Idempotency-Key: { $ref: "#/components/headers/IdempotencyKey" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  bought: { type: integer }
                  pricePerMonth: { type: integer, description: Cents per pool per month }
                  extraPools: { type: integer, description: The account's extra pools now }
                  message: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: Not verified yet, or a child's account (`This account cannot set up billing.`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Billing is not configured on this deployment
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /provision/engines:
    get:
      tags: [Databases]
      summary: The engines this API will provision
      security: []
      description: |
        Read from the library, not written down: 27 SQL dialects and 102 stores,
        each marked with whether we can build it and whether it has users of its
        own.

        **Capability:** none — public.
      x-prorm-capability: {"router": "provision", "public": true, "needs": null}
      responses:
        "200":
          description: The catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  databases:
                    type: array
                    items:
                      type: object
                      properties:
                        engine: { type: string, example: postgres }
                        managed: { type: boolean }
                        provider: { type: string, nullable: true }
                        users: { type: boolean, description: Whether it has database users we can manage }
                  stores:
                    type: array
                    items:
                      type: object
                      properties:
                        engine: { type: string, example: redis }
                        managed: { type: boolean }

  /provision/regions:
    get:
      tags: [Databases]
      summary: Where a managed database can be put
      description: |
        **Capability:** none — public.
      x-prorm-capability: {"router": "provision", "public": true, "needs": null}
      security: []
      responses:
        "200": { description: The regions }

  /provision/databases/{id}/connect:
    get:
      tags: [Connections and credentials]
      summary: How to connect to it
      description: |
        The question a person actually has, answered in the form their client
        wants: a DSN for an engine with an address, the HTTP shape for a D1
        database, and the tunnel command in front of either when the access mode
        says so.

        It never returns a password. What you connect with is a database user
        you created or an api key you were issued, and this says which.

        **Capability:** `key:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "key:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: Everything needed to connect
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectionInfo" }
        "409": { description: Nothing is recorded about how to reach it }

  /provision/databases/{id}/ssh-keys:
    get:
      tags: [Connections and credentials]
      summary: The ssh keys that open its bastion
      description: |
        **Capability:** `access:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "access:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The keys, public halves only
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys: { type: array, items: { $ref: "#/components/schemas/SshKey" } }
    post:
      tags: [Connections and credentials]
      summary: Add a key, or have one made
      description: |
        Send `publicKey` and we never see the private half — the better of the
        two, since a key we cannot hold is a key we cannot leak. Send none and a
        pair is generated; the private half comes back once, here.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 100, example: ada laptop }
                algorithm: { type: string, enum: [ed25519, rsa], default: ed25519 }
                publicKey: { type: string, description: "An authorized_keys line, or a PEM" }
                expiresInDays: { type: integer, minimum: 1, maximum: 3650 }
      responses:
        "201":
          description: Added
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { $ref: "#/components/schemas/SshKey" }
                  privateKey: { type: string, description: "Only when we generated it, and only here" }
                  authorizedKeysLine: { type: string }
                  warning: { type: string }

  /provision/databases/{id}/ssh-keys/{keyId}/reveal:
    post:
      tags: [Connections and credentials]
      summary: The private half of a generated key, once
      description: |
        For a client that lost the response. Reading it drops it, so the second
        read is a 404 — a private key we still hold is a liability.

        **Capability:** `access:write` (area `resource`, on the database), with a fresh step-up.
      x-prorm-capability: {"router": "databases", "needs": "access:write", "area": "resource", "resource": "database", "stepUp": true}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { name: keyId, in: path, required: true, schema: { type: integer } }
      responses:
        "200": { description: "The private key, for the last time" }
        "404": { description: "There is none here — it was yours to begin with, or already read" }

  /provision/databases/{id}/ssh-keys/{keyId}:
    delete:
      tags: [Connections and credentials]
      summary: Revoke an ssh key
      description: |
        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "databases", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters:
        - { $ref: "#/components/parameters/PathId" }
        - { name: keyId, in: path, required: true, schema: { type: integer } }
      responses:
        "204": { description: Revoked }

  /provision/databases/{id}/extensions:
    get:
      tags: [Schema and objects]
      summary: The extensions installed in it
      description: |
        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "objects", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200": { description: The extensions }

  /provision/databases/{id}/passwordless:
    parameters: [{ $ref: "#/components/parameters/PathId" }]
    get:
      tags: [Passwordless]
      summary: The database's passwordless sessions
      description: |
        Paginated with `limit` (default 50, at most 500) and `offset`; a `Link` header points at the next page.

        **Capability:** `access:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "passwordless", "needs": "access:read", "area": "resource", "resource": "database"}
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: The sessions, never their secrets
          headers:
            Link: { schema: { type: string }, description: '`<…>; rel="next"` when there is more' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/PasswordlessSession"
                        - type: object
                          properties:
                            protocol: { type: string, enum: [v1, v1+v2, v2], description: The wire protocols it takes }
                            bound: { type: boolean, description: A device key holds it }
                  page: { $ref: "#/components/schemas/PageWindow" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Passwordless]
      summary: Issue a passwordless session (v1)
      description: |
        A session secret, returned once and never again: it is encrypted into
        the row and no route decrypts it back. The holder keeps it in a
        `.klock` and signs grants with it for the current 30-second window
        (`POST …/passwordless/exchange`, `…/query`). Lose it and revoke the
        session.

        `auth: tls` also issues a client certificate, `kerberos` a Kerberos
        principal, minted by the team's realm when `teamId` is given; the
        request must then present it on every use. To issue a session bound
        to a device key from the start, use `POST …/passwordless/enrol`.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "passwordless", "needs": "access:write", "area": "resource", "resource": "database"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role, scope]
              properties:
                role: { type: string, maxLength: 255, description: The role the holder signs as }
                entId: { type: string, maxLength: 64 }
                label: { type: string, maxLength: 120 }
                scope: { $ref: "#/components/schemas/PasswordlessScope" }
                expiresAt: { type: string, format: date-time }
                auth: { type: string, enum: [secret, tls, kerberos], default: secret }
                teamId: { type: string, format: uuid, description: The team whose realm mints the tls or kerberos identity }
      responses:
        "201":
          description: Issued. The one time the secret is legible.
          content:
            application/json:
              schema:
                type: object
                properties:
                  session: { $ref: "#/components/schemas/PasswordlessSession" }
                  secret: { type: string }
                  secretSeenOnce: { type: boolean, enum: [true] }
                  windowSeconds: { type: integer, example: 30 }
                  tls: { $ref: "#/components/schemas/PasswordlessTlsMaterial" }
                  kerberos: { $ref: "#/components/schemas/PasswordlessKerberosMaterial" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502":
          description: The Kerberos authority refused to issue the identity; nothing was kept
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: SECRETS_KEY is not configured, or tls/kerberos without the Kerberos authority
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /provision/databases/{id}/passwordless/{sessionId}:
    parameters:
      - { $ref: "#/components/parameters/PathId" }
      - { $ref: "#/components/parameters/PasswordlessSessionId" }
    delete:
      tags: [Passwordless]
      summary: Revoke a passwordless session
      description: |
        Kept as a row for the audit trail. A Kerberos principal is removed
        from the KDC, and a service session bound to it (klock v2) is revoked
        with it. Revoking again is not an error.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "passwordless", "needs": "access:write", "area": "resource", "resource": "database"}
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  session: { $ref: "#/components/schemas/PasswordlessSession" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404":
          description: Unknown database, or `Unknown session`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /provision/databases/{id}/passwordless/{sessionId}/bind:
    parameters:
      - { $ref: "#/components/parameters/PathId" }
      - { $ref: "#/components/parameters/PasswordlessSessionId" }
    post:
      tags: [Passwordless]
      summary: Bind a session to a device key
      description: |
        `prorm klock upgrade`: the owner's call, carrying the holder's new
        public keys and the seal binding that proves the holder has the
        signing key. A service session (kind `service`, holding only
        `passwordless:use`) is opened for the key, and from then on the
        session takes device-signed requests — beside its v1 secret
        (`v1+v2`, the default) or instead of it (`v2`).

        Binding again with the same key answers the service session with a
        fresh grant (200) and may tighten to `v2`; another key is refused
        (`already_bound`). A database holds a limited number of bound
        sessions (`session_limit`).

        Checked in the controller, in every mode: an account session holding
        `access:write`.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "passwordless", "needs": "access:write", "area": "resource", "resource": "database"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/ServiceDeviceKeys"
                - type: object
                  properties:
                    protocol: { type: string, enum: [v1+v2, v2], default: v1+v2 }
      responses:
        "201":
          description: Bound
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ServiceSession"
                  - type: object
                    properties:
                      passwordless:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
                          protocol: { type: string, enum: [v1+v2, v2] }
                          databaseId: { type: integer }
                          database: { type: string, format: uuid, nullable: true }
        "200":
          description: Already bound to this key; its grant, fresh (`rebound` is true, and there is no keyset)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ServiceSession"
                  - type: object
                    properties:
                      rebound: { type: boolean, enum: [true] }
                      passwordless:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
                          protocol: { type: string, enum: [v1+v2, v2] }
        "400":
          description: "`bad_request` (keys, protocol, thumbprint, hostname, rotation), `seal_binding_invalid`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: "`capability_missing` (`access:write`), `assumed_identity`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "404":
          description: Unknown database, or `Unknown session`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            `already_bound` (another key holds it), `subject_unavailable` (the
            session is revoked or expired, or its binding has ended),
            `session_limit` (with `limit`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "502":
          description: "`klock_service_unavailable` — the klock service could not register the device"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "503": { $ref: "#/components/responses/KlockUnavailable" }

  /provision/databases/{id}/passwordless/enrol:
    parameters: [{ $ref: "#/components/parameters/PathId" }]
    post:
      tags: [Passwordless]
      summary: Issue a v2 session for a host to bind
      description: |
        A passwordless session that is v2 from birth and never has a secret
        anyone holds, and an enrolment ticket for it. Nothing opens it until a
        host redeems the ticket with its own device key
        (`POST …/passwordless/redeem`, `prorm klock init --service`). The
        ticket is signed by the API, single-use, and lasts fifteen minutes (or
        until the session expires, if sooner). This is what the MCP tool
        `prorm_issue_passwordless_binding` calls.

        Checked in the controller, in every mode: an account session (not an
        api key, a service session or an assumed identity) holding
        `access:write`.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "passwordless", "needs": "access:write", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role, scope]
              properties:
                role: { type: string, maxLength: 255 }
                entId: { type: string, maxLength: 64 }
                label: { type: string, maxLength: 120 }
                scope: { $ref: "#/components/schemas/PasswordlessScope" }
                expiresAt: { type: string, format: date-time, description: In the future }
                rotateBy: { description: "A hard key-rotation deadline, unix seconds or ISO date, within a year", oneOf: [{ type: integer }, { type: string, format: date-time }] }
                rotateEverySec: { type: integer, minimum: 3600, maximum: 31536000, description: Or a schedule that sets one }
      responses:
        "201":
          description: Issued; unbound until the ticket is redeemed
          headers:
            Idempotency-Key: { $ref: "#/components/headers/IdempotencyKey" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  session:
                    allOf:
                      - $ref: "#/components/schemas/PasswordlessSession"
                      - type: object
                        properties:
                          protocol: { type: string, enum: [v2] }
                          bound: { type: boolean, enum: [false] }
                  enrolTicket: { type: string, description: A JWS; hand it to the host }
                  enrolTicketExpiresAt: { type: string, format: date-time }
                  databaseId: { type: integer }
                  database: { type: string, format: uuid, nullable: true }
        "400":
          description: The validators (older `{errors}` shape), or `bad_request` (scope, expiresAt, rotateBy, rotateEverySec)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: "`capability_missing` (`access:write`, or not an account session), `assumed_identity`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503":
          description: "`klock_v2_unavailable` (the ticket cannot be signed), or `secrets_key_missing`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /provision/databases/{id}/passwordless/redeem:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: The database's numeric id or its uuid, as the ticket names it
    post:
      tags: [Passwordless]
      summary: Redeem an enrolment ticket on the host
      description: |
        `prorm klock init --service`. No account session: the ticket from
        `POST …/passwordless/enrol` is the owner's approval, and the seal
        binding proves the host holds the key it binds. The ticket is spent
        only by a request that could bind. The account that issued it must
        still reach the database with `access:write`.

        Answers the service session: its grant, device id and certificate.

        **Capability:** none from the table; the credential is its own (see above). A single-use enrolment ticket (and the device key it binds).
      x-prorm-capability: {"router": "passwordlessGrants", "external": true, "needs": null, "note": "a single-use enrolment ticket (and the device key it binds)"}
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/ServiceDeviceKeys"
                - type: object
                  required: [ticket]
                  properties:
                    ticket: { type: string, description: The enrolment ticket }
      responses:
        "201":
          description: Bound
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ServiceSession"
                  - type: object
                    properties:
                      passwordless:
                        allOf:
                          - $ref: "#/components/schemas/PasswordlessSession"
                          - type: object
                            properties:
                              protocol: { type: string, enum: [v2] }
                              bound: { type: boolean, enum: [true] }
                      databaseId: { type: integer }
                      database: { type: string, format: uuid, nullable: true }
                      role: { type: string }
        "400":
          description: The ticket is missing (older `{errors}` shape); `bad_request` or `seal_binding_invalid` for the keys
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401":
          description: |
            `ticket_invalid` (does not verify, not from this API, for another
            database, or its issuer is gone), `ticket_expired`, `ticket_used`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: ticket_used, message: That enrolment ticket has already been used }
        "403":
          description: "`capability_missing` — the issuer no longer holds `access:write` on the database"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "404":
          description: "`not_found` — the issuer no longer reaches the database"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "409":
          description: "`subject_unavailable` (revoked or expired), `already_bound`, `session_limit`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "502":
          description: "`klock_service_unavailable`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "503": { $ref: "#/components/responses/KlockUnavailable" }

  /provision/databases/{id}/passwordless/exchange:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: The database's numeric id or its uuid; must be the session's database
    post:
      tags: [Passwordless]
      summary: Check a passwordless credential
      description: |
        No account session, by design: the credential is the session's own.
        Two forms.

        **v1** — `credential` in the body: `<body>.<signature>`, a grant
        (role, scope, window) signed with the session secret for the current
        30-second window. Refused once the session is bound `v2`
        (`device_signature_required`).

        **v2** — the request itself, signed by the device key bound to the
        session: `Authorization: Klock <grant>` (the service session's grant)
        or `Authorization: Klock device=<thumbprint>` with the key in
        `Klock-Device-Pub`, plus an RFC 9421 signature over the method, path
        and body digest (single-use nonce). The service session must be live,
        stand for this passwordless session, and hold `passwordless:use`;
        `scope` in the body (or the whole ceiling) is what is asked for.
        Refused for a session never bound (`not_bound`).

        Either way the asked scope must sit inside the ceiling — the session's
        scope, or its role's as it stands now — and a `tls` or `kerberos`
        session must also present its certificate (through the edge) or its
        ticket (`Authorization: Negotiate`). Asked about a `table` and `op`,
        the answer covers that too.

        What comes back is what the caller may do and until when — not a
        credential. The older refusals are `{ error: "<sentence>" }`; the
        klock error catalog names them `v1_*` (`v1_session_unusable`,
        `v1_wrong_database`, `v1_signature_mismatch`, `v1_window_expired`, …).

        **Capability:** none from the table; the credential is its own (see above). A signed grant (v1) or passwordless:use (v2).
      x-prorm-capability: {"router": "passwordlessGrants", "external": true, "needs": null, "note": "a signed grant (v1) or passwordless:use (v2)"}
      security:
        - {}
        - Klock: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sessionId]
              properties:
                sessionId: { type: string, format: uuid }
                credential: { type: string, description: "v1: `<body>.<signature>`. Required unless the request is Klock-signed." }
                scope: { $ref: "#/components/schemas/PasswordlessScope" }
                table: { type: string }
                op: { type: string, enum: [read, write] }
      responses:
        "200":
          description: Allowed, until `expiresAt` (the end of the current window)
          content:
            application/json:
              schema:
                type: object
                properties:
                  role: { type: string }
                  scope: { $ref: "#/components/schemas/PasswordlessScope" }
                  ceiling: { $ref: "#/components/schemas/RoleScope" }
                  database: { type: string, format: uuid }
                  expiresAt: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401":
          description: |
            v2: the signature or grant was refused (`unauthenticated`,
            `grant_invalid`, `grant_expired`, `signature_invalid`, `clock_skew`,
            `nonce_replayed`, `session_revoked`, `device_key_mismatch`). Either:
            the tls or kerberos identity was missing or not the session's
            (`v1_client_cert_required`, `v1_kerberos_refused`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403":
          description: |
            The session cannot be used (unknown, revoked or expired — one
            answer), is for a different database, the credential was refused,
            the grant is for another role, the role is gone, the grant exceeds
            the session, or does not permit the `op` on the `table`.
            `{ error, code }` with `device_signature_required` or `not_bound`
            when the wrong protocol was used; `wrong_subject` or
            `capability_missing` (`passwordless:use`) for a v2 service session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: "This session is bound to a device key: sign the request with it (klock v2)", code: device_signature_required }
        "404":
          description: Unknown database
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "423": { $ref: "#/components/responses/SessionFrozen" }
        "503":
          description: The Kerberos authority could not check the ticket (`v1_kerberos_unavailable`), or `klock_v2_unavailable`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /provision/databases/{id}/passwordless/query:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: The database's numeric id or its uuid; must be the session's database
    post:
      tags: [Passwordless]
      summary: Run one described query under a passwordless credential
      description: |
        The same credential forms and checks as `…/passwordless/exchange` (v1
        signed grant in `credential`, or a v2 device-signed request holding
        `passwordless:use`). The query is described — `select`, `insert`,
        `update`, `delete` — never SQL, and is checked against the grant and
        then the ceiling (tables and columns) before a pooled connection is
        taken. It runs through the same gateway as the data plane: one pool,
        one circuit breaker, one query log.

        **Capability:** none from the table; the credential is its own (see above). A signed grant (v1) or passwordless:use (v2).
      x-prorm-capability: {"router": "passwordlessGrants", "external": true, "needs": null, "note": "a signed grant (v1) or passwordless:use (v2)"}
      security:
        - {}
        - Klock: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sessionId, query]
              properties:
                sessionId: { type: string, format: uuid }
                credential: { type: string, description: "v1: `<body>.<signature>`. Required unless the request is Klock-signed." }
                scope: { $ref: "#/components/schemas/PasswordlessScope" }
                query: { $ref: "#/components/schemas/QueryRequest" }
      responses:
        "200":
          description: The rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows: { type: array, items: { type: object } }
                  meta: { type: object }
                  statement: { type: string, description: What the engine was sent }
                  durationMs: { type: number }
                  pool: { type: string, format: uuid, description: The pool it ran on }
                  attempts: { type: integer, description: Present when it took more than one }
        "400":
          description: Validation, or the query is not one of select/insert/update/delete or names no table (`v1_query_invalid`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: As for `…/passwordless/exchange`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403":
          description: |
            As for `…/passwordless/exchange`, plus a query the grant or the
            session's ceiling does not permit (the refusal names what was
            reached for).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Unknown database
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "423": { $ref: "#/components/responses/SessionFrozen" }
        "429":
          description: Every pool of the database is busy; `next` says how to buy another (`POST /billing/pools`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  next: { type: string }
        "503":
          description: The Kerberos authority could not check the ticket, or `klock_v2_unavailable`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /provision/databases/{id}/roles:
    parameters: [{ $ref: "#/components/parameters/PathId" }]
    get:
      tags: [Database roles]
      summary: The database's roles
      description: |
        Paginated with `limit` (default 50, at most 500) and `offset`.

        **Capability:** `access:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "roles", "needs": "access:read", "area": "resource", "resource": "database"}
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: The roles, each with how many keys it has out
          headers:
            Link: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  roles:
                    type: array
                    items: { $ref: "#/components/schemas/DatabaseRole" }
                  page: { $ref: "#/components/schemas/PageWindow" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Database roles]
      summary: Define a role
      description: |
        A named scope over this database — which operations, which tables,
        and within a table which columns — defined once and granted many
        times. Nothing reaches the engine: no CREATE ROLE, no GRANT; the data
        plane enforces the scope before a pooled connection is used. The name
        is what a holder signs as, so it is an identifier and never changes.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "roles", "needs": "access:write", "area": "resource", "resource": "database"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scope]
              properties:
                name: { type: string, pattern: '^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$' }
                description: { type: string, maxLength: 255, nullable: true }
                scope: { $ref: "#/components/schemas/RoleScope" }
      responses:
        "201":
          description: Defined
          content:
            application/json:
              schema:
                type: object
                properties:
                  role: { $ref: "#/components/schemas/DatabaseRole" }
                  next: { type: string, description: Where to grant it }
        "400":
          description: Validation, or a scope that does not hold together (`{ error }` naming why)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: The database already has a role with that name }

  /provision/databases/{id}/roles/{roleId}:
    parameters:
      - { $ref: "#/components/parameters/PathId" }
      - { $ref: "#/components/parameters/RoleId" }
    get:
      tags: [Database roles]
      summary: One role
      description: |
        **Capability:** `access:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "roles", "needs": "access:read", "area": "resource", "resource": "database"}
      responses:
        "200":
          description: The role
          content:
            application/json:
              schema:
                type: object
                properties:
                  role: { $ref: "#/components/schemas/DatabaseRole" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { description: "Unknown database, or unknown role" }
    patch:
      tags: [Database roles]
      summary: Change what a role permits
      description: |
        The scope and description move; the name does not (sending `name` is
        a 400). Every holder's ceiling is the role's scope as it stands at
        each request, so a narrowed role narrows every holder at once.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "roles", "needs": "access:write", "area": "resource", "resource": "database"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                description: { type: string, maxLength: 255, nullable: true }
                scope: { $ref: "#/components/schemas/RoleScope" }
      responses:
        "200":
          description: Changed
          content:
            application/json:
              schema:
                type: object
                properties:
                  role: { $ref: "#/components/schemas/DatabaseRole" }
        "400":
          description: Validation, `name` sent, or a scope that does not hold together
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { description: "Unknown database, or unknown role" }
    delete:
      tags: [Database roles]
      summary: Delete a role, and revoke every key granted it
      description: |
        The passwordless sessions and api keys granted the role are revoked
        (kept, for the audit trail); the role is soft-deleted so they still
        have a name to point at.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "roles", "needs": "access:write", "area": "resource", "resource": "database"}
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  role: { $ref: "#/components/schemas/DatabaseRole" }
                  revoked:
                    type: object
                    properties:
                      grants: { type: integer }
                      keys: { type: integer }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { description: "Unknown database, or unknown role" }

  /provision/databases/{id}/roles/{roleId}/grant:
    parameters:
      - { $ref: "#/components/parameters/PathId" }
      - { $ref: "#/components/parameters/RoleId" }
    post:
      tags: [Database roles]
      summary: Grant a role, which mints a key
      description: |
        The holder is a label (`ci-runner`, `ada's laptop`), not an account.
        What comes back is a passwordless session and, exactly once, its
        secret: the holder signs grants naming this role and its id, and the
        session's ceiling is the role's scope at each request. Revoke one at
        `DELETE …/passwordless/{sessionId}`. An api key is bound to a role at
        `POST …/keys` with `role`.

        **Capability:** `access:write` (area `resource`, on the database).
      x-prorm-capability: {"router": "roles", "needs": "access:write", "area": "resource", "resource": "database"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label: { type: string, minLength: 1, maxLength: 120 }
                expiresAt: { type: string, format: date-time, nullable: true }
                auth: { type: string, enum: [secret, tls, kerberos], default: secret }
                teamId: { type: string, format: uuid, description: The team whose realm mints the tls or kerberos identity }
      responses:
        "201":
          description: Granted. The one time the secret is legible.
          content:
            application/json:
              schema:
                type: object
                properties:
                  grant: { $ref: "#/components/schemas/PasswordlessSession" }
                  secret: { type: string }
                  secretSeenOnce: { type: boolean, enum: [true] }
                  client:
                    type: object
                    description: What a client needs beside the secret to sign a grant
                    properties:
                      sessionId: { type: string, format: uuid }
                      role: { type: string }
                      entId: { type: string, format: uuid, description: The role's id }
                      windowSeconds: { type: integer, example: 30 }
                  tls: { $ref: "#/components/schemas/PasswordlessTlsMaterial" }
                  kerberos: { $ref: "#/components/schemas/PasswordlessKerberosMaterial" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { description: "Unknown database, or unknown role" }
        "502":
          description: The Kerberos authority could not issue the identity; nothing was kept
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: SECRETS_KEY is not configured, or tls/kerberos without the Kerberos authority
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /provision/databases/{id}/roles/{roleId}/grants:
    parameters:
      - { $ref: "#/components/parameters/PathId" }
      - { $ref: "#/components/parameters/RoleId" }
    get:
      tags: [Database roles]
      summary: Who holds a role
      description: |
        The passwordless sessions granted it, revoked ones included. Paginated with `limit` and `offset`.

        **Capability:** `access:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "roles", "needs": "access:read", "area": "resource", "resource": "database"}
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: The grants
          headers:
            Link: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  grants:
                    type: array
                    items: { $ref: "#/components/schemas/PasswordlessSession" }
                  page: { $ref: "#/components/schemas/PageWindow" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { description: "Unknown database, or unknown role" }

  /provision/databases/{id}/roles/{roleId}/keys:
    parameters:
      - { $ref: "#/components/parameters/PathId" }
      - { $ref: "#/components/parameters/RoleId" }
    get:
      tags: [Database roles]
      summary: The api keys bound to a role
      description: |
        **Capability:** `access:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "roles", "needs": "access:read", "area": "resource", "resource": "database"}
      responses:
        "200":
          description: The keys, each naming the role
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/ApiKey"
                        - type: object
                          properties:
                            role:
                              type: object
                              properties:
                                id: { type: string, format: uuid }
                                name: { type: string }
                            poolId: { type: integer, nullable: true }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { description: "Unknown database, or unknown role" }

  /provision/databases/{id}/pools:
    get:
      tags: [Pools]
      summary: A database's connection pools, and how busy each is
      description: |
        Every pool, open or closed, oldest first. `active` is the requests in
        flight on an open pool now — counted in Redis, so true across every
        instance of the API; `shared: false` means Redis is not configured and
        the count is this instance's alone. Pools are counted per owner (the
        account, or the team for a team-owned database), so `allowance` is the
        owner-wide picture, not this database's.

        **Capability:** `resource:read` (area `resource`, on the database).
      x-prorm-capability: {"router": "pools", "needs": "resource:read", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The pools and the owner's allowance
          content:
            application/json:
              schema:
                type: object
                properties:
                  pools: { type: array, items: { $ref: "#/components/schemas/Pool" } }
                  allowance: { $ref: "#/components/schemas/PoolAllowance" }
                  counted: { type: string, description: Whether the live counts are shared across instances }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Pools]
      summary: Open another pool
      description: |
        Takes one from the owner's allowance. When it is spent the answer is
        429 with `next`: an account buys another with `POST /billing/pools`
        (or a higher plan); a team raises its plan with
        `PUT /teams/{teamId}/billing/plan`.

        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "pools", "needs": "resource:operate", "area": "resource", "resource": "database"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, minLength: 1, maxLength: 80, description: "`pool-<n>` by default" }
                maxConnections: { type: integer, minimum: 1, maximum: 200, default: 10 }
      responses:
        "201":
          description: Opened
          content:
            application/json:
              schema:
                type: object
                properties:
                  pool: { $ref: "#/components/schemas/Pool" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429":
          description: The owner's pools are all in use
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  next: { type: string, example: POST /billing/pools buys another pool }

  /provision/databases/{id}/pools/{poolId}:
    parameters:
      - { $ref: "#/components/parameters/PathId" }
      - { $ref: "#/components/parameters/PoolId" }
    patch:
      tags: [Pools]
      summary: Rename a pool or change its size
      description: |
        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "pools", "needs": "resource:operate", "area": "resource", "resource": "database"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                label: { type: string, minLength: 1, maxLength: 80 }
                maxConnections: { type: integer, minimum: 1, maximum: 200 }
      responses:
        "200":
          description: Changed
          content:
            application/json:
              schema:
                type: object
                properties:
                  pool: { $ref: "#/components/schemas/Pool" }
        "400":
          description: The body did not pass its rules, a pool id that is not a uuid, or nothing to change
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/ValidationErrors" }
                  - { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404":
          description: "`Unknown database` or `Unknown pool`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Pools]
      summary: Close a pool
      description: |
        Clients on it fall back to the database's default on their next
        request — a pool is a routing detail, not a credential, so nothing
        they hold stops working. Closing one already closed answers it as it
        is. The last open pool cannot be closed: a database with no pool
        cannot be reached.

        **Capability:** `resource:operate` (area `resource`, on the database).
      x-prorm-capability: {"router": "pools", "needs": "resource:operate", "area": "resource", "resource": "database"}
      responses:
        "200":
          description: Closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  pool: { $ref: "#/components/schemas/Pool" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404":
          description: "`Unknown database` or `Unknown pool`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: It is the database's only open pool
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /teams/{teamId}/transfer:
    post:
      tags: [Teams]
      summary: Hand the team to somebody else
      description: |
        Ownership is transferred, never granted — this is the route that does
        it. The old owner becomes an admin rather than being dropped. Owner
        only, and asks for the password.

        **Capability:** `team:read` (area `team`). And the team owner, with their password.
      x-prorm-capability: {"router": "teams", "needs": "team:read", "area": "team", "note": "and the team owner, with their password"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId, current-password]
              properties:
                userId: { type: integer }
                current-password: { type: string, format: password }
      responses:
        "200": { description: Handed over }
        "403": { description: Only the owner can hand a team over }
        "409": { description: They are not an active member }

  /teams/{teamId}/leave:
    post:
      tags: [Teams]
      summary: Leave a team
      description: |
        **Capability:** `team:read` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:read", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      responses:
        "204": { description: Left }
        "409": { description: The owner cannot leave; hand it over or close it }

  /teams/{teamId}/devices:
    get:
      tags: [Devices]
      summary: The team's members' devices
      description: |
        Every device of the team's active and suspended members, and any
        enrolled for the team. The caller's live role must carry
        `device:manage-members` (owner, admin, security); a non-member gets
        the same 404 as a team that does not exist. `teamId` is the team's id,
        uuid or slug.

        **Capability:** `device:manage-members` (area `team`).
      x-prorm-capability: {"router": "teamDevices", "needs": "device:manage-members", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamRef" }]
      responses:
        "200":
          description: The devices
          content:
            application/json:
              schema:
                type: object
                properties:
                  team: { $ref: "#/components/schemas/DeviceTeamRef" }
                  devices:
                    type: array
                    items: { $ref: "#/components/schemas/DeviceListed" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { $ref: "#/components/responses/UnknownTeam" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /teams/{teamId}/devices/{deviceId}:
    delete:
      tags: [Devices]
      summary: Revoke a member's device
      description: |
        As `DELETE /security/devices/{id}` with `team`: a `reason` is
        required, the action is audited on the member's trail with the caller
        as actor, and the member is emailed. Needs `device:manage-members`;
        the team owner's devices are theirs alone.

        **Capability:** `device:manage-members` (area `team`). A reason; the member is told.
      x-prorm-capability: {"router": "teamDevices", "needs": "device:manage-members", "area": "team", "note": "a reason; the member is told"}
      parameters:
        - $ref: "#/components/parameters/TeamRef"
        - name: deviceId
          in: path
          required: true
          schema: { type: string, pattern: '^dev_[A-Za-z0-9]{1,40}$' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, minLength: 1, maxLength: 500 }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  device: { $ref: "#/components/schemas/Device" }
                  sessionsEnded: { type: integer }
                  credentialsRevoked: { type: integer }
        "400": { $ref: "#/components/responses/ReasonRequired" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/DeviceCapabilityMissing" }
        "404": { $ref: "#/components/responses/DeviceNotFound" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /teams/{teamId}/sessions:
    get:
      tags: [Sessions]
      summary: The team's members' live sessions
      description: |
        The live sessions of the team's active and suspended members — not
        staff, assumed, handoff or service sessions. The caller's live role
        must carry `session:manage-members`; a non-member gets the same 404 as
        a team that does not exist.

        **Capability:** `session:manage-members` (area `team`).
      x-prorm-capability: {"router": "teamDevices", "needs": "session:manage-members", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamRef" }]
      responses:
        "200":
          description: The sessions
          content:
            application/json:
              schema:
                type: object
                properties:
                  team: { $ref: "#/components/schemas/DeviceTeamRef" }
                  sessions:
                    type: array
                    items: { $ref: "#/components/schemas/ManagedSession" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/CapabilityMissing" }
        "404": { $ref: "#/components/responses/UnknownTeam" }

  /teams/{teamId}/sessions/{sid}:
    delete:
      tags: [Sessions]
      summary: Sign a member's session out
      description: |
        Needs `session:manage-members` and a `reason`; not under an assumed
        identity. The member is audited and emailed (unless it is the caller's
        own session). The team owner's sessions are theirs alone. Signing out
        a session already revoked answers 200 with `revoked: false`.

        **Capability:** `session:manage-members` (area `team`). A reason; the member is told.
      x-prorm-capability: {"router": "teamDevices", "needs": "session:manage-members", "area": "team", "note": "a reason; the member is told"}
      parameters:
        - $ref: "#/components/parameters/TeamRef"
        - $ref: "#/components/parameters/SessionRef"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, minLength: 1, maxLength: 500 }
      responses:
        "200":
          description: Signed out
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionRevoked" }
        "400": { $ref: "#/components/responses/ReasonRequired" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: "`capability_missing` (the role, or the team owner's session), `assumed_identity`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "404":
          description: "`Unknown team`, or `not_found` — no such session among the team's members"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /teams/{teamId}/security/access-rules:
    parameters: [{ $ref: "#/components/parameters/TeamId" }]
    get:
      tags: [Access rules and behaviour]
      summary: The team's access rules
      description: |
        Any active member may read them: they are the rules applied to them.
        A non-member (or an unknown team) is a 404. The controller also
        checks the session holds `access-rule:read`, in every mode.

        **Capability:** `team:read` (area `team`).
      x-prorm-capability: {"router": "teamSecurity", "needs": "team:read", "area": "team"}
      responses:
        "200":
          description: The rules
          content:
            application/json:
              schema:
                type: object
                properties:
                  rules: { type: array, items: { $ref: "#/components/schemas/AccessRule" } }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeam" }
    post:
      tags: [Access rules and behaviour]
      summary: Add a team access rule
      description: |
        As `POST /security/access-rules` with the scope fixed to this team:
        mandatory by default, so the team's allowlists intersect with the
        members' own rather than being widened by them. They apply to a
        member's requests in the team's context — a session opened for the
        team, `x-prorm-team`, `/teams/{teamId}/…`, and the team's resources.
        The team's owner is exempt from the team's rules on these routes, so a
        bad team rule can always be taken back.

        The caller's role must carry `access-rule:team`; the controller also
        checks the session's own ceiling in every mode. The lock-out guard
        and `force` work as for your own rules. A verified account.

        **Capability:** `access-rule:team` (area `team`). Mandatory by default; the lock-out guard.
      x-prorm-capability: {"router": "teamSecurity", "needs": "access-rule:team", "area": "team", "note": "mandatory by default; the lock-out guard"}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccessRuleRequest" }
      responses:
        "201":
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  rule: { $ref: "#/components/schemas/AccessRule" }
                  forced: { type: boolean }
        "400":
          description: "`invalid_rule` — the rule does not validate, or the scope is full"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeam" }
        "409": { $ref: "#/components/responses/WouldLockYouOut" }

  /teams/{teamId}/security/access-rules/{ruleId}:
    parameters:
      - { $ref: "#/components/parameters/TeamId" }
      - name: ruleId
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [Access rules and behaviour]
      summary: Remove a team access rule
      description: |
        Only a rule of this team (any other is a 404). The role must carry
        `access-rule:team`; the lock-out guard and `force` (body or query)
        apply. A verified account.

        **Capability:** `access-rule:team` (area `team`). The lock-out guard.
      x-prorm-capability: {"router": "teamSecurity", "needs": "access-rule:team", "area": "team", "note": "the lock-out guard"}
      parameters:
        - name: force
          in: query
          required: false
          schema: { type: boolean }
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  removed: { type: boolean, enum: [true] }
                  rule: { $ref: "#/components/schemas/AccessRule" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeam" }
        "409": { $ref: "#/components/responses/WouldLockYouOut" }

  /teams/{teamId}/security/behaviour:
    parameters: [{ $ref: "#/components/parameters/TeamId" }]
    get:
      tags: [Access rules and behaviour]
      summary: The team's behaviour profiles
      description: |
        Any active member. The controller also checks the session holds
        `behaviour:read`, in every mode.

        **Capability:** `team:read` (area `team`).
      x-prorm-capability: {"router": "teamSecurity", "needs": "team:read", "area": "team"}
      responses:
        "200":
          description: The profiles
          content:
            application/json:
              schema:
                type: object
                properties:
                  profiles: { type: array, items: { $ref: "#/components/schemas/BehaviourProfile" } }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeam" }
    post:
      tags: [Access rules and behaviour]
      summary: Add or change a team behaviour profile
      description: |
        As `POST /security/behaviour` with the scope fixed to this team. The
        role must carry `behaviour:team`, and so must the session (checked in
        the controller in every mode). Changing an enforcing profile needs a
        step-up. A verified account.

        **Capability:** `behaviour:team` (area `team`).
      x-prorm-capability: {"router": "teamSecurity", "needs": "behaviour:team", "area": "team"}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BehaviourProfileRequest" }
      responses:
        "200":
          description: Changed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BehaviourProfileSaved" }
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BehaviourProfileSaved" }
        "400":
          description: "`invalid_profile`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401":
          description: Not authenticated; or `step_up_required` / `step_up_failed` when changing an enforcing profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403": { $ref: "#/components/responses/TeamRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeam" }

  /teams/{teamId}/security/behaviour/{profileId}:
    parameters:
      - { $ref: "#/components/parameters/TeamId" }
      - name: profileId
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [Access rules and behaviour]
      summary: Remove a team behaviour profile
      description: |
        Only a profile of this team. `behaviour:team`; an enforcing profile
        needs a step-up. A verified account.

        **Capability:** `behaviour:team` (area `team`).
      x-prorm-capability: {"router": "teamSecurity", "needs": "behaviour:team", "area": "team"}
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StepUpProof" }
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Removed" }
        "401":
          description: Not authenticated; or `step_up_required` / `step_up_failed`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403": { $ref: "#/components/responses/TeamRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeam" }

  /teams/{teamId}/security/findings:
    parameters: [{ $ref: "#/components/parameters/TeamId" }]
    get:
      tags: [Access rules and behaviour]
      summary: What the team's profiles caught
      description: |
        Findings of the team's behaviour profiles, newest first, at most 500;
        only the unacknowledged ones unless `all=1`. The role and the session
        must both carry `behaviour:team`.

        **Capability:** `behaviour:team` (area `team`).
      x-prorm-capability: {"router": "teamSecurity", "needs": "behaviour:team", "area": "team"}
      parameters:
        - name: all
          in: query
          required: false
          schema: { type: string, enum: ["1", "true"] }
      responses:
        "200":
          description: The findings
          content:
            application/json:
              schema:
                type: object
                properties:
                  findings: { type: array, items: { $ref: "#/components/schemas/BehaviourFinding" } }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeam" }

  /teams/{teamId}/security/findings/{findingId}/ack:
    parameters:
      - { $ref: "#/components/parameters/TeamId" }
      - name: findingId
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Access rules and behaviour]
      summary: Acknowledge a team finding
      description: |
        A finding of one of this team's profiles. The role and the session
        must both carry `behaviour:team`. A verified account.

        **Capability:** `behaviour:team` (area `team`).
      x-prorm-capability: {"router": "teamSecurity", "needs": "behaviour:team", "area": "team"}
      responses:
        "200":
          description: Acknowledged
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BehaviourFindingAck" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamRoleRefusal" }
        "404":
          description: "`Unknown team`, or `not_found` — no such finding on this team"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /teams/{teamId}/security/lockdown/{memberId}:
    parameters:
      - { $ref: "#/components/parameters/TeamId" }
      - name: memberId
        in: path
        required: true
        schema: { type: integer, minimum: 1 }
        description: The membership's id (not the user's).
    post:
      tags: [Recovery and lockdown]
      summary: Lock a member down
      description: |
        Suspends the membership — the team's resources then refuse them — and
        signs out their sessions in the team's context. Needs a `reason`
        (the member is told it), is audited at `alert` with the actor, and
        the member is emailed. The owner cannot be locked down, nor can you
        lock yourself down here (use `POST /security/lockdown`); only the
        owner may lock down an admin.

        The role and the session must both carry `session:manage-members`
        (the session's checked in the route, in every mode). A verified
        account.

        **Capability:** `session:manage-members` (area `team`). A reason; alert audit; the member is told.
      x-prorm-capability: {"router": "teamSecurity", "needs": "session:manage-members", "area": "team", "note": "a reason; alert audit; the member is told"}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemberLockdownRequest" }
      responses:
        "200":
          description: Locked down
          content:
            application/json:
              schema:
                type: object
                properties:
                  member: { $ref: "#/components/schemas/TeamMember" }
                  revoked: { type: integer, description: Sessions signed out }
        "400":
          description: "`bad_request` — no reason"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamRoleRefusal" }
        "404":
          description: "`Unknown team`, or `not_found` — no such member of this team"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "409":
          description: "`invalid_state` — the member is the owner, or you"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
    delete:
      tags: [Recovery and lockdown]
      summary: Release a member's lockdown
      description: |
        Makes a suspended membership active again. Needs a `reason`; audited
        at `alert`, and the member is emailed. The same checks as the lockdown.
        A verified account.

        **Capability:** `session:manage-members` (area `team`). A reason; alert audit; the member is told.
      x-prorm-capability: {"router": "teamSecurity", "needs": "session:manage-members", "area": "team", "note": "a reason; alert audit; the member is told"}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemberLockdownRequest" }
      responses:
        "200":
          description: Released
          content:
            application/json:
              schema:
                type: object
                properties:
                  member: { $ref: "#/components/schemas/TeamMember" }
        "400":
          description: "`bad_request` — no reason"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamRoleRefusal" }
        "404":
          description: "`Unknown team`, or `not_found` — no such member of this team"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "409":
          description: "`invalid_state` — the member is not locked down, or is the owner, or you"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /teams/{teamId}/billing:
    get:
      tags: [Team billing]
      summary: The team's billing account
      description: |
        A team the caller is not an active member of answers 404 `Unknown
        team`, exactly as one that does not exist does — on every team billing
        route, before anything else is read. The role is checked next (403
        `capability_missing`, `unlock: team-role`, with `yourCapabilities`).
        `plan` is `teams.plan`, which the webhook writes and the team's quota
        reads; nothing here sets it directly.

        **Capability:** `billing:read` (area `team`).
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:read", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      responses:
        "200":
          description: The plan, the subscription, the contact, pools and this month's estimate
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TeamBilling" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeamBilling" }

  /teams/{teamId}/billing/invoices:
    get:
      tags: [Team billing]
      summary: What the team has been charged
      description: |
        Newest first, mirrored from Stripe by the webhook.

        **Capability:** `billing:read` (area `team`).
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:read", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      responses:
        "200":
          description: The invoices
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoices: { type: array, items: { $ref: "#/components/schemas/Invoice" } }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeamBilling" }

  /teams/{teamId}/billing/upcoming:
    get:
      tags: [Team billing]
      summary: What this month's invoice will say
      description: |
        The team's billable resources, priced the way the invoice will price them, before Stripe raises it.

        **Capability:** `billing:read` (area `team`).
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:read", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      responses:
        "200":
          description: The lines that would be charged
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UpcomingCharges" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeamBilling" }

  /teams/{teamId}/billing/payment-methods:
    parameters: [{ $ref: "#/components/parameters/TeamId" }]
    get:
      tags: [Team billing]
      summary: The team's cards
      description: |
        Empty when the team has no Stripe customer yet, or billing is not configured.

        **Capability:** `billing:read` (area `team`).
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:read", "area": "team"}
      responses:
        "200":
          description: The cards, the default marked
          content:
            application/json:
              schema:
                type: object
                properties:
                  paymentMethods: { type: array, items: { $ref: "#/components/schemas/PaymentMethod" } }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeamBilling" }
        "502": { $ref: "#/components/responses/StripeRefused" }
    post:
      tags: [Team billing]
      summary: Add a card, through a Stripe-hosted setup session
      description: |
        The card never passes through this API; the webhook makes it the
        team's default. Makes the team's Stripe customer on first need. Needs
        a verified account; a child's account cannot.

        **Capability:** `billing:manage` (area `team`). A Stripe-hosted setup session.
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:manage", "area": "team", "note": "a Stripe-hosted setup session"}
      responses:
        "200":
          description: Where to send the browser
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
                  id: { type: string }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeamBilling" }
        "502": { $ref: "#/components/responses/StripeRefused" }
        "503": { $ref: "#/components/responses/BillingNotConfigured" }

  /teams/{teamId}/billing/payment-methods/{pmId}/default:
    put:
      tags: [Team billing]
      summary: Make a card the team's default
      description: |
        Another customer's card is as unknown here as one that does not exist.

        **Capability:** `billing:manage` (area `team`).
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:manage", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { $ref: "#/components/parameters/PaymentMethodId" }
      responses:
        "200":
          description: The card, now the default
          content:
            application/json:
              schema:
                type: object
                properties:
                  paymentMethod: { $ref: "#/components/schemas/PaymentMethod" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404":
          description: "`Unknown team`, or `Unknown payment method`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502": { $ref: "#/components/responses/StripeRefused" }
        "503": { $ref: "#/components/responses/BillingNotConfigured" }

  /teams/{teamId}/billing/payment-methods/{pmId}:
    delete:
      tags: [Team billing]
      summary: Remove a card
      description: |
        **Capability:** `billing:manage` (area `team`).
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:manage", "area": "team"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { $ref: "#/components/parameters/PaymentMethodId" }
      responses:
        "204": { description: Detached from the team's customer }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404":
          description: "`Unknown team`, or `Unknown payment method`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502": { $ref: "#/components/responses/StripeRefused" }
        "503": { $ref: "#/components/responses/BillingNotConfigured" }

  /teams/{teamId}/billing/checkout:
    post:
      tags: [Team billing]
      summary: Start the team paying
      description: |
        The same checkout an account's is — the zero-cost usage price, and
        membership if wanted — on the team's own Stripe customer, made here on
        first need. The webhook moves `teams.plan` when Stripe confirms. A
        team already paying changes its plan instead (409). Needs a verified
        account; a child's account cannot.

        **Capability:** `billing:manage` (area `team`). Makes the team’s Stripe customer on first need.
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:manage", "area": "team", "note": "makes the team’s Stripe customer on first need"}
      parameters:
        - { $ref: "#/components/parameters/TeamId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                membership: { type: boolean, default: false }
      responses:
        "200":
          description: Where to send the browser
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
                  id: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeamBilling" }
        "409":
          description: The team is already paying
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  next: { type: string, example: PUT /teams/7/billing/plan }
        "502": { $ref: "#/components/responses/StripeRefused" }
        "503": { $ref: "#/components/responses/BillingNotConfigured" }

  /teams/{teamId}/billing/portal:
    post:
      tags: [Team billing]
      summary: Open the Stripe portal on the team's customer
      description: |
        Invoices, cards and cancellation. Needs a verified account; a child's account cannot.

        **Capability:** `billing:manage` (area `team`).
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:manage", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      responses:
        "200":
          description: Where to send the browser
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeamBilling" }
        "502": { $ref: "#/components/responses/StripeRefused" }
        "503": { $ref: "#/components/responses/BillingNotConfigured" }

  /teams/{teamId}/billing/plan:
    put:
      tags: [Team billing]
      summary: Change the team's plan
      description: |
        On the team's existing subscription: `free` cancels at the end of the
        period (the team keeps what it paid for), `usage` drops the membership
        item, `membership` adds it. Answers 202 — Stripe makes the change and
        the webhook writes `teams.plan`. A team not paying yet starts with
        checkout (409), except `free`, which it already is (200, `pending:
        null`). Needs a verified account; a child's account cannot.

        **Capability:** `billing:manage` (area `team`). 202; the webhook moves teams.plan.
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:manage", "area": "team", "note": "202; the webhook moves teams.plan"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string, enum: [free, usage, membership] }
      responses:
        "200":
          description: "Not paying and asked for `free`: nothing to change"
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan: { $ref: "#/components/schemas/Plan" }
                  pending: { nullable: true, description: Always null here }
        "202":
          description: Asked of Stripe; the plan moves when it confirms
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan: { $ref: "#/components/schemas/Plan" }
                  pending: { type: string, enum: [free, usage, membership] }
                  at: { type: string, format: date-time, nullable: true, description: "For `free`: the end of the period" }
                  message: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404": { $ref: "#/components/responses/UnknownTeamBilling" }
        "409":
          description: The team has no subscription yet; start one with checkout
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  next: { type: string, example: POST /teams/7/billing/checkout }
        "502": { $ref: "#/components/responses/StripeRefused" }
        "503": { $ref: "#/components/responses/BillingNotConfigured" }

  /teams/{teamId}/billing/contact:
    put:
      tags: [Team billing]
      summary: Who receives the team's bills
      description: |
        An active member whose role may read billing (`billing:read`), or
        `null` for the owner. Kept on the team's Stripe customer too, so
        Stripe's own receipts go to the same person. Needs a verified account.

        **Capability:** `billing:manage` (area `team`). An active member with billing:read, or null for the owner.
      x-prorm-capability: {"router": "teamBilling", "needs": "billing:manage", "area": "team", "note": "an active member with billing:read, or null for the owner"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId]
              properties:
                userId: { type: integer, minimum: 1, nullable: true, description: "A member's account id, or null for the owner" }
      responses:
        "200":
          description: The contact now
          content:
            application/json:
              schema:
                type: object
                properties:
                  contact: { $ref: "#/components/schemas/BillingContact" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/TeamBillingRoleRefusal" }
        "404":
          description: "`Unknown team`, or `Unknown member` — not an active member"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: That member's role cannot read the team's billing
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  needs: { type: string, example: billing:read }

  /teams/{teamId}/kerberos:
    parameters: [{ $ref: "#/components/parameters/TeamId" }]
    post:
      tags: [Kerberos]
      summary: Give the team its own Kerberos realm
      description: |
        At kerberos.prod-orm.io/<team-uuid>: its own KDC master key, service
        principal and certificate authority. Passwordless sessions the team
        issues with `--auth kerberos` (or `--auth tls`) are then principals in
        the team's realm, revocable as a set by dropping it. A role with
        `kerberos:manage` (owner, admin, security); the member's role is
        checked first (404 for a non-member). Needs a verified account.

        **Capability:** `kerberos:manage` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "kerberos:manage", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses:
        "201":
          description: Created
          headers:
            Idempotency-Key: { $ref: "#/components/headers/IdempotencyKey" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  kerberos: { $ref: "#/components/schemas/TeamKerberosRealm" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502":
          description: The Kerberos authority refused or did not answer (its 4xx is passed through)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: The Kerberos authority is not configured (KERBEROS_AUTHORITY_TOKEN)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      tags: [Kerberos]
      summary: The team's realm
      description: |
        Its KDC, service and CA. Any member (`team:read`).

        **Capability:** `team:read` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "team:read", "area": "team"}
      responses:
        "200":
          description: The realm
          content:
            application/json:
              schema:
                type: object
                properties:
                  kerberos: { $ref: "#/components/schemas/TeamKerberosRealm" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: "`Unknown team`, or `This team has no Kerberos realm`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: The Kerberos authority did not answer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: The Kerberos authority is not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Kerberos]
      summary: Remove the team's realm
      description: |
        Every session in it stops verifying at once. `kerberos:manage`. Needs a verified account.

        **Capability:** `kerberos:manage` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "kerberos:manage", "area": "team"}
      responses:
        "204": { description: Removed (one already gone is not an error) }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502":
          description: The Kerberos authority refused or did not answer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: The Kerberos authority is not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /teams/{teamId}/transfer-billing:
    put:
      tags: [Team billing]
      summary: How the team pays for transfers
      description: |
        `immediate` charges each transfer as it completes; `deferred` settles
        them all on the team's end-of-term bill. Applies to jobs created after
        the change. A billing role (`billing:manage`); the member's role is
        checked first (404 for a non-member). Needs a verified account.

        **Capability:** `billing:manage` (area `team`).
      x-prorm-capability: {"router": "teams", "needs": "billing:manage", "area": "team"}
      parameters: [{ $ref: "#/components/parameters/TeamId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode: { type: string, enum: [immediate, deferred] }
      responses:
        "200":
          description: Set
          content:
            application/json:
              schema:
                type: object
                properties:
                  teamId: { type: string, format: uuid, description: The team's uuid }
                  transferBilling: { type: string, enum: [immediate, deferred] }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/users:
    get:
      tags: [Users]
      summary: Every account
      description: |
        Paginated, and never carrying a password hash or an outstanding
        verification code. Filterable by `role`, `plan` and `verified`.
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0 } }
        - { name: role, in: query, schema: { type: string, enum: [admin, parent, basic] } }
        - { name: plan, in: query, schema: { type: string } }
        - { name: verified, in: query, schema: { type: boolean } }
      responses:
        "200":
          description: The accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  users: { type: array, items: { $ref: "#/components/schemas/User" } }
                  page: { $ref: "#/components/schemas/Page" }
        "403": { $ref: "#/components/responses/AdminOnly" }

  /admin/users/{id}:
    get:
      tags: [Users]
      summary: One account, with everything hanging off it
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The account, its plan, subscription, usage, resources and teams
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/users/{id}/role:
    put:
      tags: [Users]
      summary: Grant or revoke admin
      description: |
        Emails the account, and writes an audit row. Refuses to remove the last
        admin — an account nobody can administer is not a state worth being one
        request away from.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties: { role: { type: string, enum: [admin, parent, basic] } }
      responses:
        "200":
          description: Changed, or already that role
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
                  changed: { type: boolean }
        "409": { description: That is the last admin account }

  /admin/users/{id}/verify:
    post:
      tags: [Users]
      summary: Verify an account by hand
      description: For an address that is broken but real. Retires any outstanding code.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200": { description: Verified }
        "409": { description: Already verified }

  /admin/resources:
    get:
      tags: [Platform]
      summary: Every database and store, across all accounts
      parameters:
        - { name: limit, in: query, schema: { type: integer } }
        - { name: offset, in: query, schema: { type: integer } }
        - { name: kind, in: query, schema: { type: string, enum: [database, store] } }
        - { name: status, in: query, schema: { type: string } }
      responses:
        "200": { description: "The resources, each with its owner and usage" }

  /admin/operations:
    get:
      tags: [Operations]
      summary: Every operation, across all accounts
      description: Oldest first, so a stuck queue is on the first page.
      parameters:
        - { name: state, in: query, schema: { type: string, enum: [queued, running, succeeded, failed, cancelled] } }
        - { name: userId, in: query, schema: { type: integer } }
      responses:
        "200": { description: "The operations, with owner and how long a running one has been going" }
        "400": { description: "An unknown state, named against the valid ones" }

  /admin/stats:
    get:
      tags: [Platform]
      summary: The counts worth putting on a dashboard
      responses:
        "200":
          description: Accounts, resources by status, operations by state, subscriptions by plan

  /admin/me:
    get:
      tags: [Staff and permissions]
      summary: What kind of admin the caller is, and which scopes they hold
      description: The one admin route any admin may reach; it is how a UI knows which pages to offer.
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  admin: { $ref: "#/components/schemas/AdminPowers" }
          description: The resolved powers
        "403": { $ref: "#/components/responses/AdminOnly" }

  /admin/perms:
    get:
      tags: [Staff and permissions]
      summary: The admin presets
      description: Each kind of admin, what it may do, and whether it may grant admin. Needs `grant-admin`.
      responses:
        "200": { description: The presets }

  /admin/admins:
    get:
      tags: [Staff and permissions]
      summary: Every admin, and the scopes each resolves to
      description: Needs `grant-admin`.
      parameters:
        - { name: limit, in: query, schema: { type: integer } }
        - { name: offset, in: query, schema: { type: integer } }
      responses:
        "200":
          description: The admins
          content:
            application/json:
              schema:
                type: object
                properties:
                  admins: { type: array, items: { $ref: "#/components/schemas/Admin" } }
                  page: { $ref: "#/components/schemas/Page" }
    post:
      tags: [Staff and permissions]
      summary: Make an account an admin of a kind
      description: |
        Needs `grant-admin`. A preset `type` resolves to its constants; `other`
        takes `scopes` and keeps them as a document in object storage, and is
        refused while that store is not live. Granting an account that already
        has a row rewrites it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId, type]
              properties:
                userId: { type: integer }
                type: { type: string, enum: [general, billing, logs, support, email, dev, other] }
                scopes: { type: array, items: { type: string }, description: For `other` only }
      responses:
        "201":
          description: |
            The admin — and their personal inbox address, `<f><lastname>@` at
            the sending domain, made (or switched back on) with the grant and
            routed to them. Its `routing` says whether the Cloudflare rule that
            delivers its mail was made; if not, `routing.error` says why, and
            `POST /admin/email/addresses/{id}/route` retries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  admin: { $ref: "#/components/schemas/Admin" }
                  personalAddress: { $ref: "#/components/schemas/InboxAddress" }
        "400": { description: A custom set with no object store to keep it in }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/admins/{userId}:
    parameters:
      - { name: userId, in: path, required: true, schema: { type: integer } }
    patch:
      tags: [Staff and permissions]
      summary: Change an admin's kind, or a custom admin's scopes
      description: Needs `grant-admin`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                type: { type: string, enum: [general, billing, logs, support, email, dev, other] }
                scopes: { type: array, items: { type: string } }
      responses:
        "200": { description: "The admin, as changed" }
        "404": { description: That account is not an admin }
    delete:
      tags: [Staff and permissions]
      summary: Revoke admin
      description: |
        Needs `grant-admin`. The account goes back to basic. The last general
        admin cannot be revoked — an installation with no general admin has no
        route back except the database.
      responses:
        "204": { description: Revoked }
        "404": { description: That account is not an admin }
        "409": { description: That is the last general admin }

  /admin/users/{id}/lock:
    put:
      tags: [Users]
      summary: Lock or unlock sign-in
      description: Needs `support` (`grant-admin` for an admin account). Locking ends every live session; unlocking restores sign-in only.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [locked]
              properties:
                locked: { type: boolean }
                reason: { type: string, maxLength: 200 }
      responses:
        "200": { description: "`{ user, changed, sessionsEnded }`" }

  /admin/users/{id}/provisioning:
    put:
      tags: [Users]
      summary: Allow or hold provisioning
      description: Needs `resources`. The account keeps what it has and cannot create more.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [allowed], properties: { allowed: { type: boolean } } }
      responses:
        "200": { description: "`{ user, changed }`" }

  /admin/users/{id}/signout:
    post:
      tags: [Sessions]
      summary: Sign an account out everywhere
      description: Needs `support`. Every session, including any staff holds as them.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200": { description: "`{ sessionsEnded }`" }

  /admin/users/{id}/verify/resend:
    post:
      tags: [Users]
      summary: Send the verification mail again
      description: Needs `support`.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "202": { description: Sent }
        "409": { description: Already verified }

  /admin/users/{id}/plan:
    put:
      tags: [Users]
      summary: Set the plan by hand
      description: Needs `billing`. Writes the column a Stripe subscription writes; does not touch Stripe. A reason is required.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan, reason]
              properties:
                plan: { type: string }
                reason: { type: string, minLength: 3, maxLength: 200 }
      responses:
        "200": { description: "`{ user, plan, changed }`" }

  /admin/users/{id}/sessions:
    get:
      tags: [Sessions]
      summary: Everywhere the account is signed in
      description: Needs `users`. Staff sessions carry `assumed`.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200": { description: "`{ sessions, page }`" }

  /admin/users/{id}/sessions/{sid}:
    delete:
      tags: [Sessions]
      summary: Sign an account's session out
      description: |
        Needs the `support` admin scope, a fresh step-up and a reason of
        10–500 characters, in every enforcement mode. Audited at `alert`; the
        account holder is emailed. Staff sessions acting as the account are not
        reachable here. Already revoked answers 200 with `revoked: false`.

        **Capability:** `admin:support` (area `admin`), with a fresh step-up. A reason; alert audit; the user is told.
      x-prorm-capability: {"router": "adminDevices", "needs": "admin:support", "area": "admin", "stepUp": true, "note": "a reason; alert audit; the user is told"}
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/SessionRef"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StaffActionRequest" }
      responses:
        "200":
          description: Signed out
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionRevoked" }
        "400": { $ref: "#/components/responses/StaffActionBadRequest" }
        "401": { $ref: "#/components/responses/StaffActionUnauthorized" }
        "403": { $ref: "#/components/responses/StaffActionForbidden" }
        "404":
          description: "`not_found` — unknown account, or no such session of it"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "429": { $ref: "#/components/responses/StepUpTooManyAttempts" }

  /admin/users/{id}/audit:
    get:
      tags: [Audit log]
      summary: The account's audit trail
      description: Needs `users`. Newest first.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200": { description: "`{ audit, page }`" }

  /admin/users/{id}/devices:
    get:
      tags: [Devices]
      summary: An account's devices
      description: |
        Every device of the account, revoked ones included. Needs the `support` admin scope.

        **Capability:** `admin:support` (area `admin`).
      x-prorm-capability: {"router": "adminDevices", "needs": "admin:support", "area": "admin"}
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The devices
          content:
            application/json:
              schema:
                type: object
                properties:
                  devices:
                    type: array
                    items: { $ref: "#/components/schemas/DeviceListed" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404":
          description: "`not_found` — unknown account"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /admin/users/{id}/devices/{deviceId}:
    delete:
      tags: [Devices]
      summary: Revoke an account's device
      description: |
        As the owner's own revoke. Needs the `support` admin scope and, in
        every enforcement mode, a fresh step-up (the session's, or `stepUp` in
        the body) and a reason of 10–500 characters. Audited at `alert` on the
        account's trail with the admin as actor; the account holder is emailed.

        **Capability:** `admin:support` (area `admin`), with a fresh step-up. A reason; alert audit; the user is told.
      x-prorm-capability: {"router": "adminDevices", "needs": "admin:support", "area": "admin", "stepUp": true, "note": "a reason; alert audit; the user is told"}
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/DeviceIdAdmin"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StaffActionRequest" }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  device: { $ref: "#/components/schemas/Device" }
                  sessionsEnded: { type: integer }
                  credentialsRevoked: { type: integer }
        "400": { $ref: "#/components/responses/StaffActionBadRequest" }
        "401": { $ref: "#/components/responses/StaffActionUnauthorized" }
        "403": { $ref: "#/components/responses/StaffActionForbidden" }
        "404": { $ref: "#/components/responses/StaffActionNotFound" }
        "429": { $ref: "#/components/responses/StepUpTooManyAttempts" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /admin/users/{id}/devices/{deviceId}/wipe:
    post:
      tags: [Devices]
      summary: Wipe an account's device
      description: |
        As the owner's own wipe. Needs the `support` admin scope, a fresh
        step-up and a reason of 10–500 characters, in every enforcement mode.
        Audited at `alert`; the account holder is emailed.

        **Capability:** `admin:support` (area `admin`), with a fresh step-up. A reason; alert audit; the user is told.
      x-prorm-capability: {"router": "adminDevices", "needs": "admin:support", "area": "admin", "stepUp": true, "note": "a reason; alert audit; the user is told"}
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/DeviceIdAdmin"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StaffActionRequest" }
      responses:
        "200":
          description: The wipe is queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  device: { $ref: "#/components/schemas/Device" }
                  sessionsEnded: { type: integer }
                  command: { $ref: "#/components/schemas/DeviceCommand" }
        "400": { $ref: "#/components/responses/StaffActionBadRequest" }
        "401": { $ref: "#/components/responses/StaffActionUnauthorized" }
        "403": { $ref: "#/components/responses/StaffActionForbidden" }
        "404": { $ref: "#/components/responses/StaffActionNotFound" }
        "409": { $ref: "#/components/responses/DeviceInvalidState" }
        "429": { $ref: "#/components/responses/StepUpTooManyAttempts" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /admin/teams:
    get:
      tags: [Team administration]
      summary: Every team, with its owner and size
      description: Needs `users`. `?userId=` narrows to the teams that account is in.
      parameters:
        - { name: userId, in: query, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer } }
        - { name: offset, in: query, schema: { type: integer } }
      responses:
        "200": { description: "`{ teams, page }`" }

  /admin/teams/{teamId}:
    parameters:
      - { name: teamId, in: path, required: true, schema: { type: integer } }
    get:
      tags: [Team administration]
      summary: One team in full — members with their accounts, and projects
      description: Needs `users`.
      responses:
        "200": { description: "`{ team, members, projects }`" }

  /admin/teams/{teamId}/transfer:
    post:
      tags: [Team administration]
      summary: Hand the team to another member
      description: Needs `users`. What the owner does with their password, done for them; the heir must be an active member, the old owner becomes an admin. A reason is required.
      parameters:
        - { name: teamId, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId, reason]
              properties: { userId: { type: integer }, reason: { type: string } }
      responses:
        "200": { description: "`{ team, changed }`" }
        "409": { description: Not an active member }

  /admin/teams/{teamId}/members:
    post:
      tags: [Team administration]
      summary: Put an account in the team, active at once
      description: Needs `users`. No invitation; ownership is transferred, not granted.
      parameters:
        - { name: teamId, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId]
              properties: { userId: { type: integer }, role: { type: string, default: viewer } }
      responses:
        "201": { description: "`{ member }`" }
        "409": { description: Already a member }

  /admin/teams/{teamId}/members/{userId}:
    parameters:
      - { name: teamId, in: path, required: true, schema: { type: integer } }
      - { name: userId, in: path, required: true, schema: { type: integer } }
    put:
      tags: [Team administration]
      summary: Change a member's role
      description: Needs `users`. The owner's row is transferred, not rewritten.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [role], properties: { role: { type: string } } }
      responses:
        "200": { description: "`{ member }`" }
        "409": { description: That is the owner }
    delete:
      tags: [Team administration]
      summary: Remove a member
      description: Needs `users`. The owner cannot be removed; transfer first.
      responses:
        "204": { description: Removed }
        "409": { description: That is the owner }

  /admin/assume:
    post:
      tags: [Assumed identities]
      summary: Assume an identity — act as a customer account
      description: |
        Needs `assume`. Mints a bearer token that *is* the customer's: every
        route sees them. Read mode sees what they see and changes nothing.
        Write mode may also change the areas the caller's own admin scopes
        cover — `billing` for billing, `resources` for provisioning, `users`
        for the profile, teams and family — and never more: the identity itself
        (sessions, MFA, password) is never writable, and nothing under `/admin`
        answers. Assuming an admin account needs `grant-admin`, and still buys
        no admin powers.

        The token is a session of the customer's, marked with who really holds
        it and why; they see it under Security → Sessions and can end it, and
        they are emailed when it opens — the role, the mode, the reason and
        when it ends, never the admin's address. The caller's power to assume
        is re-checked on every request, so losing it ends every assumed token
        at once. Every write under an assumed identity is audited against the
        admin.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId, reason]
              properties:
                userId: { type: integer }
                mode: { type: string, enum: [read, write], default: read }
                minutes: { type: integer, minimum: 1, maximum: 480, default: 60 }
                reason: { type: string, minLength: 3, maxLength: 200 }
      responses:
        "201":
          description: The token, and what it may do
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  expiresAt: { type: string, format: date-time }
                  mode: { type: string, enum: [read, write] }
                  user: { $ref: "#/components/schemas/User" }
                  writable:
                    type: array
                    items: { type: string }
                    description: In write mode, the areas the caller's scopes let them change; empty in read mode
        "400": { description: "Assuming yourself, or no reason" }
        "403": { description: "No `assume` scope, or an admin account without `grant-admin`" }
        "404": { $ref: "#/components/responses/NotFound" }
    get:
      tags: [Assumed identities]
      summary: The identities the caller holds open
      responses:
        "200": { description: "Live assumed sessions of the caller's, each with the account, mode and reason" }

  /admin/assume/{id}:
    delete:
      tags: [Assumed identities]
      summary: End an assumed identity
      description: The token keeps verifying; the session behind it does not. Only the caller's own.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "204": { description: Ended }
        "404": { description: No such assumed session of the caller's }

  /admin/email/addresses:
    get:
      tags: [Support inbox]
      summary: The platform's addresses
      description: |
        Any admin: an `email` holder sees every address the platform answers
        mail at, anyone else the ones routed to them (`?mine=true` narrows the
        holder the same way). Each with
        its label, the admins it routes to, and how many messages sit at it in
        each state. Filterable by `active`.
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0 } }
        - { name: active, in: query, schema: { type: boolean } }
      responses:
        "200":
          description: The addresses
          content:
            application/json:
              schema:
                type: object
                properties:
                  addresses: { type: array, items: { $ref: "#/components/schemas/InboxAddress" } }
                  page: { $ref: "#/components/schemas/Page" }
        "403": { $ref: "#/components/responses/AdminOnly" }
    post:
      tags: [Support inbox]
      summary: Add a platform address
      description: |
        Needs `email`. The address has to be at the sending domain
        (`prod-orm.io`): replies go out *from* it, and only a domain onboarded
        onto Email Sending can be sent from. `routesTo` names the admins told
        by mail when something arrives — each must hold the `email` scope,
        because a nudge about a message you cannot open is no use. The
        Cloudflare routing rule that delivers mail for the address to the
        Worker is made by hand in the dashboard; this is what happens once it
        arrives. Audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address: { type: string, format: email, example: support@prod-orm.io }
                label: { type: string, maxLength: 120, example: Support }
                routesTo: { type: array, items: { type: integer }, description: users.id of admins holding `email` }
      responses:
        "201":
          description: The address
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { $ref: "#/components/schemas/InboxAddress" }
        "400": { description: "Not at the sending domain, or routed to somebody without `email`" }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "409": { description: Already a platform address }

  /admin/email/addresses/{id}:
    get:
      tags: [Support inbox]
      summary: One address, with its counts
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The address
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { $ref: "#/components/schemas/InboxAddress" }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Support inbox]
      summary: Change an address's label, routing, or whether it accepts mail
      description: |
        Not the address itself — mail is matched against it and a routing rule
        at Cloudflare names it. Add a new one and switch the old one off.
        `active: false` makes the Worker reject mail for it with a permanent
        error rather than file it. Audited.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, maxLength: 120 }
                routesTo: { type: array, items: { type: integer } }
                active: { type: boolean }
      responses:
        "200":
          description: The address, and whether anything changed
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { $ref: "#/components/schemas/InboxAddress" }
                  changed: { type: boolean }
        "400": { description: Routed to somebody without `email` }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Support inbox]
      summary: Remove an address that holds no mail
      description: |
        An address with messages at it is history and cannot be removed —
        switch it off instead, which keeps what it has and refuses more.
        Audited.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "204": { description: Removed }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: The address holds messages }

  /admin/email/addresses/{id}/route:
    post:
      tags: [Support inbox]
      summary: Make, adopt or repair the address's routing rule
      description: |
        The Cloudflare Email Routing rule that delivers this address's mail to
        the Worker: made if there is none, adopted if one was made by hand,
        switched to match `active`. For an address recorded before rules were
        managed here, or one whose rule failed when it was added. Idempotent.
        Needs `email`. Audited.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The address, with its rule — or, when Cloudflare refused or could not be reached, `routing.managed` false and `routing.error` saying what it said
          content:
            application/json:
              schema:
                type: object
                properties: { address: { $ref: "#/components/schemas/InboxAddress" } }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/email/addresses/personal:
    post:
      tags: [Support inbox]
      summary: Every admin's personal address, made where missing
      description: |
        Granting admin makes a personal `<f><lastname>@` address; this is for
        admins granted before that was so, and for repairing a rule that
        failed at grant time. One entry per admin, made or found. Idempotent.
        Needs `email`. Audited.
      responses:
        "200":
          description: Every admin's personal address
          content:
            application/json:
              schema:
                type: object
                properties:
                  addresses:
                    type: array
                    items:
                      type: object
                      properties:
                        userId: { type: integer }
                        created: { type: boolean, description: Made by this call rather than found }
                        address: { $ref: "#/components/schemas/InboxAddress" }
        "403": { $ref: "#/components/responses/AdminOnly" }

  /admin/email/ingest:
    post:
      tags: [Support inbox]
      summary: File a raw message, as if it had arrived
      description: |
        Needs `email`. The same function the Worker's `email` export calls on
        every message Email Routing delivers, reached over HTTP: for a test, a
        saved `.eml`, or replaying something refused while an address was
        switched off. `raw` is the whole message, RFC 822, up to 512 KiB in
        a request body of up to 1 MB (the Worker path has no such cap). The envelope is optional; without it the
        recipient is read off Delivered-To, To and Cc, first match wins.

        A message for no active address of ours is a 422 carrying the reason
        the Worker would have rejected it with. One already filed — the same
        Message-ID at the same address — is a 200 with `duplicate: true` and
        the existing row. Audited when something new is filed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [raw]
              properties:
                raw: { type: string, description: "The message, headers and body, as text" }
                envelopeFrom: { type: string, format: email, nullable: true, description: SMTP MAIL FROM }
                envelopeTo: { type: string, format: email, nullable: true, description: SMTP RCPT TO }
      responses:
        "201":
          description: Filed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { $ref: "#/components/schemas/InboxMessageSummary" }
                  address: { $ref: "#/components/schemas/InboxAddress" }
                  duplicate: { type: boolean }
        "200": { description: "Already filed — the same shape, with `duplicate` true" }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "422": { description: "No active platform address among the recipients, or not a readable message" }

  /admin/email/messages:
    get:
      tags: [Support inbox]
      summary: The inbox
      description: |
        Any admin. One holding `email` sees every address; any other sees the
        addresses routed to them — their personal one, and any shared one
        whose list they are on — whatever they ask for, and `?mine=true`
        keeps the `email` holder to their own as well. Newest first, without
        bodies or headers. Narrow by `address` (its id), `state`, `direction`,
        `assignedTo` or `thread`.
      parameters:
        - { name: mine, in: query, schema: { type: boolean }, description: Only the caller's own addresses }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0 } }
        - { name: address, in: query, schema: { type: integer }, description: An address id }
        - { name: state, in: query, schema: { type: string, enum: [unread, read, archived, spam] } }
        - { name: direction, in: query, schema: { type: string, enum: [in, out] } }
        - { name: assignedTo, in: query, schema: { type: integer }, description: A users.id }
        - { name: thread, in: query, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The messages
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages: { type: array, items: { $ref: "#/components/schemas/InboxMessageSummary" } }
                  page: { $ref: "#/components/schemas/Page" }
        "403": { $ref: "#/components/responses/AdminOnly" }

  /admin/email/messages/{id}:
    get:
      tags: [Support inbox]
      summary: One message, whole
      description: |
        Every header, both bodies, the attachments it named (name, type, size
        — the bytes are not kept), the notes on it, and its thread oldest
        first. Opening it changes nothing; marking it read is a separate
        request.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The message
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { $ref: "#/components/schemas/InboxMessage" }
                  address: { $ref: "#/components/schemas/InboxAddress", nullable: true }
                  notes: { type: array, items: { $ref: "#/components/schemas/InboxNote" } }
                  thread: { type: array, items: { $ref: "#/components/schemas/InboxMessageSummary" } }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Support inbox]
      summary: Mark a message
      description: Read, unread, archived or spam. Audited when it changes.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state]
              properties:
                state: { type: string, enum: [unread, read, archived, spam] }
      responses:
        "200":
          description: The message, and whether anything changed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { $ref: "#/components/schemas/InboxMessageSummary" }
                  changed: { type: boolean }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/email/messages/{id}/assignee:
    put:
      tags: [Support inbox]
      summary: Hand a message to an admin, or take it back
      description: |
        `userId` names an admin holding `email`; null releases it. A pointer
        for the team, not a permission — every inbox admin still sees
        everything. Audited when it changes.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                userId: { type: integer, nullable: true }
      responses:
        "200":
          description: The message, and whether anything changed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { $ref: "#/components/schemas/InboxMessageSummary" }
                  changed: { type: boolean }
        "400": { description: Not an admin holding `email` }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/email/messages/{id}/notes:
    post:
      tags: [Support inbox]
      summary: Leave an internal note
      description: For the other admins. Never sent; the sender never sees it. Audited.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, minLength: 1, maxLength: 4000 }
      responses:
        "201":
          description: The note
          content:
            application/json:
              schema:
                type: object
                properties:
                  note: { $ref: "#/components/schemas/InboxNote" }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/email/messages/{id}/reply:
    post:
      tags: [Support inbox]
      summary: Reply, from the address the message came to
      description: |
        Any admin who can see the message (see the inbox). Plain text in; it goes out through the same template
        path as every other email, in the platform's shell, from the platform
        address the message was sent to, to the sender's Reply-To or From.
        `In-Reply-To` and `References` name the message it answers (and
        everything that message referenced), so the other side's client files
        it under the conversation; the reply carries a Message-ID of ours, so
        their next answer is filed under the same thread here too.

        The reply is recorded as an outbound message in the thread, and the
        original — evidently read — is marked read. Only an inbound message can
        be answered. Under `EMAIL_DRY_RUN` nothing is sent and the log shows
        what would have been, threading headers included. A send the mailer
        refuses is a 502 with its words, and nothing is recorded. Audited.
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, minLength: 1, maxLength: 20000, description: Blank lines separate paragraphs }
                subject: { type: string, maxLength: 200, description: "Defaults to the original's, with Re: in front" }
      responses:
        "201":
          description: Sent and recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { $ref: "#/components/schemas/InboxMessageSummary" }
                  sent:
                    type: object
                    properties:
                      to: { type: string }
                      from: { type: string }
                      subject: { type: string }
                      inReplyTo: { type: string, nullable: true }
                      references: { type: string, nullable: true }
        "400": { description: The message is a reply of ours }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { description: The mailer refused the send }

  /admin/email/stats:
    get:
      tags: [Support inbox]
      summary: What is waiting
      description: |
        Any admin. Unread and unassigned inbound counts, and each address with
        its own — across the inbox for an `email` holder (`all: true`), or the
        caller's own addresses otherwise, or with `?mine=true`.
      parameters:
        - { name: mine, in: query, schema: { type: boolean }, description: Only the caller's own addresses }
      responses:
        "200":
          description: The counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  unread: { type: integer }
                  unassigned: { type: integer, description: "Inbound, unread or read, with nobody assigned" }
                  addresses: { type: array, items: { $ref: "#/components/schemas/InboxAddress" } }
        "403": { $ref: "#/components/responses/AdminOnly" }

  /admin/klock/devices:
    get:
      tags: [Devices]
      summary: The device fleet
      description: |
        Counts by status, kind and key store, and the newest devices. Needs the `support` admin scope.

        **Capability:** `admin:support` (area `admin`).
      x-prorm-capability: {"router": "adminDevices", "needs": "admin:support", "area": "admin"}
      parameters:
        - name: recent
          in: query
          required: false
          description: How many of the newest devices to list, 0–100
          schema: { type: integer, minimum: 0, maximum: 100, default: 20 }
      responses:
        "200":
          description: The fleet
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  byStatus: { type: object, additionalProperties: { type: integer } }
                  byKind: { type: object, additionalProperties: { type: integer } }
                  byKeyStore: { type: object, additionalProperties: { type: integer } }
                  recent:
                    type: array
                    items: { $ref: "#/components/schemas/Device" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403": { $ref: "#/components/responses/AdminOnly" }
        "502": { $ref: "#/components/responses/KlockServiceUnavailable" }
        "503": { $ref: "#/components/responses/KlockUnconfigured" }

  /proxy/{uuid}/query:
    servers:
      - url: https://api.prod-orm.io
        description: the data plane is mounted outside the control plane's base path
    post:
      tags: [Data plane]
      summary: Run a query against a database
      description: |
        The data plane: what a client library talks to, continuously. Same
        described shapes as the control plane's query route — `select`,
        `insert`, `update`, `delete` — but pooled, with a circuit breaker in
        front of a failing engine, retries for transient failures only, and
        every statement recorded.

        Api keys only. A database is addressed by uuid, which is what a client
        can be given without also learning how many exist.
      security:
        - apiKeyAuth: []
      parameters: [{ $ref: "#/components/parameters/DatabaseUuid" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QueryRequest" }
      responses:
        "200":
          description: What the engine answered
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows: { type: array, items: { type: object } }
                  meta: { type: object }
                  statement: { type: string, description: What the ORM built and ran }
                  durationMs: { type: integer }
                  attempts: { type: integer, description: Present only when it took more than one }
        "400":
          description: The request could not be compiled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: "No key, or one that is not valid"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: A readonly key was asked to write; the key's role refuses the query; the role it was issued for is gone; or the caller's address is blocked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429":
          description: Rate limited, or no pool is available for this database and its owner's allowance is spent (`next` says how to get another)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  next: { type: string }
        "502":
          description: The database could not be reached
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: |
            The breaker is open — this database has been failing, so the request
            was refused without trying. `Retry-After` says when to come back.
          headers:
            Retry-After: { schema: { type: integer }, description: seconds }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /proxy/{uuid}/batch:
    servers:
      - url: https://api.prod-orm.io
        description: the data plane is mounted outside the control plane's base path
    post:
      tags: [Data plane]
      summary: Several statements, in order, over one connection
      description: |
        Not a transaction — engines differ on what one means over HTTP, and
        promising atomicity we cannot deliver is worse than not offering it.
        Every query is judged against the key (readonly, its role) before any
        runs; then it stops at the first failure and reports which one,
        alongside what already succeeded.
      security:
        - apiKeyAuth: []
      parameters: [{ $ref: "#/components/parameters/DatabaseUuid" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [queries]
              properties:
                queries:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { $ref: "#/components/schemas/QueryRequest" }
      responses:
        "200":
          description: "Every result, in order"
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        rows: { type: array, items: { type: object } }
                        meta: { type: object }
                        statement: { type: string }
        "400":
          description: The batch did not pass its rules, or one of the queries could not be compiled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchFailure" }
        "401":
          description: "No key, or one that is not valid"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: A readonly key with a write in the batch; or the key's role refuses one of the queries (`failedAt` names it, `completed` is empty)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchFailure" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "502":
          description: One of them failed at the database
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchFailure" }
        "503":
          description: The breaker is open; `Retry-After` says when to come back
          headers:
            Retry-After: { schema: { type: integer }, description: seconds }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /proxy/{uuid}/health:
    servers:
      - url: https://api.prod-orm.io
        description: the data plane is mounted outside the control plane's base path
    get:
      tags: [Data plane]
      summary: Is this database answering
      description: Cheap enough for a client to poll.
      security:
        - apiKeyAuth: []
      parameters: [{ $ref: "#/components/parameters/DatabaseUuid" }]
      responses:
        "200":
          description: Its status, and what the breaker thinks
          content:
            application/json:
              schema:
                type: object
                properties:
                  database:
                    type: object
                    properties:
                      uuid: { type: string, format: uuid }
                      name: { type: string }
                      status: { type: string }
                  reachable: { type: boolean }
                  breaker:
                    type: object
                    properties:
                      state: { type: string, enum: [closed, open, half-open] }
                      failures: { type: integer }
                      retryInMs: { type: integer, description: Present while open }
        "401":
          description: "No key, or one that is not valid"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: The caller's address is blocked, or the key's role is gone
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }

  /proxy/status:
    servers:
      - url: https://api.prod-orm.io
        description: the data plane is mounted outside the control plane's base path
    get:
      tags: [Data plane]
      summary: What the proxy itself is holding
      description: |
        For the platform's own monitoring, not a tenant's: the open
        connections and every circuit breaker, keyed by serial database id —
        which is exactly what uuid addressing withholds from clients, so it is
        admin-only (`requireAdmin`: the admin role; with E3, a confirmed TOTP
        second factor; for a klock session, a live `admin:*` capability after
        a step-up).
      responses:
        "200":
          description: The pool and the breakers
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProxyStatus" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: Not an admin; an assumed identity; a customer-console session; or, with E3, no second factor (`mfa_required`)
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/Error" }
                  - { $ref: "#/components/schemas/ApiError" }

  /.well-known/klock-keys.json:
    servers:
      - url: https://api.prod-orm.io
        description: at the host root, outside the API's base path
    get:
      tags: [Klock]
      summary: The published keyset
      description: |
        The root-signed keyset exactly as published — a compact JWS; clients
        verify the bytes against the root they pin. It certifies the keys that
        sign grants, device certificates and the revocation feed. Cacheable
        for five minutes.
      security: []
      responses:
        "200":
          description: The keyset (compact JWS)
          headers:
            Klock-Keyset-Seq: { description: The keyset's sequence number, schema: { type: integer } }
            Cache-Control: { schema: { type: string, example: "public, max-age=300" } }
          content:
            application/jose:
              schema: { type: string }
        "503":
          description: '`no_keyset` — none is published yet; retry later'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: no_keyset, message: No klock keyset is published }

  /.well-known/klock-configuration:
    servers:
      - url: https://api.prod-orm.io
        description: at the host root, outside the API's base path
    get:
      tags: [Klock]
      summary: What this control plane supports
      description: For a client deciding between klock v2 and the legacy sign-in.
      security: []
      responses:
        "200":
          description: The configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  v2: { type: boolean, description: Whether this API can sign grants right now }
                  api: { type: string, description: The issuer grants name }
                  klock: { type: string, format: uri, example: https://klock.prod-orm.io }
                  keysetUrl: { type: string, format: uri }
                  revocationsUrl: { type: string, format: uri }
                  sessionKinds: { type: array, items: { type: string }, example: [interactive, device, guest] }
                  capabilitiesVersion: { type: integer }

  /internal/mcp/forward:
    post:
      tags: [MCP tokens]
      summary: Run a device-signed MCP tool call relayed by the MCP Worker
      description: |
        For the MCP Worker, not for clients. The Worker relays a request a
        device signed for the MCP endpoint (`POST https://mcp.prod-orm.io/mcp`
        or `/mcp/account`, per `MCP_ORIGINS`): the device's Klock grant, the
        signature headers, and the exact body bytes. The API verifies it as
        the Klock scheme would have — grant, RFC 9421 signature, single-use
        nonce, live session, live capabilities — and takes the tool and its
        arguments from the signed JSON-RPC `tools/call` only; the Worker's
        own `tool` and `arguments`, when sent, must agree. A compromised
        Worker can neither invent nor replay a call.

        Then the same rules as `POST /security/mcp/call`: the tool's `needs`
        against the session's capabilities, confirmation for a destructive
        tool, a fresh step-up for a `stepUp` tool (which a Klock session can
        make), no mutating admin tools. The call runs as the device session's
        owner and answers with the derived route's answer.

        The credential is in the body, not in `Authorization`.

        **Capability:** none from the table; the credential is its own (see above). Klock grant + RFC 9421 over the MCP request; the tool's needs.
      x-prorm-capability: {"router": "mcpForward", "external": true, "needs": null, "note": "Klock grant + RFC 9421 over the MCP request; the tool's needs"}
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/McpForwardRequest" }
      responses:
        "200":
          description: The derived route's answer (its own status and body)
          headers:
            X-Prorm-Mcp-Tool: { schema: { type: string } }
        "400":
          description: |
            `bad_request` (not `{ grant, url, headers, body }`, or `url` is not
            a URL), `bad_intent` (the signed body is not one JSON-RPC
            `tools/call`), `intent_mismatch` (the relayed tool or arguments
            differ from the signed ones), `bad_arguments`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
              example: { error: intent_mismatch, message: The relayed tool call is not the one the device signed }
        "401":
          description: |
            The signed request was refused: `grant_invalid`, `grant_expired`,
            `signature_invalid` (unsigned, or not signed for the MCP endpoint),
            `clock_skew`, `nonce_replayed`, `session_revoked`,
            `device_key_mismatch`; or `step_up_required`.
            `WWW-Authenticate: Klock error="<code>"`.
          headers:
            WWW-Authenticate: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403":
          description: "`capability_missing` (a query's intent), or `service_session_scope` (a service session's grant)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityRefusal" }
        "404":
          description: "`tool_not_found`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "423": { $ref: "#/components/responses/SessionFrozen" }
        "428":
          description: "`confirmation_required` — see `POST /security/mcp/call`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "503":
          description: "`mcp_unavailable`, or `klock_v2_unavailable`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /transfer/strategies:
    get:
      tags: [Transfers]
      summary: The strategies this account's tier may use
      description: |
        The account's plan maps to a Migrate tier (`free` → free, `usage` →
        pro, `membership` → premium); SSH/SFTP and the accelerated strategies
        are premium.

        **Capability:** none from the table; any signed-in account, about itself.
      x-prorm-capability: {"router": "transfers", "self": true, "needs": null}
      responses:
        "200":
          description: The tier, and what it may use
          content:
            application/json:
              schema:
                type: object
                properties:
                  tier: { type: string, enum: [free, pro, premium] }
                  strategies: { type: array, items: { $ref: "#/components/schemas/TransferStrategy" } }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }

  /transfer/quote:
    post:
      tags: [Transfers]
      summary: Price a transfer without creating it
      description: |
        The same planning `POST /transfer/jobs` does — the strategy, the tier,
        the scope and the price — with nothing created. A local run is priced
        at the local flat rate at least.

        **Capability:** `resource:operate` (area `resource`, on the transfer-job).
      x-prorm-capability: {"router": "transfers", "needs": "resource:operate", "area": "resource", "resource": "transfer-job"}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TransferJobRequest" }
      responses:
        "200":
          description: The quote
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TransferQuote" }
        "400":
          description: The body did not pass its rules, or Migrate refused the plan (an unknown strategy, an unmet control)
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/ValidationErrors" }
                  - { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "403":
          description: The strategy needs a higher tier than this account's plan buys
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /transfer/jobs:
    get:
      tags: [Transfers]
      summary: The account's transfer jobs, newest first
      description: |
        With no `scope`, exactly the jobs this account made that are not a
        team's. `scope=team` is the jobs of the account's active teams its
        role may read, `all` both; with a scope each job says how it is
        reached (`access`).

        **Capability:** `resource:read` (area `resource`, on the transfer-job).
      x-prorm-capability: {"router": "transfers", "needs": "resource:read", "area": "resource", "resource": "transfer-job"}
      parameters:
        - name: scope
          in: query
          required: false
          schema: { type: string, enum: [own, team, all] }
      responses:
        "200":
          description: The jobs
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/TransferJob"
                        - type: object
                          properties:
                            access: { $ref: "#/components/schemas/TransferJobAccess" }
        "400":
          description: "`scope is own, team or all`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
    post:
      tags: [Transfers]
      summary: Create a transfer job
      description: |
        Plans the job with Migrate, stores the projected price for the holder
        to confirm at `/start`, and answers 201. Needs a verified account and
        a card on file — the team's, for a team's job.

        With `teamId`, the job is the team's: the caller must be an active
        member (another team answers 404 `Unknown team`, as a team that does
        not exist does) whose role carries `resource:operate` (403
        `capability_missing`, `unlock: team-role`). Its members then operate
        it by role, and it outlives the member who made it. A team may settle
        transfers at end of term (`PUT /teams/{teamId}/transfer-billing`).

        `regimes` holds the transfer to named data-localisation regimes; one
        that breaches a regime is refused (422) before anything is created.

        A local job (`execution: local`) reports its own progress. With
        `klock: v1` (the default) the answer carries `klockSecret`, once: the
        on-device agent MACs its reports with it. With `klock: v2` no secret
        is handed back — the owner opens a service session for the agent's
        device key (`POST /security/klock/sessions`, `kind: service`,
        `resource: {kind: transfer-job, id}`) and the agent signs every report
        with it.

        **Capability:** `resource:operate` (area `resource`, on the transfer-job). With a teamId: resource:operate in that team.
      x-prorm-capability: {"router": "transfers", "needs": "resource:operate", "area": "resource", "resource": "transfer-job", "note": "with a teamId: resource:operate in that team"}
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TransferJobRequest" }
      responses:
        "201":
          description: Created, in status `created`
          headers:
            Idempotency-Key: { $ref: "#/components/headers/IdempotencyKey" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/TransferJob" }
                  klockSecret:
                    type: string
                    description: A local v1 job only, and only in this answer — base64 of the job's transfer klock secret
        "400":
          description: The body did not pass its rules, or Migrate refused the plan
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/ValidationErrors" }
                  - { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "402":
          description: No card on file (the team's, for a team's job)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: |
            An account that is not verified yet; a strategy above the plan's
            tier; or, with `teamId`, a role without `resource:operate`
            (`capability_missing`).
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/Error" }
                  - { $ref: "#/components/schemas/CapabilityRefusal" }
        "404":
          description: "`Unknown team` — a `teamId` the caller is not an active member of"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Refused for compliance — the transfer breaches a named regime
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /transfer/jobs/{jobId}:
    parameters:
      - { $ref: "#/components/parameters/TransferJobId" }
    get:
      tags: [Transfers]
      summary: One job, and whether its certificate holds
      description: |
        The caller's own job, or their team's; anybody else's is 404.
        `certified` checks the completion certificate with the job's klock.
        `attestation` says what the certificate rests on: `platform` (the
        runner reported it), `relay-metered` (the owner's side reported it and
        klock relay receipts metered at least the bytes it states), or
        `self-attested`.

        **Capability:** `resource:read` (area `resource`, on the transfer-job).
      x-prorm-capability: {"router": "transfers", "needs": "resource:read", "area": "resource", "resource": "transfer-job"}
      responses:
        "200":
          description: The job
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/TransferJob" }
                  certified: { type: boolean }
                  attestation:
                    type: string
                    nullable: true
                    enum: [platform, relay-metered, self-attested]
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404":
          description: "`Unknown transfer job`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Transfers]
      summary: Cancel an unfinished job
      description: |
        A job that has not started, or a recurring one whose next run is not
        yet due, is cancelled free. A local run that was armed (started, the
        price confirmed) settles its confirmed price — at least the local flat
        rate, and at least the price of what the klock relay metered —
        because the platform cannot tell a run that never happened from one
        finished on the owner's device.

        **Capability:** `resource:operate` (area `resource`, on the transfer-job).
      x-prorm-capability: {"router": "transfers", "needs": "resource:operate", "area": "resource", "resource": "transfer-job"}
      responses:
        "200":
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/TransferJob" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "404":
          description: "`Unknown transfer job`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: The job is already completed, failed, restored or cancelled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /transfer/jobs/{jobId}/start:
    post:
      tags: [Transfers]
      summary: Start a created job, confirming its price
      description: |
        The holder confirms the projected price they were shown; a job never
        starts against a number they did not agree to. A local job priced
        before the local flat rate existed is re-priced to it first, so a
        confirmation of the old price is then refused as stale (409). The card
        must still be on file. The job moves to `queued`; a cloud job starts
        running on the tail of this response, a local one is run on the
        device.

        **Capability:** `resource:operate` (area `resource`, on the transfer-job).
      x-prorm-capability: {"router": "transfers", "needs": "resource:operate", "area": "resource", "resource": "transfer-job"}
      parameters:
        - { $ref: "#/components/parameters/TransferJobId" }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                confirmProjectedCents:
                  type: integer
                  minimum: 0
                  description: The job's `projectedCents`, exactly
      responses:
        "200":
          description: Queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/TransferJob" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/KlockUnauthorized" }
        "402":
          description: No card on file any more
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: "`Unknown transfer job`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: The job is not in status `created`, or the confirmed price is not the projected one — re-quote and confirm again
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "428":
          description: No `confirmProjectedCents`; the message names the price to confirm
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /transfer/jobs/{jobId}/events:
    post:
      tags: [Transfers]
      summary: Report a job's status (the runner, or the owner's side of a local run)
      description: |
        Not a customer route: the reporter is whoever moves the bytes, and the
        job is loaded by its uuid across every account. Four credentials, one
        of which must hold:

        - **the cloud runner**: `Authorization: Bearer <MIGRATE_RUNNER_TOKEN>`,
          compared in constant time. Without the token configured this arm is
          closed. The runner is the platform, so what it reports is believed.
        - **the owner**, for a local job: their own bearer session token.
        - **a v1 transfer klock**, for a local job: `Authorization: klock <mac>`
          or `X-Transfer-Klock: <mac>`, the base64url HMAC-SHA256 with the
          job's `klockSecret` over `transfer/report/v1`, a zero byte, then
          `<jobId>.<status>.<window>` (the 30-second window, or the one
          before). Not accepted for a v2 job.
        - **a v2 on-device agent** (a job created with `klock: v2`): the
          agent's service session — `transfer:report` over exactly this job —
          signs the whole request, body included, with its device key (RFC
          9421, a single-use nonce): `Authorization: Klock <grant>`, or
          `Authorization: Klock device=<thumbprint>` with `Klock-Device-Pub`
          before it has synced a grant. Refusals: `wrong_subject` (403: not a
          local job, or not this job's current agent), `not_bound` (403: the
          job reports with its v1 klock), and the scheme's own
          (`grant_invalid`, `grant_expired`, `device_key_mismatch`,
          `session_revoked`, `unauthenticated`, …).

        Anything the owner's side reports about a local run is self-reported:
        an armed local run's end — completed, failed, restored or cancelled —
        is billed at least the confirmed price and at least what the relay
        metered; an under-report is flagged, and the certificate is marked
        `self-attested` (or `relay-metered`).

        **The local-run metering receipt.** `receipt` is a klock relay
        receipt: a JWS the klock service signs with the keyset's `klock` key
        when a relayed file transfer ends. It is counted before the report
        (so the report is priced with it) when it verifies against the
        current keyset, says `completed`, was sent or received by the job's
        owner, began after the run was confirmed (60 s of skew), and has
        never been counted before, for any job. Its bytes add to
        `meteredBytes`. Ignored from the runner, which measures its own runs.

        Unknown transitions are refused (409), so a job's status stays a true
        account of where it is. On `completed` with a `manifestDigest` the
        job's klock signs a certificate; a recurring job then re-arms to
        `queued` for its next occurrence.

        **Capability:** none from the table; the credential is its own (see above). Runner token, owner session, or job klock.
      x-prorm-capability: {"router": "transfers", "external": true, "needs": null, "note": "runner token, owner session, or job klock"}
      security:
        - transferRunnerToken: []
        - bearerAuth: []
        - transferKlockV1: []
        - Klock: []
      parameters:
        - { $ref: "#/components/parameters/TransferJobId" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [created, queued, backing-up, running, verifying, completed, failed, restoring, restored, cancelled]
                message: { type: string }
                manifestDigest: { type: string, description: "On completion, the digest of what was moved" }
                bytes: { type: integer, minimum: 0, description: "On completion, the bytes moved" }
                error: { type: string }
                receipt:
                  type: string
                  maxLength: 16384
                  description: A klock relay receipt (compact JWS) that meters a local run
      responses:
        "200":
          description: Applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/TransferJob" }
        "400":
          description: The body did not pass its rules; or a receipt for a job that is not local
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/ValidationErrors" }
                  - { $ref: "#/components/schemas/Error" }
        "401":
          description: |
            None of the four credentials holds (`{error}`: only the transfer
            runner, the owner, or the job's klock may report status); or a v2
            report whose signature, grant or session was refused
            (`{error, message}` with a klock code, and `WWW-Authenticate`).
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/Error" }
                  - { $ref: "#/components/schemas/ApiError" }
        "403":
          description: |
            A v2 report refused as `wrong_subject` or `not_bound`; or a
            receipt for a relay transfer that is not the job owner's.
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/ApiError" }
                  - { $ref: "#/components/schemas/Error" }
              example: { error: not_bound, message: "This job reports with its v1 klock: no on-device agent is bound to it" }
        "404":
          description: "`Unknown transfer job`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: A transition the job cannot make; a receipt before the job was started; a receipt already counted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: The receipt is not a klock relay receipt, does not verify, did not complete, or began before this run was confirmed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Relay receipts cannot be checked right now (no verified keyset); report without one — or a klock v2 report on a deployment without klock v2 (`klock_v2_unavailable`)
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/Error" }
                  - { $ref: "#/components/schemas/ApiError" }

components:
  securitySchemes:
    apiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        A key issued for one database, as `prk_<prefix>.<secret>`. It is shown
        once when issued and stored only as a hash, so it cannot be read back.

    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        The token from `/signin` or `/verify`. Every token carries a `jti` and
        has a session behind it, so one can be signed out without changing the
        signing key and ending everybody's day.

    Klock:
      type: http
      scheme: Klock
      description: |
        Klock v2: a device-bound session. Every request carries
        `Authorization: Klock <grant>` — the grant is a JWS the API signed when
        the session was opened (or last refreshed), naming the session, the
        device key's thumbprint and the capabilities it may carry — and an
        RFC 9421 HTTP message signature (`Signature-Input`, `Signature`) made by
        that device key over the request, the body included. The API checks the
        grant, the signature, the nonce (single-use for anything that changes
        state, and for any request made with a live `admin:*` capability), then
        the session row, which must exist and be live. The capabilities are the
        grant's intersected with what the account may do right now, so a
        demoted team role or a withdrawn admin scope takes effect at once.

        A refusal is `{ error, message }` with a code from the klock error
        catalog (`KlockErrorCode`), and a 401 carries
        `WWW-Authenticate: Klock error="<code>"`.

        Where no grant exists yet — opening a session, the guest grant, and a
        service session that has not synced its grant — the header is
        `Authorization: Klock device=<thumbprint>` instead, signed the same way
        by the device key (whose public key travels in the body, or in
        `Klock-Device-Pub`). Every route that takes a bearer token also takes
        this scheme.

    delegatedBearer:
      type: http
      scheme: bearer
      bearerFormat: prorm_dt_…
      description: |
        A delegated MCP client token, `prorm_dt_<…>`, made with
        `POST /security/mcp/clients` and shown once. It is accepted only by
        `GET /security/mcp/capabilities` and `POST /security/mcp/call` — never
        by a route that takes an account's own bearer — and every call is
        re-checked against the MCP tool registry and the token's delegated
        capabilities. It never carries an `admin:*` capability.

    transferRunnerToken:
      type: http
      scheme: bearer
      description: |
        The shared `MIGRATE_RUNNER_TOKEN` the platform's transfer runner holds.
        Accepted only by `POST /transfer/jobs/{jobId}/events`; with no token
        configured, nothing matches it.

    transferKlockV1:
      type: apiKey
      in: header
      name: X-Transfer-Klock
      description: |
        A v1 transfer klock report MAC for a local job — base64url
        HMAC-SHA256 with the job's `klockSecret` over `transfer/report/v1`, a
        zero byte, and `<jobId>.<status>.<30-second window>`. Also accepted as
        `Authorization: klock <mac>`. Only `POST /transfer/jobs/{jobId}/events`.

  parameters:
    PathId:
      name: id
      in: path
      required: true
      schema: { type: integer, minimum: 1 }
    OperationId:
      name: operationId
      in: path
      required: true
      schema: { type: integer, minimum: 1 }
    PolicyId:
      name: policyId
      in: path
      required: true
      schema: { type: string, example: auth.failed.standard.rotate }
    TeamId:
      name: teamId
      in: path
      required: true
      schema: { type: integer, minimum: 1 }
    ProjectId:
      name: projectId
      in: path
      required: true
      schema: { type: integer, minimum: 1 }
    DatabaseUuid:
      name: uuid
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: The database's uuid — what a client is given instead of its id.

    ChildId:
      name: childId
      in: path
      required: true
      schema: { type: integer, minimum: 1 }

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, maxLength: 200 }
      description: |
        Send one and a retry replays the first response instead of creating a
        second thing. The same key with a different body is refused (422); while
        the first is still running, a retry is told to wait (409). Send none and
        one is generated and returned, so a caller that decides to retry has
        something to retry with. Keys last a day and are scoped per account.

    KlockSessionRef:
      name: id
      in: path
      required: true
      description: '`current`, the session''s `ks_…` id, or its row id. It must be one of the caller''s sessions.'
      schema: { type: string, example: current }

    ListLimit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 500, default: 50 }
      description: Clamped to 1–500 rather than refused

    ListOffset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, default: 0 }

    DeviceIdPath:
      name: id
      in: path
      required: true
      description: A device id; anything not of this shape answers 404 `device_not_found`
      schema: { type: string, pattern: '^dev_[A-Za-z0-9]{1,40}$', example: dev_7fK2mQ }

    DeviceIdAdmin:
      name: deviceId
      in: path
      required: true
      schema: { type: string, pattern: '^dev_[A-Za-z0-9]{1,40}$' }

    DeviceTeamQuery:
      name: team
      in: query
      required: false
      description: |
        A team's id, uuid or slug: act on a member's device, through the
        caller's `device:manage-members` in that team. May be sent in the body
        instead on the routes that take one.
      schema: { type: string, maxLength: 100 }

    TeamRef:
      name: teamId
      in: path
      required: true
      description: The team's id, uuid or slug
      schema: { type: string, maxLength: 100 }

    SessionRef:
      name: sid
      in: path
      required: true
      description: The session's `ks_…` id or its numeric row id
      schema: { type: string, example: ks_0b6f0c5e-8a4a-4f55-9d7e-0f3c2b1a9e11 }

    PasswordlessSessionId:
      name: sessionId
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: The passwordless session's id (its uuid)

    RoleId:
      name: roleId
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: The role's id (its uuid)

    TransferJobId:
      name: jobId
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: The job's uuid (its `id`)

    PoolId:
      name: poolId
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: The pool's uuid (its `id`)

    PaymentMethodId:
      name: pmId
      in: path
      required: true
      schema: { type: string, pattern: '^pm_[A-Za-z0-9_]+$' }
      description: A Stripe payment method id on the team's customer

  headers:
    IdempotencyKey:
      description: The key this request was recorded under.
      schema: { type: string }
    IdempotencyReplayed:
      description: Present when the body is a replay rather than fresh work.
      schema: { type: string, enum: ["true"] }

  responses:
    Accepted:
      description: |
        Taken, not done. The operation is where the answer will appear.
      content:
        application/json:
          schema:
            type: object
            properties:
              operation: { $ref: "#/components/schemas/Operation" }
    Unauthorized:
      description: No token, a token that no longer verifies, or a session that was signed out
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Your role cannot do that
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              needs: { type: string, example: project:write }
              yourCapabilities: { type: array, items: { type: string } }
    PlanLimit:
      description: |
        The account's plan does not allow this. The body names the limit, what
        is in use, what it would become, and which plans would take it.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              limit:
                type: object
                properties:
                  of: { type: string, enum: [databases, stores, storage] }
                  allowed: { type: integer }
                  used: { type: integer }
                  wouldBe: { type: integer }
              plan: { $ref: "#/components/schemas/Plan" }
              upgrade:
                type: object
                nullable: true
                properties:
                  to: { type: array, items: { type: string } }
                  how: { type: string }
                  body: { type: object }
    AdminOnly:
      description: |
        This route is admin-only: the account is not an admin, the admin is
        holding an assumed identity, or the session was handed off to the
        customer console (`{ error: "<sentence>" }`). With enforcement E3 on,
        also `mfa_required` — an admin with no confirmed TOTP second factor —
        whose body says where to enrol (see `MfaRequired`).
      content:
        application/json:
          schema:
            oneOf:
              - $ref: "#/components/schemas/Error"
              - $ref: "#/components/schemas/ApiError"
    TooManyRequests:
      description: |
        Rate limited. The auth routes get a strict budget, the data plane its
        own per-minute one keyed by api key, and everything else a shared one.
      headers:
        Retry-After: { schema: { type: integer }, description: seconds }
        RateLimit-Policy: { schema: { type: string }, description: e.g. "600;w=60" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServiceUnavailable:
      description: |
        Read-only mode (MAINTENANCE_MODE), or a dependency this route needs is
        not configured on this deployment.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such thing, or none that is yours
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: It clashes with something that already exists
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: The request body did not pass its rules
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ValidationErrors" }

    KlockUnauthorized:
      description: |
        Not authenticated. For a bearer, the token is missing, expired or signed
        out. For `Authorization: Klock`, the grant, the signature or the session
        was refused: `grant_invalid`, `grant_expired`, `signature_invalid`,
        `clock_skew`, `nonce_replayed`, `session_revoked`, `device_key_mismatch`.
      headers:
        WWW-Authenticate:
          description: '`Klock error="<code>"` on a klock refusal'
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
          example: { error: grant_expired, message: The grant has expired; refresh it }

    CapabilityMissing:
      description: |
        `capability_missing` — the session, the key or the caller's team role
        does not carry the capability this route needs. Only answered where the
        area's enforcement mode is `enforce` (a team role is always checked).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/CapabilityRefusal" }
          example: { error: capability_missing, message: This session cannot do that, needs: resource:delete, unlock: team-role }

    StepUpRequired:
      description: |
        `step_up_required` — the route (or the capability, when it is only
        dormant) needs a fresh step-up. 401 with `WWW-Authenticate: Klock
        error="step_up_required"` for a klock session, 403 for a bearer.
      headers:
        WWW-Authenticate:
          description: '`Klock error="step_up_required"` for a klock session'
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/CapabilityRefusal" }
          example: { error: step_up_required, message: "This needs a fresh step-up: prorm step-up", needs: admin:support, unlock: step-up }

    SessionFrozen:
      description: '`session_frozen` — the klock session is frozen until it is unfrozen; or `lockdown` — the account is locked down.'
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    LoginV2Required:
      description: |
        `login_v2_required` — with enforcement E3 on (`CAPABILITY_E3`), no new
        v1 CLI bearer tokens are issued from the login flow. Sign in with
        `prorm login` (a klock v2 device session) instead. A personal token
        (`purpose: token`, 30 days at most) is still issued, and tokens already
        issued keep working until they expire.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
          example: { error: login_v2_required, message: Legacy CLI tokens are no longer issued }

    MfaRequired:
      description: |
        `mfa_required` — with enforcement E3 on, every admin route needs the
        admin to have a confirmed TOTP second factor. The body says where to
        enrol (`/security/mfa/setup`, then `/security/mfa/confirm`); those
        routes are the account's own, so an admin is never locked out of them.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/ApiError"
              - type: object
                properties:
                  enrol:
                    type: object
                    properties:
                      setup: { type: object, properties: { method: { type: string }, url: { type: string } } }
                      confirm: { type: object, properties: { method: { type: string }, url: { type: string }, body: { type: object } } }

    KlockUnavailable:
      description: |
        `klock_v2_unavailable` — this deployment has no klock signing keys or
        no published keyset, so v2 sessions cannot be issued or checked.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    AccessDenied:
      description: |
        `access_denied` — an account, team or device access rule refused the
        request (`/security/access-rules`); or `lockdown` (423) when the
        account is locked down.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    KlockUnconfigured:
      description: "`klock_unconfigured` — device management needs the klock service, and this deployment has no binding to it (or it is not configured); or `klock_v2_unavailable` — no signing key"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
          example: { error: klock_unconfigured, message: "Device management needs the klock service, and this API has no KLOCK service binding" }

    KlockServiceUnavailable:
      description: "`klock_service_unavailable` — the klock service refused or did not answer"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    DeviceCapabilityMissing:
      description: |
        `capability_missing` — the session lacks `device:read` / `device:manage`
        (or is a service session), the team role lacks `device:manage-members`,
        or the device is the team owner's; `assumed_identity` — a change under
        an assumed identity; for a pairing, `console_only`. Checked in every
        enforcement mode.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/CapabilityRefusal" }
          example: { error: capability_missing, message: "This session cannot do that (needs device:manage)", needs: device:manage, unlock: none }

    DeviceNotFound:
      description: "`device_not_found` — no such device, or none the caller may act on; or `Unknown team`"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
          example: { error: device_not_found, message: Unknown device }

    DeviceInvalidState:
      description: "`invalid_state` — the device is in the wrong state for that (a revoked or wiped device cannot be wiped; only an active one rotates its key)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    ReasonRequired:
      description: "`reason_required` — acting on someone else's device or session needs a reason (at most 500 characters)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    StaffActionBadRequest:
      description: "`reason_required` (10–500 characters), or a refused `stepUp` proof: `bad_request`, `mfa_not_enrolled`, `totp_required`, `password_browser_only`"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    StaffActionUnauthorized:
      description: "Not signed in, `step_up_failed`, or — for a klock session — `step_up_required` (also when admin capabilities are still dormant)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    StaffActionForbidden:
      description: |
        Not an admin, or the role lacks the `support` scope; `mfa_required`
        under E3 when the admin has no second factor; `step_up_required` for a
        bearer without a fresh step-up.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    StaffActionNotFound:
      description: "`not_found` — unknown account; `device_not_found` — not a device of that account"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    StepUpTooManyAttempts:
      description: "`too_many_attempts` — too many step-up attempts; try again in a few minutes"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

    WouldLockYouOut:
      description: |
        `would_lock_you_out` — the change would refuse your own current
        request. `reasons` says which rule and dimension; send `force: true`
        with a step-up to save it anyway. Forcing needs a verified email
        address (`recovery: false` when there is none).
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/ApiError"
              - type: object
                properties:
                  reasons:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string }
                        dimension: { type: string }
                        scope: { type: string, example: "account:42" }
                        message: { type: string }
                  ruleIds: { type: array, items: { type: string } }
                  fix: { type: string }
                  recovery: { type: boolean }

    TeamRoleRefusal:
      description: |
        `capability_missing` — your role on this team cannot do that
        (`unlock: team-role`, with `yourCapabilities`), or the session does not
        hold the capability; or the account is not verified yet.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/CapabilityRefusal"
              - type: object
                properties:
                  yourCapabilities: { type: array, items: { type: string } }

    UnknownTeam:
      description: No such team, or you are not an active member of it
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: Unknown team }

    TeamBillingRoleRefusal:
      description: "`capability_missing` — the caller's role on the team cannot do that (`unlock: team-role`); or an account that is not verified, or a child's account, where the route spends"
      content:
        application/json:
          schema:
            oneOf:
              - allOf:
                  - $ref: "#/components/schemas/CapabilityRefusal"
                  - type: object
                    properties:
                      yourCapabilities: { type: array, items: { type: string } }
              - $ref: "#/components/schemas/Error"
          example: { error: capability_missing, message: Your role on this team (developer) cannot do that, needs: billing:manage, unlock: team-role }

    UnknownTeamBilling:
      description: "`Unknown team` — no such team, or one the caller is not an active member of (the same answer)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    StripeRefused:
      description: The payment provider refused the request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    BillingNotConfigured:
      description: Billing (or membership, or the usage price) is not configured on this deployment
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              detail: { type: string }

  schemas:
    Error:
      type: object
      properties:
        error: { type: string }

    ValidationErrors:
      type: object
      properties:
        errors:
          type: array
          items:
            type: object
            properties:
              path: { type: string }
              msg: { type: string }
              location: { type: string }

    Token:
      type: object
      properties:
        token: { type: string }
        tokenType: { type: string, example: Bearer }
        expiresIn: { type: string, example: 1h }
        user: { $ref: "#/components/schemas/User" }

    PolicyCategory:
      type: string
      enum: [authentication, credentials, access, data-protection, availability, operations, compliance, billing, change-management, family]

    PolicySeverity:
      type: string
      enum: [info, low, medium, high, critical]

    PolicyAction:
      type: string
      enum: [rotate-credentials, revoke-key, revoke-all-keys, revoke-idle-keys, expire-keys, block-ip, pause, resume, backup, restrict-readonly, lock-provisioning, revoke-owner-sessions, notify-owner, notify-admins, record]

    PolicyTrigger:
      type: object
      description: The observation that fires the policy. Numbers are the defaults; params may move them.
      properties:
        kind: { type: string, enum: [threshold, event, metric, age, every, state] }
        "on": { type: string, description: "threshold/event: the event name" }
        count: { type: integer }
        withinMinutes: { type: integer }
        metric: { type: string }
        op: { type: string, enum: [">=", "<="] }
        value: { type: number }
        forMinutes: { type: integer }
        of: { type: string, description: "age: what is measured" }
        days: { type: integer }
        check: { type: string, description: "state: the fact checked" }

    PolicyParam:
      type: object
      properties:
        type: { type: string, enum: [integer, string, boolean] }
        default: {}
        min: { type: number }
        max: { type: number }
        description: { type: string }

    Policy:
      type: object
      properties:
        id: { type: string, example: auth.failed.standard.rotate }
        name: { type: string }
        category: { $ref: "#/components/schemas/PolicyCategory" }
        severity: { $ref: "#/components/schemas/PolicySeverity" }
        description: { type: string }
        trigger: { $ref: "#/components/schemas/PolicyTrigger" }
        actions: { type: array, items: { $ref: "#/components/schemas/PolicyAction" } }
        params:
          type: object
          additionalProperties: { $ref: "#/components/schemas/PolicyParam" }
        cooldownMinutes: { type: integer }
        frameworks: { type: array, items: { type: string }, description: "Citations: SOC 2 CC6.1, GDPR Art. 32, PCI DSS 8.3.9" }

    PolicyCatalog:
      type: object
      properties:
        total: { type: integer, example: 150 }
        categories:
          type: array
          items:
            type: object
            properties:
              category: { $ref: "#/components/schemas/PolicyCategory" }
              count: { type: integer }
              policies: { type: array, items: { $ref: "#/components/schemas/Policy" } }
        events:
          type: object
          description: "Every event a policy may be about, with what it means"
          additionalProperties: { type: string }

    PolicyAssignment:
      type: object
      properties:
        uuid: { type: string, format: uuid }
        policyId: { type: string }
        enabled: { type: boolean }
        params: { type: object, additionalProperties: true }
        lastFiredAt: { type: string, format: date-time, nullable: true }
        fireCount: { type: integer }
        lastCheckedAt: { type: string, format: date-time, nullable: true }
        assignedAt: { type: string, format: date-time }
        policy: { $ref: "#/components/schemas/Policy" }

    PolicyOutcome:
      type: object
      properties:
        action: { $ref: "#/components/schemas/PolicyAction" }
        ok: { type: boolean }
        result: { type: string, description: "What happened, in a sentence. Never a secret." }

    PolicyFiring:
      type: object
      description: Also the `detail` of a `policy.fired` event.
      properties:
        policyId: { type: string }
        reason: { type: string, example: "12 auth.failed from 203.0.113.9 in 5m (threshold 10)" }
        outcomes: { type: array, items: { $ref: "#/components/schemas/PolicyOutcome" } }

    CredentialTrace:
      type: object
      properties:
        prefix: { type: string }
        key:
          type: object
          properties:
            uuid: { type: string, format: uuid }
            name: { type: string }
            privilege: { type: string, enum: [owner, admin, readwrite, readonly] }
            createdAt: { type: string, format: date-time }
            lastUsedAt: { type: string, format: date-time, nullable: true }
            expiresAt: { type: string, format: date-time, nullable: true }
            revokedAt: { type: string, format: date-time, nullable: true }
            live: { type: boolean }
        issuedTo:
          type: object
          nullable: true
          properties:
            id: { type: integer }
            username: { type: string }
            email: { type: string, format: email }
        addresses:
          type: array
          items:
            type: object
            properties:
              ip: { type: string }
              first: { type: string, format: date-time }
              last: { type: string, format: date-time }
              count: { type: integer }
        recent:
          type: array
          items:
            type: object
            properties:
              at: { type: string, format: date-time }
              action: { type: string }
              ip: { type: string, nullable: true }
              detail: { type: object, additionalProperties: true }

    Relation:
      type: object
      description: One adult standing behind one child's account.
      properties:
        uuid: { type: string, format: uuid }
        parentId: { type: integer }
        childId: { type: integer }
        relationship: { type: string, enum: [parent, guardian, other] }
        since: { type: string, format: date-time }

    Restrictions:
      type: object
      description: What a parent has allowed. Every account starts fully allowed.
      properties:
        provisioning: { type: boolean }
        signin: { type: boolean }

    ChildSummary:
      allOf:
        - { $ref: "#/components/schemas/Relation" }
        - type: object
          properties:
            child: { $ref: "#/components/schemas/User" }
            holdings:
              type: object
              properties:
                databases: { type: integer }
                stores: { type: integer }
                storageMb: { type: integer }
                monthly: { type: integer, description: Cents }
            restrictions: { $ref: "#/components/schemas/Restrictions" }

    SignupRequest:
      type: object
      required: [firstname, lastname, username, email, password, conf-password]
      properties:
        firstname: { type: string }
        lastname: { type: string }
        username: { type: string, pattern: "^[a-zA-Z0-9._-]{3,30}$" }
        email: { type: string, format: email }
        password: { type: string, format: password, minLength: 8 }
        conf-password: { type: string, format: password }
        age:
          type: integer
          minimum: 8
          maximum: 120
          description: Required unless `birthday` is sent; derived from it when both are.
        birthday:
          type: string
          format: date
          description: >-
            YYYY-MM-DD. Required under 16, because the consent rule ends on a
            date. Optional and preferred for everyone else.

    SigninRequest:
      type: object
      required: [password]
      description: Send an address, a username, or `login` holding either.
      properties:
        email: { type: string, format: email }
        username: { type: string }
        login: { type: string }
        password: { type: string, format: password }

    UpdateMeRequest:
      type: object
      properties:
        firstname: { type: string }
        lastname: { type: string }
        email: { type: string, format: email }
        password: { type: string, format: password, minLength: 8 }
        conf-password: { type: string, format: password }
        current-password:
          type: string
          format: password
          description: Required when changing the address or the password.

    User:
      type: object
      properties:
        id: { type: integer }
        email: { type: string, format: email }
        username: { type: string }
        fname: { type: string }
        lname: { type: string }
        fullname: { type: string }
        age: { type: integer }
        role: { type: string, enum: [admin, parent, basic], description: This account's standing with us }
        birthday: { type: string, format: date, nullable: true, description: Present for a child's account }
        guardian:
          type: object
          nullable: true
          description: Present while the account has a named parent or guardian
          properties:
            email: { type: string, format: email }
            username: { type: string, nullable: true }
            relationship: { type: string, enum: [parent, guardian, other] }
            approved: { type: boolean }
        emancipatedAt: { type: string, format: date-time, nullable: true, description: When the account stopped being a child's }
        restrictions: { $ref: "#/components/schemas/Restrictions" }
        verified: { type: boolean }

    Address:
      type: object
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid }
        email: { type: string, format: email }
        primary: { type: boolean }
        verified: { type: boolean, description: "Confirmed with the code mailed to it; until then it counts for nothing" }

    GeoPoint:
      type: object
      description: |
        Where something runs. Stored as GeoJSON in a geography column and taken
        as `{ lat, lng }` here.
      properties:
        lat: { type: number, minimum: -90, maximum: 90 }
        lng: { type: number, minimum: -180, maximum: 180 }

    D1Region:
      type: string
      description: Cloudflare D1 takes a location hint, not an address.
      enum: [wnam, enam, weur, eeur, apac, oc]

    Usage:
      type: object
      properties:
        used: { type: integer }
        usedUnit: { type: string, example: 1.70 GB }
        usedPercent: { type: string, example: 85% }
        cpu: { type: integer, description: millicores in use }
        memory: { type: integer, description: MB in use }
        connections: { type: integer }
        requests: { type: integer }
        lastUsedAt: { type: string, format: date-time, nullable: true }

    UsageReport:
      type: object
      properties:
        used: { type: integer }
        cpu: { type: integer }
        memory: { type: integer }
        connections: { type: integer }
        requests: { type: integer }

    Resource:
      type: object
      description: What a database and a data store answer the same way.
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        type: { type: string, description: The engine }
        status: { type: string, enum: [provisioning, active, paused, error, deleting] }
        managed: { type: boolean, description: Whether we run it or they do }
        region: { type: string, nullable: true }
        size: { type: integer }
        sizeUnit: { type: string, example: 2.00 GB }
        usage: { $ref: "#/components/schemas/Usage" }
        localPoint: { $ref: "#/components/schemas/GeoPoint" }
        primaryPoint: { $ref: "#/components/schemas/GeoPoint" }
        primaryDistance: { type: string, nullable: true, example: 6549 km }

    Database:
      allOf:
        - $ref: "#/components/schemas/Resource"
        - type: object
          properties:
            type: { type: string, description: "One of the 27 SQL dialects prorm supports, e.g. postgres" }
            version: { type: string, nullable: true }
            roles:
              type: array
              description: The engine's own roles — who may read and write the data.
              items:
                type: object
                properties:
                  name: { type: string }
                  privilege: { $ref: "#/components/schemas/DatabasePrivilege" }
            provider: { type: string, nullable: true, example: cloudflare }
            externalId: { type: string, nullable: true, description: The provider's own id }

    DataStore:
      allOf:
        - $ref: "#/components/schemas/Resource"
        - type: object
          properties:
            type: { type: string, description: "One of the 102 stores prorm ships a driver for, e.g. redis" }
            endpoint: { type: string, nullable: true }
            container: { type: string, nullable: true, description: "bucket, index, keyspace, topic" }
            items: { type: integer }

    NewDatabase:
      type: object
      required: [name, type, size]
      properties:
        name: { type: string, maxLength: 200 }
        description: { type: string, maxLength: 2000 }
        type: { type: string, example: postgres }
        size: { type: integer, minimum: 1, maximum: 10000 }
        managed: { type: boolean, default: true }
        region: { type: string, description: For a managed sqlite database this must be a D1 region }
        localPoint: { $ref: "#/components/schemas/GeoPoint" }
        primaryPoint: { $ref: "#/components/schemas/GeoPoint" }
        version: { type: string }
        roles: { type: array, items: { type: object } }
        teamId:
          description: Create it owned by this team (id or uuid), billed to the team. Needs a role that may create resources there.
          oneOf: [{ type: integer, minimum: 1 }, { type: string, format: uuid }]
        dedicated:
          type: boolean
          default: false
          description: A dedicated instance rather than a database on the shared cluster. Only for the engines the shared cluster runs; not for managed sqlite (D1).
        cpu: { type: integer, minimum: 1, maximum: 32, description: vCPUs for a dedicated instance. }
        memory: { type: integer, minimum: 512, maximum: 262144, description: Memory for a dedicated instance, in MB. }

    NewDataStore:
      type: object
      required: [name, type, size]
      properties:
        name: { type: string }
        description: { type: string }
        type: { type: string, example: redis }
        size: { type: integer, minimum: 1, maximum: 10000 }
        managed: { type: boolean, default: true }
        region: { type: string }
        endpoint: { type: string }
        container: { type: string }
        localPoint: { $ref: "#/components/schemas/GeoPoint" }
        primaryPoint: { $ref: "#/components/schemas/GeoPoint" }

    QueryRequest:
      type: object
      description: Exactly one of these. The ORM compiles it and binds the values.
      properties:
        select:
          type: object
          required: [from]
          properties:
            from: { type: string }
            attributes: { type: array, items: { type: string } }
            where: { type: object }
            order: { type: array, items: { type: array, items: { type: string } } }
            limit: { type: integer }
            offset: { type: integer }
        insert:
          type: object
          required: [into, values]
          properties:
            into: { type: string }
            values: { type: object }
        update:
          type: object
          required: [table, set]
          properties:
            table: { type: string }
            set: { type: object }
            where: { type: object }
        delete:
          type: object
          required: [from, where]
          properties:
            from: { type: string }
            where: { type: object }

    DatabasePrivilege:
      type: string
      enum: [owner, admin, readwrite, readonly]

    ApiKey:
      type: object
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid }
        name: { type: string }
        prefix: { type: string, example: prk_99cbd6a2 }
        privilege: { $ref: "#/components/schemas/DatabasePrivilege" }
        databaseId: { type: integer }
        lastUsedAt: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }

    DbUser:
      type: object
      properties:
        id: { type: integer }
        username: { type: string }
        host: { type: string, nullable: true }
        privilege: { $ref: "#/components/schemas/DatabasePrivilege" }
        state: { type: string, enum: [creating, active, error, dropped] }
        lastRotatedAt: { type: string, format: date-time, nullable: true }

    Access:
      type: object
      properties:
        mode: { type: string, enum: [cloud, private, ssh] }
        host: { type: string, nullable: true }
        port: { type: integer, nullable: true }
        sslMode: { type: string, enum: [disable, require, verify-full] }
        adminUsername: { type: string, nullable: true }
        hasAdminSecret: { type: boolean, description: "Whether one is stored, never the secret itself" }
        allowedIps: { type: array, items: { type: string }, description: CIDRs; empty on `cloud` means everybody }
        ssh:
          type: object
          nullable: true
          properties:
            host: { type: string }
            port: { type: integer }
            username: { type: string }
            fingerprint: { type: string, description: What the client should expect to be presented }

    AccessRequest:
      type: object
      required: [mode]
      properties:
        mode: { type: string, enum: [cloud, private, ssh] }
        host: { type: string }
        port: { type: integer }
        sslMode: { type: string, enum: [disable, require, verify-full] }
        adminUsername: { type: string }
        adminPassword: { type: string, format: password, description: Encrypted at rest; never returned }
        allowedIps: { type: array, items: { type: string } }
        ssh:
          type: object
          properties:
            host: { type: string }
            port: { type: integer }
            username: { type: string }
            fingerprint: { type: string }
            publicKey: { type: string }

    DatabaseObject:
      type: object
      properties:
        id: { type: integer }
        kind: { type: string, enum: [trigger, view, index, fulltext-index, sequence, policy, event, constraint] }
        name: { type: string }
        table: { type: string, nullable: true }
        definition: { type: object, description: "As it was asked for, structured" }
        sql: { type: string, nullable: true, description: What the ORM compiled it to }
        state: { type: string, enum: [pending, applied, failed, dropped] }
        appliedAt: { type: string, format: date-time, nullable: true }
        error: { type: string, nullable: true }

    NewDatabaseObject:
      type: object
      required: [kind, name, definition]
      properties:
        kind: { type: string, enum: [trigger, view, index, fulltext-index, sequence, policy, event, constraint] }
        name: { type: string, pattern: "^[a-zA-Z_][a-zA-Z0-9_]{0,99}$" }
        definition:
          type: object
          description: |
            The shapes prorm's schema API takes. A trigger has `table`, `timing`,
            `event` and a structured `body`; a view has `from`, `attributes` and
            `where`; an index has `table` and `fields`.

    NewMigration:
      type: object
      required: [version, name, operations]
      properties:
        version: { type: string, description: "Sorted as text, so pad it" }
        name: { type: string }
        operations: { type: array, items: { $ref: "#/components/schemas/SchemaOperation" } }
        rollback: { type: array, items: { $ref: "#/components/schemas/SchemaOperation" } }

    SchemaOperation:
      type: object
      required: [op]
      description: |
        One structured schema change. The list is closed: an operation that is
        not here is one the ORM cannot express for every engine.
      properties:
        op:
          type: string
          enum: [createTable, dropTable, renameTable, addColumn, removeColumn, changeColumn,
                 renameColumn, addIndex, removeIndex, addConstraint, removeConstraint,
                 createView, dropView, createTrigger, dropTrigger, createSequence,
                 createPolicy, createFullTextIndex]
        table: { type: string }
        name: { type: string }
        columns: { type: object }
        definition: { type: object }
        fields: { type: array, items: { type: string } }

    Migration:
      type: object
      properties:
        id: { type: integer }
        version: { type: string }
        name: { type: string }
        state: { type: string, enum: [pending, applied, failed, rolled-back] }
        checksum: { type: string, description: "Over the operations, not the SQL" }
        operations: { type: array, items: { $ref: "#/components/schemas/SchemaOperation" } }
        sql: { type: string, nullable: true }
        appliedAt: { type: string, format: date-time, nullable: true }
        durationMs: { type: integer }
        reversible: { type: boolean }

    Operation:
      type: object
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid }
        kind:
          type: string
          enum: [provision, resize, pause, resume, backup, restore, migrate, replicate,
                 promote, move-region, rotate-credentials, export, import, delete]
        target:
          type: object
          properties:
            kind: { type: string, enum: [database, store] }
            id: { type: integer }
        state: { type: string, enum: [queued, running, succeeded, failed, cancelled] }
        progress: { type: integer, minimum: 0, maximum: 100 }
        step: { type: string, nullable: true }
        attempts: { type: integer }
        maxAttempts: { type: integer }
        input: { type: object }
        result: { type: object }
        error: { type: string, nullable: true }
        runAfter: { type: string, format: date-time, nullable: true }
        startedAt: { type: string, format: date-time, nullable: true }
        finishedAt: { type: string, format: date-time, nullable: true }
        steps:
          type: array
          description: Present when one operation is asked for by id.
          items:
            type: object
            properties:
              position: { type: integer }
              name: { type: string }
              state: { type: string, enum: [pending, running, succeeded, failed, skipped] }
              detail: { type: object }
              error: { type: string, nullable: true }

    Session:
      type: object
      properties:
        id: { type: integer }
        userAgent: { type: string, nullable: true }
        ip: { type: string, nullable: true }
        lastSeenAt: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }
        current: { type: boolean, description: Whether this is the session asking }
        assumed:
          type: object
          nullable: true
          description: Set when this session is really a member of staff acting as the account
          properties:
            actorId: { type: integer, description: The admin's users.id }
            mode: { type: string, enum: [read, write] }

    AdminPowers:
      type: object
      properties:
        isAdmin: { type: boolean }
        type: { type: string, nullable: true, enum: [general, billing, logs, support, email, dev, other] }
        scopes: { type: array, items: { type: string } }
        canGrantAdmin: { type: boolean }

    Admin:
      type: object
      properties:
        id: { type: string, format: uuid }
        userId: { type: integer }
        type: { type: string, enum: [general, billing, logs, support, email, dev, other] }
        role: { type: string, description: "The type, or the label a custom admin was given" }
        scopes: { type: array, items: { type: string }, description: "What the row resolves to, never guessed off the type" }
        canGrantAdmin: { type: boolean }
        user: { $ref: "#/components/schemas/User", nullable: true }
        createdAt: { type: string, format: date-time }

    AuditEntry:
      type: object
      properties:
        action: { type: string, example: mfa-enabled }
        ok: { type: boolean }
        ip: { type: string, nullable: true }
        userAgent: { type: string, nullable: true }
        detail: { type: object }
        at: { type: string, format: date-time }

    TeamRole:
      type: string
      description: |
        Twelve jobs, not twelve ranks. `owner` and `admin` run the team;
        `billing` handles money and nothing that touches data; `security` holds
        the guard rails; `operator` provisions and resizes; `developer` and
        `deployer` write, with only `deployer` allowed at production;
        `analyst` reads data, `support` reads metadata, `auditor` reads the log,
        `viewer` sees that things exist, `guest` has not accepted yet.
      enum: [owner, admin, billing, security, operator, developer, deployer,
             analyst, support, auditor, viewer, guest]

    Conversation:
      type: object
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid, description: The room's address }
        kind: { type: string, enum: [direct, group, support] }
        teamId: { type: integer, nullable: true }
        team:
          type: object
          nullable: true
          properties: { id: { type: integer }, name: { type: string }, slug: { type: string } }
        title: { type: string, description: "The group's, or the other people's names" }
        open: { type: boolean, description: "Support — closed by staff, reopened by the customer writing" }
        createdBy: { type: integer, nullable: true }
        lastMessageAt: { type: string, format: date-time, nullable: true }
        lastMessagePreview: { type: string }
        participants:
          type: array
          items:
            type: object
            properties:
              userId: { type: integer }
              username: { type: string }
              name: { type: string }
              staff: { type: boolean }
              readUpTo: { type: integer }
        unread: { type: integer, description: Messages after the caller's read cursor }
        createdAt: { type: string, format: date-time }

    ChatMessage:
      type: object
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid }
        conversationId: { type: integer }
        authorId: { type: integer, nullable: true, description: Null for a line the system wrote }
        author:
          type: object
          properties: { id: { type: integer }, username: { type: string }, name: { type: string } }
        kind: { type: string, enum: [text, system] }
        body: { type: string }
        at: { type: string, format: date-time }

    TeamSso:
      type: object
      properties:
        id: { type: integer }
        teamId: { type: integer }
        provider: { type: string, enum: [oidc] }
        issuer: { type: string, format: uri }
        clientId: { type: string }
        hasSecret: { type: boolean }
        endpoints:
          type: object
          nullable: true
          description: Read from the issuer's discovery document
          properties:
            authorization: { type: string, format: uri }
            token: { type: string, format: uri }
            userinfo: { type: string, format: uri }
            fetchedAt: { type: string, format: date-time }
        domains: { type: array, items: { type: string } }
        defaultRole: { type: string }
        enabled: { type: boolean }
        enforced: { type: boolean }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Team:
      type: object
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        description: { type: string, nullable: true }
        ownerId: { type: integer }
        yourRole: { $ref: "#/components/schemas/TeamRole" }
        yourCapabilities: { type: array, items: { type: string } }

    TeamMember:
      type: object
      properties:
        id: { type: integer }
        userId: { type: integer }
        username: { type: string }
        email: { type: string, format: email }
        role: { $ref: "#/components/schemas/TeamRole" }
        state: { type: string, enum: [invited, active, suspended] }
        joinedAt: { type: string, format: date-time, nullable: true }
        capabilities: { type: array, items: { type: string } }

    SshKey:
      type: object
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid }
        name: { type: string }
        algorithm: { type: string, enum: [ed25519, rsa] }
        publicKey: { type: string }
        fingerprint: { type: string, example: "SHA256:adlBydIAnuk2gKehCFZ…" }
        databaseId: { type: integer }
        generated: { type: boolean, description: Whether we still hold a private half to reveal }
        lastUsedAt: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }

    ConnectionInfo:
      type: object
      description: How to reach a database, in the shape its transport takes.
      properties:
        transport: { type: string, enum: [http, tcp, ssh-tunnel] }
        engine: { type: string }
        endpoint: { type: string, description: For an http transport }
        authenticate:
          type: object
          description: For an http transport — which header, and which keys exist.
          properties:
            header: { type: string, example: x-api-key }
            keys: { type: array, items: { $ref: "#/components/schemas/ApiKey" } }
        host: { type: string }
        port: { type: integer }
        database: { type: string }
        sslMode: { type: string }
        users:
          type: array
          description: The database users you created. Passwords are yours; we do not keep them.
          items: { $ref: "#/components/schemas/DbUser" }
        dsn: { type: string, example: "postgres://app:<password>@db.example.com:5432/app?sslmode=require" }
        tunnel:
          type: object
          nullable: true
          properties:
            host: { type: string }
            port: { type: integer }
            username: { type: string }
            fingerprint: { type: string }
            command: { type: string, description: "The ssh -L invocation, ready to paste" }
            then: { type: string, description: What to connect to once the tunnel is up }
        allowedFrom: { type: array, items: { type: string } }

    InboxAddress:
      type: object
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid }
        address: { type: string, format: email, description: Lower case }
        label: { type: string, description: "What it is for, and the display name replies carry" }
        routesTo: { type: array, items: { type: integer }, description: users.id of the admins told when mail arrives }
        active: { type: boolean, description: Whether it accepts mail }
        ownerId: { type: integer, nullable: true, description: users.id of the admin whose personal address this is; null for a shared address }
        routing:
          type: object
          description: The Cloudflare Email Routing rule that delivers this address's mail to the Worker
          properties:
            managed: { type: boolean, description: A rule exists and this API made or adopted it }
            ruleTag: { type: string, nullable: true }
            error: { type: string, description: "Why there is no rule, when a call just tried and failed" }
        createdBy: { type: integer, nullable: true }
        createdAt: { type: string, format: date-time }
        counts:
          type: object
          description: Inbound messages at this address, by state
          properties:
            unread: { type: integer }
            read: { type: integer }
            archived: { type: integer }
            spam: { type: integer }

    InboxMailbox:
      type: object
      properties:
        address: { type: string, format: email }
        name: { type: string, nullable: true }

    InboxMessageSummary:
      type: object
      description: A row of the inbox — no bodies, no headers
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid }
        addressId: { type: integer }
        direction: { type: string, enum: [in, out] }
        state: { type: string, enum: [unread, read, archived, spam] }
        threadId: { type: string, format: uuid, description: Shared by every message in the conversation }
        parentId: { type: integer, nullable: true, description: "The message this one answers, when it is one of ours" }
        messageId: { type: string, nullable: true, description: "The Message-ID header, angle brackets included" }
        inReplyTo: { type: string, nullable: true }
        from: { $ref: "#/components/schemas/InboxMailbox" }
        to: { type: array, items: { $ref: "#/components/schemas/InboxMailbox" } }
        subject: { type: string }
        preview: { type: string, description: The first 140 characters of the text body }
        attachments: { type: integer, description: How many }
        receivedAt: { type: string, format: date-time, description: When it reached us }
        assignedTo: { type: integer, nullable: true }
        sentBy: { type: integer, nullable: true, description: "For a reply, the admin who sent it" }

    InboxMessage:
      allOf:
        - $ref: "#/components/schemas/InboxMessageSummary"
        - type: object
          properties:
            cc: { type: array, items: { $ref: "#/components/schemas/InboxMailbox" } }
            replyTo: { type: string, nullable: true }
            references: { type: string, nullable: true, description: The References header verbatim }
            envelope:
              type: object
              properties:
                from: { type: string, nullable: true, description: SMTP MAIL FROM }
                to: { type: string, nullable: true, description: SMTP RCPT TO }
            text: { type: string, nullable: true }
            html: { type: string, nullable: true }
            headers:
              type: array
              items:
                type: object
                properties:
                  key: { type: string, description: Lower case }
                  value: { type: string }
            attachments:
              type: array
              description: Metadata only — the bytes are not kept
              items:
                type: object
                properties:
                  filename: { type: string, nullable: true }
                  mimeType: { type: string }
                  size: { type: integer }
                  disposition: { type: string, nullable: true, enum: [attachment, inline] }
                  contentId: { type: string }
            rawSize: { type: integer }

    InboxNote:
      type: object
      properties:
        id: { type: integer }
        messageId: { type: integer }
        authorId: { type: integer }
        body: { type: string }
        createdAt: { type: string, format: date-time }

    Page:
      type: object
      description: What a paginated listing reports about itself.
      properties:
        limit: { type: integer }
        offset: { type: integer }
        count: { type: integer, description: Rows on this page }
        total: { type: integer, description: "Rows matching the filter, where it is cheap to know" }

    Quote:
      type: object
      description: >-
        One database priced. Every amount is integer cents — money in floating
        point is how an invoice ends up ending in 0.30000000000000004.
      properties:
        lines:
          type: array
          items:
            type: object
            properties:
              id: { type: string, enum: [storage, vcpu, memory, instance, overage, requests] }
              units: { type: number }
              unit: { type: string }
              rate: { type: integer, description: Cents per unit per month }
              amount: { type: integer, description: "Cents, after any member discount" }
              listAmount: { type: integer, description: Cents before the discount }
        reserved: { type: integer }
        metered: { type: integer }
        subtotal: { type: integer }
        discount: { type: integer }
        monthly: { type: integer }
        member: { type: boolean }
        currency: { type: string }

    Plan:
      type: object
      properties:
        id: { type: string, example: pro }
        name: { type: string, example: Pro }
        priced: { type: boolean, description: Whether it is something to check out }
        limits:
          type: object
          properties:
            databases: { type: integer }
            stores: { type: integer }
            storage: { type: integer }

    ApiError:
      type: object
      description: |
        The shape of a refusal from the klock v2 surface, and of every newer
        route: a stable machine-readable `error` code and a human `message`.
        Some refusals add fields — `needs` and `unlock` on a capability refusal,
        `reason` on an ended session. (Older routes answer `{ error }` with a
        sentence in `error`; see `Error`.)
      required: [error]
      properties:
        error:
          type: string
          description: A code — for klock refusals, one of `KlockErrorCode`.
          example: capability_missing
        message: { type: string, example: This session cannot do that }
      additionalProperties: true

    CapabilityRefusal:
      allOf:
        - $ref: "#/components/schemas/ApiError"
        - type: object
          properties:
            needs:
              description: The capability the route needs — one id, or an array meaning any-of.
              oneOf:
                - { type: string }
                - { type: array, items: { type: string } }
            unlock:
              type: string
              description: |
                What would unlock it: `login`, `step-up`, `team-role`, `none`,
                or an upgrade hint from the capability catalog.

    KlockErrorCode:
      type: string
      description: |
        The codes the klock error catalog (`ts-prorm-orm/klock`, `src/klock/errors.ts`)
        lists for this API and the klock service behind it — the `error` of a
        `{ error, message }` refusal — with the HTTP status each is answered
        with where it has one (the rest reach a caller relayed, or only over
        RPC):

          - `signature_invalid` (401) — The request signature was refused
          - `unsigned` (401) — The request is not klock-signed
          - `signature_malformed` (401) — The request signature is malformed
          - `signature_incomplete` (401) — The signature does not cover what it must
          - `signature_expired` (401) — The request signature has expired
          - `bad_signature` (401) — The request signature does not verify
          - `bad_digest` (401) — Content-Digest does not match the body
          - `clock_skew` (401) — The clock is too far off
          - `nonce_replayed` (401) — That signed request was already used
          - `nonce_reused` (401) — The klock service has seen this request
          - `bad_nonce` (400) — Not a nonce
          - `grant_expired` (401) — The grant has expired
          - `grant_invalid` (401, 400) — The grant was refused
          - `session_revoked` (401, 409) — The session has ended
          - `session_expired` — The session has expired
          - `idle_timeout` — The session idled out
          - `session_unknown` — No such session
          - `session_frozen` (423) — The session is frozen
          - `session_limit` (409) — Too many sessions of this kind
          - `not_this_session` (403) — Only the session itself may do this
          - `console_only` (403) — Open klock sessions from the console
          - `kind_unsupported` (400) — A service session needs a resource it can stand for
          - `confirm_code_required` (400) — The confirm code is required
          - `confirm_code_mismatch` (400) — The confirm code does not match
          - `already_rotated` (409) — The session was already rotated
          - `rotation_proof_invalid` (400) — The key rotation proof was refused
          - `service_session_scope` (403) — A service session is used only for what it stands for
          - `wrong_subject` (403) — That service session stands for something else
          - `already_bound` (409) — Already bound to another device key
          - `subject_unavailable` (409) — Nothing left to bind
          - `not_bound` (403) — Not bound to a device key
          - `device_signature_required` (403) — This session takes only device-signed requests
          - `assumed_identity` (403) — Not while assuming an identity
          - `not_yours` (403) — That grant is not yours to hand over
          - `step_up_failed` (401) — The step-up was refused
          - `too_many_attempts` (429) — Too many step-up attempts
          - `mfa_not_enrolled` (400) — No second factor to step up with
          - `totp_required` (400) — Step up with a TOTP code
          - `mfa_required` (403) — Admins need a second factor
          - `login_v2_required` (410) — Legacy CLI tokens are no longer issued
          - `password_browser_only` (400) — Password step-up is for the browser
          - `team_not_active` (400) — Not an active member of that team
          - `team_not_found` (404) — No such team
          - `lockdown` (423) — The account is locked down
          - `capability_missing` (403) — This session cannot do that
          - `step_up_required` (401, 403) — This needs a fresh step-up
          - `unauthenticated` (401) — Sign in to do that
          - `capabilities_unavailable` (503) — Capabilities could not be resolved
          - `resource_in_other_team` (409) — The resource belongs to another team
          - `unknown_team` (404) — Unknown team
          - `access_check_unavailable` (503) — Access rules could not be checked
          - `access_denied` (403) — An access rule denied the request
          - `would_lock_you_out` (409) — That rule would lock you out
          - `invalid_rule` (400) — That access rule is not valid
          - `invalid_profile` (400) — That behaviour profile is not valid
          - `rate_limited` (429) — Rate limited
          - `recovery_failed` (400, 401) — Account recovery failed
          - `fresh_signin_required` (401) — This needs a fresh sign-in
          - `unknown_device` (401) — No device is enrolled with this key (internal)
          - `device_revoked` (401) — This device was revoked
          - `device_pending` (401) — The device is not approved yet
          - `device_suspended` (401) — The device is suspended
          - `device_key_mismatch` (401) — The device key does not match its enrolment
          - `device_mismatch` (400) — The request is for another device
          - `device_key_revoked` (403) — This device key was revoked
          - `device_key_in_use` (409) — This device key is enrolled to someone else
          - `device_key_enrolled` (409) — This device key is already enrolled
          - `device_not_found` — No such device
          - `invalid_state` — The device cannot do that in its state
          - `pairing_not_found` (404) — No pairing has that code
          - `pairing_expired` (410) — The pairing code has expired
          - `pairing_used` (409) — The pairing code has already been approved
          - `pairing_kind_mismatch` (409) — The pairing asked for another kind of session
          - `reason_required` (400) — A reason is required
          - `bad_pop` (401) — The proof of possession does not verify
          - `seal_binding_invalid` (400) — The seal binding was refused
          - `ticket_invalid` (401) — The enrolment ticket is not valid
          - `ticket_expired` (401) — The enrolment ticket has expired
          - `ticket_used` (401) — The enrolment ticket has been used
          - `certify_failed` (502) — The API did not certify the device
          - `cert_invalid` — The device certificate is not valid
          - `klock_service_unavailable` (502) — The klock service could not register the device
          - `sync_rollback` (409) — The device is behind what it acknowledged
          - `sync_seq_ahead` (409) — The device claims a sync that never happened
          - `command_invalid` — A device command does not verify
          - `feed_invalid` — The revocation feed does not verify
          - `rpc_unsigned` — The call is not signed
          - `rpc_malformed` — The call is malformed
          - `rpc_wrong_caller` — Signed by the wrong service
          - `rpc_bad_signature` — The call signature does not verify
          - `rpc_wrong_audience` — The call is for another service
          - `rpc_wrong_method` — The call is signed for another method
          - `rpc_args_mismatch` — The arguments are not the ones signed
          - `rpc_clock_skew` — The call was signed too far from now
          - `rpc_replay` — The call nonce has been used
          - `api_unavailable` (503) — The API did not answer the klock service
          - `policy_unavailable` (503) — The klock service could not read the access rules
          - `bad_request` (400) — Bad request
          - `not_found` (404) — Not found
          - `method_not_allowed` (405) — Method not allowed
          - `body_too_large` (413) — The body is too large
          - `upgrade_required` (426) — A WebSocket is required
          - `not_implemented` (501) — Not available yet
          - `internal` (500) — klock could not complete the request
          - `klock_v2_unavailable` (503) — klock v2 is not available on this API
          - `klock_unconfigured` — The klock service pair is not configured
          - `no_keyset` (503) — No keyset is published
          - `keyset_invalid` — The keyset does not verify
          - `keyset_rollback` — The keyset is older than one already seen
          - `klock_key_unpublished` — The klock service key is not in the keyset
          - `secrets_key_missing` (503) — The API cannot store secrets
          - `v1_session_unusable` (403) — That session cannot be used
          - `v1_wrong_database` (403) — That session is for a different database
          - `v1_unknown` (404) — Unknown session or database
          - `v1_signature_mismatch` (403) — The signature does not match this session
          - `v1_credential_malformed` (403) — The credential is not a v1 grant
          - `v1_grant_not_canonical` (403) — The grant is not canonical
          - `v1_window_expired` (403) — The grant’s window has passed
          - `v1_window_not_started` (403) — The grant’s window has not started
          - `v1_role_mismatch` (403) — The grant is for a different role
          - `v1_role_gone` (403) — The granted role no longer exists
          - `v1_beyond_session` (403) — Outside the session’s ceiling
          - `v1_query_invalid` (400) — Not a passwordless query
          - `v1_client_cert_required` (401) — The session needs its client certificate
          - `v1_kerberos_refused` (401) — The session needs its Kerberos ticket
          - `v1_kerberos_unavailable` (503) — The Kerberos authority is unavailable
          - `tool_not_found` (404) — No such MCP tool (or not for this session)
          - `confirmation_required` (428) — A destructive MCP tool needs confirming
          - `token_invalid` (401) — The MCP client token is not valid
          - `token_revoked` (401) — The MCP client token was revoked
          - `token_expired` (401) — The MCP client token has expired
          - `not_delegable` (400) — That capability cannot be delegated
          - `admin_not_delegable` (400) — Admin capabilities are never delegated
          - `unknown_capability` (400) — Unknown capability id
          - `mcp_unavailable` (503) — MCP forwarding is unavailable
          - `bad_arguments` (400) — Bad MCP tool arguments
          - `bad_intent` (400) — The signed MCP request is not one tool call
          - `intent_mismatch` (400) — The relayed MCP call is not the one the device signed
          - `keytab_invalid` — The delivered keytab is not this principal’s
          - `kerberos_realm_missing` (409) — The team has no Kerberos realm
          - `provider_unknown` (404) — No such credential provider
          - `provider_unavailable` (403) — The provider is not on your plan
          - `capability_required` (403) — The session lacks the capability for this credential
          - `credential_not_found` (404) — No such credential on this device
          - `credential_not_active` (409) — The credential is no longer current
          - `csr_invalid` (400, 422) — The certificate request is malformed
          - `csr_key_type` (400, 422) — The certificate key must be P-256
          - `csr_subject_mismatch` (400, 422) — The certificate request names something else
          - `csr_attestation_invalid` (401) — The device key did not vouch for this certificate key
          - `authority_unavailable` (502, 503) — The certificate authority could not sign
          - `session_required` (401) — The device has no klock session
          - `transfer_not_found` (404) — No such transfer
          - `transfer_recipient_refused` (403) — You cannot send files to that device
          - `transfer_too_large` (413) — The transfer is over the plan’s size limit
          - `transfer_quota_exceeded` (429) — File transfer quota reached
          - `transfer_expired` (410) — The transfer has expired
          - `transfer_sender_revoked` (410) — The sending device is no longer trusted
          - `transfer_manifest_invalid` (400) — The file list was refused
          - `transfer_manifest_sig_invalid` (400) — The file list is not signed by this device
          - `transfer_key_invalid` (400) — The sealed file key is malformed
          - `transfer_chunk_invalid` (400) — A chunk was refused
          - `transfer_chunk_exists` (409) — That chunk is already stored
          - `transfer_incomplete` (409) — Not every chunk has been uploaded
          - `transfer_chunk_missing` (410) — A chunk is no longer on the relay
          - `transfer_chunk_corrupt` (502) — A stored chunk was altered

        What each means and how to fix it:
        https://prod-orm.io/docs/guides/klock-errors
      enum: [signature_invalid, unsigned, signature_malformed, signature_incomplete, signature_expired, bad_signature, bad_digest, clock_skew, nonce_replayed, nonce_reused, bad_nonce, grant_expired, grant_invalid, session_revoked, session_expired, idle_timeout, session_unknown, session_frozen, session_limit, not_this_session, console_only, kind_unsupported, confirm_code_required, confirm_code_mismatch, already_rotated, rotation_proof_invalid, service_session_scope, wrong_subject, already_bound, subject_unavailable, not_bound, device_signature_required, assumed_identity, not_yours, step_up_failed, too_many_attempts, mfa_not_enrolled, totp_required, mfa_required, login_v2_required, password_browser_only, team_not_active, team_not_found, lockdown, capability_missing, step_up_required, unauthenticated, capabilities_unavailable, resource_in_other_team, unknown_team, access_check_unavailable, access_denied, would_lock_you_out, invalid_rule, invalid_profile, rate_limited, recovery_failed, fresh_signin_required, unknown_device, device_revoked, device_pending, device_suspended, device_key_mismatch, device_mismatch, device_key_revoked, device_key_in_use, device_key_enrolled, device_not_found, invalid_state, pairing_not_found, pairing_expired, pairing_used, pairing_kind_mismatch, reason_required, bad_pop, seal_binding_invalid, ticket_invalid, ticket_expired, ticket_used, certify_failed, cert_invalid, klock_service_unavailable, sync_rollback, sync_seq_ahead, command_invalid, feed_invalid, rpc_unsigned, rpc_malformed, rpc_wrong_caller, rpc_bad_signature, rpc_wrong_audience, rpc_wrong_method, rpc_args_mismatch, rpc_clock_skew, rpc_replay, api_unavailable, policy_unavailable, bad_request, not_found, method_not_allowed, body_too_large, upgrade_required, not_implemented, internal, klock_v2_unavailable, klock_unconfigured, no_keyset, keyset_invalid, keyset_rollback, klock_key_unpublished, secrets_key_missing, v1_session_unusable, v1_wrong_database, v1_unknown, v1_signature_mismatch, v1_credential_malformed, v1_grant_not_canonical, v1_window_expired, v1_window_not_started, v1_role_mismatch, v1_role_gone, v1_beyond_session, v1_query_invalid, v1_client_cert_required, v1_kerberos_refused, v1_kerberos_unavailable, tool_not_found, confirmation_required, token_invalid, token_revoked, token_expired, not_delegable, admin_not_delegable, unknown_capability, mcp_unavailable, bad_arguments, bad_intent, intent_mismatch, keytab_invalid, kerberos_realm_missing, provider_unknown, provider_unavailable, capability_required, credential_not_found, credential_not_active, csr_invalid, csr_key_type, csr_subject_mismatch, csr_attestation_invalid, authority_unavailable, session_required, transfer_not_found, transfer_recipient_refused, transfer_too_large, transfer_quota_exceeded, transfer_expired, transfer_sender_revoked, transfer_manifest_invalid, transfer_manifest_sig_invalid, transfer_key_invalid, transfer_chunk_invalid, transfer_chunk_exists, transfer_incomplete, transfer_chunk_missing, transfer_chunk_corrupt]

    PageWindow:
      type: object
      description: What a page of a paginated listing (`middleware/pagination.js`) reports about itself.
      properties:
        limit: { type: integer }
        offset: { type: integer }
        returned: { type: integer, description: Rows on this page }
        hasMore: { type: boolean }
        nextOffset: { type: integer, description: Present when hasMore }

    SessionView:
      description: A session as the sessions list shows it — the legacy view plus the klock v2 fields.
      allOf:
        - $ref: "#/components/schemas/Session"
        - type: object
          properties:
            uuid: { type: string }
            kind: { type: string, example: interactive, description: 'browser, cli-legacy, interactive, device, service, delegated, …' }
            binding: { type: string, enum: [device, bearer] }
            device:
              type: object
              nullable: true
              properties:
                id: { type: string }
                label: { type: string, nullable: true }
            caps: { type: array, nullable: true, items: { type: string } }
            limits: { type: object, nullable: true }
            checkInBy: { type: string, format: date-time, nullable: true }
            stepUpAt: { type: string, format: date-time, nullable: true }
            label: { type: string, nullable: true }
            clientName: { type: string, nullable: true }
            subject:
              type: object
              nullable: true
              description: What a service session stands for
              properties:
                kind: { type: string, enum: [passwordless, transfer-job] }
                id: { type: string }
            frozen:
              type: object
              nullable: true
              properties:
                at: { type: string, format: date-time }
                reason: { type: string, nullable: true }
            revokedReason: { type: string, nullable: true }
            rotatedFromId: { type: integer, nullable: true }

    KlockStepUpRequest:
      type: object
      description: One of the two
      properties:
        totp: { type: string, description: A six-digit code; single-use }
        password: { type: string, format: password, description: "From a browser, on an account without a second factor only" }

    KlockOpenRequest:
      type: object
      required: [devicePub]
      properties:
        kind: { type: string, enum: [interactive, device, service], default: interactive }
        devicePub: { type: string, pattern: "^[A-Za-z0-9_-]{43}$", description: 'The device''s Ed25519 signing key, b64url (32 bytes). A service session also takes it as `signPub`.' }
        sealPub: { type: string, pattern: "^[A-Za-z0-9_-]{43}$", description: "The device's sealing key; required for a service session, and whenever the klock service is bound" }
        sealBinding: { type: string, description: 'A JWS by devicePub over {deviceId, sealPub}; required with sealPub' }
        hostname: { type: string, minLength: 1, maxLength: 80, description: Printable ASCII }
        platform: { type: string, maxLength: 40 }
        label: { type: string, maxLength: 120 }
        teamUuid: { type: string, description: A team the caller is an active member of; the session speaks for it }
        state: { type: string, pattern: "^[A-Za-z0-9_-]{16,128}$", description: What `prorm login` printed; required from the console }
        confirmCode: { type: string, description: "The six-character code the terminal printed, for this state and device key" }
        stepUp: { $ref: "#/components/schemas/KlockStepUpRequest" }
        resource:
          type: object
          description: 'kind service: what it stands for'
          properties:
            kind: { type: string, enum: [database, transfer-job] }
            id: { description: The database or transfer job, oneOf: [{ type: integer }, { type: string }] }
        thumbprint: { type: string, description: 'kind service: the key''s thumbprint as the holder printed it, checked against devicePub' }
        signPub: { type: string, description: 'kind service: the same key as devicePub' }
        role: { type: string, maxLength: 255, description: 'kind service over a database: the role it speaks as' }
        scope:
          type: object
          description: 'kind service over a database'
          properties:
            ops: { type: array, minItems: 1, items: { type: string, enum: [read, write] } }
            tables: { type: array, items: { type: string } }
        entId: { type: string, maxLength: 64 }
        expiresAt: { type: string, format: date-time, description: 'kind service over a database: when it ends' }
        rotateBy: { description: 'kind service: a hard rotation deadline, unix seconds or ISO, within a year', oneOf: [{ type: integer }, { type: string, format: date-time }] }
        rotateEverySec: { type: integer, minimum: 3600, maximum: 31536000, description: 'kind service: a rotation schedule' }
        compat: { type: string, enum: [v1], description: 'kind service over a database: also hand back a v1 secret (protocol v1+v2)' }

    KlockOpenedSession:
      type: object
      properties:
        id: { type: integer, description: The session row }
        sessionId: { type: string, example: ks_4f9c0d8e-2a61-4b1f-9c3d-7e5a2b1c0d9e }
        kind: { type: string, enum: [interactive, device, service] }
        grant: { type: string, description: 'The API-signed grant (compact JWS): `Authorization: Klock <grant>`' }
        grantSeq: { type: integer }
        deviceId: { type: string }
        deviceCert: { type: string, description: The device certificate (compact JWS) }
        keyset: { type: string, description: The root-signed keyset (compact JWS) }
        keysetSeq: { type: integer }
        tier: { type: string }
        caps: { type: array, items: { type: string } }
        dormant: { type: array, items: { type: string }, description: Capabilities a step-up wakes (not on a service session) }
        limits: { type: object, description: 'The kind''s limits: grant TTL, check-in interval, idle timeout, step-up TTL, …' }
        checkInBy: { type: integer, description: Unix seconds }
        exp: { type: integer, description: Unix seconds; when the grant expires }
        expiresAt: { type: string, format: date-time, nullable: true, description: When the session itself ends (not on a service session) }
        resource: { type: object, nullable: true, description: 'Service session: what it stands for' }
        rotateBy: { type: string, format: date-time, nullable: true, description: 'Service session: the rotation deadline' }
        passwordless: { type: object, description: 'Service session over a database: the passwordless session it created' }
        job: { type: object, description: 'Service session over a transfer job: { id, protocol: v2 }' }
        secret: { type: string, description: "Service session with compat v1: the v1 secret, shown once" }
        secretSeenOnce: { type: boolean }

    UpdateMeResult:
      type: object
      properties:
        user: { $ref: "#/components/schemas/User" }
        token: { type: string, description: "A fresh token, when the password changed (every other session was ended)" }
        tokenType: { type: string, enum: [Bearer], description: With `token` }
        expiresIn: { type: string, description: "With `token`, e.g. \"1h\"" }

    CapabilitiesView:
      type: object
      description: What the caller may do right now (`GET /me/capabilities`).
      properties:
        tier: { type: string, enum: [guest, user, team, admin] }
        caps: { type: array, items: { type: string }, description: "The live capabilities, sorted" }
        dormant: { type: array, items: { type: string }, description: What a fresh step-up would make live }
        teamRole: { type: string, nullable: true, description: "The role in the team context, if any" }
        teams:
          type: array
          description: The caller's active teams
          items:
            type: object
            properties:
              uuid: { type: string }
              slug: { type: string, nullable: true }
              name: { type: string, nullable: true }
              role: { type: string, enum: [owner, admin, billing, security, operator, developer, deployer, analyst, support, auditor, viewer, guest] }
              caps: { type: array, items: { type: string }, description: What the role carries }
        admin:
          type: object
          nullable: true
          properties:
            type: { type: string }
            scopes: { type: array, items: { type: string } }
        plan:
          type: object
          properties:
            name: { type: string }
            entitlements: { type: object, description: "The plan's entitlements, from the capability catalog" }
        limits:
          type: object
          properties:
            byKind:
              type: object
              description: Session limits by session kind (browser, cli-legacy, interactive, device, service, delegated; guest for a guest)
              additionalProperties: { $ref: "#/components/schemas/SessionLimits" }
        session:
          type: object
          nullable: true
          properties:
            kind: { type: string }
            binding: { type: string, enum: [device, bearer] }
            stepUpAt: { type: integer, nullable: true, description: Unix seconds }
            checkInBy: { nullable: true }
            deviceId: { type: string, nullable: true }
        source: { type: string, enum: [guest, apikey, bearer, klock] }
        resourceLock: { nullable: true, description: "For an api key, the resource it is locked to" }
        capabilitiesVersion: { type: string }

    SessionLimits:
      type: object
      properties:
        maxDurationSec: { type: integer, nullable: true }
        idleTimeoutSec: { type: integer, nullable: true }
        concurrent: { type: integer, nullable: true, description: Live sessions of this kind per account }
        evict: { type: string, enum: [lru, refuse] }
        checkInEverySec: { type: integer }
        grantTtlSec: { type: integer }
        stepUpTtlSec: { type: integer, nullable: true }
        rotateEverySec: { type: integer, nullable: true }
        rotateBy: { type: integer, nullable: true }
        usage:
          type: object
          properties:
            requestsPerHour: { type: integer }
            rowsReadPerHour: { type: integer }
            transferBytesPerDay: { type: integer }
            newSessionsPerIpPerHour: { type: integer }

    Device:
      type: object
      description: A klock device as the API sees it — public material only. Devices live on the klock service.
      properties:
        id: { type: string, example: dev_7fK2mQ }
        userUuid: { type: string, nullable: true }
        teamUuid: { type: string, nullable: true }
        kind: { type: string, enum: [interactive-host, device, service, guest] }
        label: { type: string, nullable: true }
        hostname: { type: string, nullable: true }
        keyStore: { type: string, nullable: true, description: "Where the device keeps its private key, as it said at enrolment" }
        platform: { type: string, nullable: true }
        agentVersion: { type: string, nullable: true }
        thumbprint: { type: string, description: The signing key's thumbprint }
        signPub: { type: string }
        sealPub: { type: string }
        sealBinding: { type: string }
        deviceCert: { type: string, nullable: true }
        status: { type: string, enum: [pending, active, suspended, revoked, wiped] }
        enrolledVia: { type: string, example: pair }
        sessionId: { type: string, nullable: true }
        syncSeq: { type: integer }
        lastSyncAt: { type: string, format: date-time, nullable: true }
        lastSyncCountry: { type: string, nullable: true }
        providers: { type: object, nullable: true, description: "Installed credential serials, as the device reports them" }
        wipeRequestedAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }
        revokeReason: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    DeviceListed:
      description: A device in a list — without `deviceCert` and `sealBinding` — with its owner.
      allOf:
        - $ref: "#/components/schemas/Device"
        - type: object
          properties:
            current: { type: boolean, description: The device asking (not in staff listings) }
            owner:
              type: object
              nullable: true
              properties:
                id: { type: integer }
                username: { type: string }

    DeviceTeamRef:
      type: object
      properties:
        id: { type: integer }
        uuid: { type: string }
        slug: { type: string, nullable: true }

    DeviceCommand:
      type: object
      description: An API-signed command queued for the device's next sync.
      properties:
        id: { type: string, example: cmd_Zk1x9 }
        type: { type: string, enum: [wipe, rotate-key] }
        issuedAt: { type: integer, description: Unix seconds }

    DeviceAuditLine:
      type: object
      properties:
        id: { type: integer }
        at: { type: string, format: date-time }
        action: { type: string }
        ok: { type: boolean }
        level: { type: string }
        category: { type: string, nullable: true }
        ip: { type: string, nullable: true }
        detail: { type: object }
        actorId: { type: integer, nullable: true }

    DeviceActionRequest:
      type: object
      properties:
        team: { type: string, description: "A team's id, uuid or slug, to act on a member's device" }
        reason:
          type: string
          maxLength: 500
          description: Required when acting on someone else's device (kept in their audit log; they are told)

    StaffActionRequest:
      type: object
      required: [reason]
      properties:
        reason: { type: string, minLength: 10, maxLength: 500 }
        stepUp: { $ref: "#/components/schemas/StepUpProof" }

    ManagedSession:
      type: object
      description: A session as the sessions and devices views show it.
      properties:
        id: { type: integer }
        uuid: { type: string }
        userAgent: { type: string, nullable: true }
        ip: { type: string, nullable: true }
        lastSeenAt: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }
        current: { type: boolean }
        assumed:
          type: object
          nullable: true
          properties:
            actorId: { type: integer }
            mode: { type: string, enum: [read, write] }
        kind: { type: string, enum: [browser, cli-legacy, interactive, device, service, delegated, guest, handoff, assumed] }
        binding: { type: string, enum: [device, bearer] }
        device:
          type: object
          nullable: true
          properties:
            id: { type: string }
            label: { type: string, nullable: true }
        caps: { type: array, items: { type: string }, nullable: true }
        limits: { type: object, nullable: true }
        checkInBy: { nullable: true }
        stepUpAt: { nullable: true }
        label: { type: string, nullable: true }
        clientName: { type: string, nullable: true }
        subject:
          type: object
          nullable: true
          description: What a service session stands for
          properties:
            kind: { type: string, enum: [passwordless, transfer-job] }
            id: {}
        frozen:
          type: object
          nullable: true
          properties:
            at: { type: string, format: date-time }
            reason: { type: string, nullable: true }
        revokedReason: { type: string, nullable: true }
        rotatedFromId: { type: integer, nullable: true }
        owner:
          type: object
          description: In team and staff views
          properties:
            id: { type: integer }
            username: { type: string }

    SessionRevoked:
      type: object
      properties:
        session: { $ref: "#/components/schemas/ManagedSession" }
        revoked: { type: boolean, description: False when it had already ended }

    AccessRule:
      type: object
      properties:
        id: { type: string, format: uuid }
        uuid: { type: string, format: uuid }
        scopeKind: { type: string, enum: [account, team, database, store, device] }
        scopeId: { type: string }
        scope:
          type: object
          properties:
            kind: { type: string, enum: [account, team, database, store, device] }
            id: { type: string }
        effect: { type: string, enum: [allow, block] }
        dimension: { type: string, enum: [ip, country, asn, device, cert, principal, time, client, api-key] }
        value: { description: The value matched; a JSON time window for `time` }
        appliesTo: { type: array, nullable: true, items: { type: string }, description: Capability patterns the rule is limited to }
        mandatory: { type: boolean }
        mode: { type: string, enum: [enforce, report], description: The mode now }
        enforceAt: { type: string, format: date-time, description: Present while a new rule is in its first day of reporting }
        note: { type: string, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        enabled: { type: boolean }
        createdAt: { type: string, format: date-time, nullable: true }
        createdBy: { type: integer, nullable: true }
        lockdown: { type: boolean, enum: [true], description: Present on a lockdown rule }
        legacy: { type: string, enum: [blockedIps], description: Present on a rule mirrored from a database's legacy blockedIps }

    AccessRuleRequest:
      type: object
      required: [effect, dimension, value]
      properties:
        scopeKind:
          type: string
          enum: [account, team, database, store, device]
          default: account
          description: Ignored on the team route, where the scope is the team.
        scopeId: { type: string, description: "The team, resource (id, uuid or name) or device; not needed for account" }
        effect: { type: string, enum: [allow, block] }
        dimension: { type: string, enum: [ip, country, asn, device, cert, principal, time, client, api-key] }
        value:
          description: At most 500 characters stored. For `time`, a time window (an object, or its JSON).
          oneOf:
            - { type: string }
            - { type: number }
            - { type: object }
        appliesTo:
          description: Capability patterns the rule is limited to (a list, or a comma-separated string)
          oneOf:
            - { type: array, items: { type: string } }
            - { type: string }
        mode: { type: string, enum: [report, enforce], description: "Omit for a day of reporting, then enforcing" }
        enforceNow: { type: boolean }
        expiresAt: { type: string, format: date-time, description: In the future }
        note: { type: string, maxLength: 200 }
        mandatory: { type: boolean, description: "Defaults to true for a team rule, false otherwise" }
        force: { type: boolean, description: Save it even though it would lock you out (needs a step-up) }
        totp: { type: string, description: "A step-up, when forcing without a fresh one" }
        password: { type: string, format: password, description: "The browser's step-up, on an account without a second factor" }

    BehaviourProfile:
      type: object
      properties:
        id: { type: string, format: uuid }
        uuid: { type: string, format: uuid }
        scopeKind: { type: string, enum: [account, team, device, session, service] }
        scopeId: { type: string }
        scope:
          type: object
          properties:
            kind: { type: string, enum: [account, team, device, session, service] }
            id: { type: string }
        name: { type: string }
        preset: { type: string, nullable: true }
        spec: { type: object, additionalProperties: true }
        mode: { type: string, enum: [enforce, report] }
        version: { type: integer }
        createdAt: { type: string, format: date-time, nullable: true }
        updatedAt: { type: string, format: date-time, nullable: true }

    BehaviourProfileRequest:
      type: object
      properties:
        scopeKind:
          type: string
          enum: [account, team, device, session, service]
          default: account
          description: Ignored on the team route.
        scopeId: { type: string, description: "The team, device, or session (jti or id); not needed for account" }
        name: { type: string, maxLength: 120, description: Defaults to the spec's name (or the preset's) }
        preset: { type: string, example: ci-service, description: "A preset's name (`preset:` prefix allowed)" }
        spec:
          description: The profile (an object, or its JSON). Omit it to use the preset as it is.
          oneOf:
            - { type: object, additionalProperties: true }
            - { type: string }
        mode: { type: string, enum: [report, enforce], default: report }
        totp: { type: string, description: "A step-up, to change an enforcing profile" }
        password: { type: string, format: password }

    BehaviourProfileSaved:
      type: object
      properties:
        profile: { $ref: "#/components/schemas/BehaviourProfile" }
        created: { type: boolean }

    BehaviourFinding:
      type: object
      properties:
        id: { type: string, format: uuid }
        profileId: { type: integer, nullable: true }
        signal:
          type: string
          example: location.new-country
          description: |
            `location.new-country`, `location.new-asn`, `location.outside`,
            `impossible-travel`, `hours.outside`, `device.unknown`,
            `device.new`, `device.file-key`, `client.unexpected`,
            `cap.outside-profile`, `rate.exceeded`, `volume.exceeded`,
            `agent.tamper`, `sync.stale`, `sync.rollback`
        severity: { type: string }
        action: { type: string, enum: [log, alert, step-up, rate-limit, freeze, revoke] }
        sessionRef: { type: string, nullable: true }
        deviceId: { type: string, nullable: true }
        data: { type: object, additionalProperties: true }
        acknowledgedAt: { type: string, format: date-time, nullable: true }
        acknowledgedBy: { type: integer, nullable: true }
        createdAt: { type: string, format: date-time, nullable: true }

    BehaviourFindingAck:
      type: object
      properties:
        finding: { $ref: "#/components/schemas/BehaviourFinding" }
        acknowledged: { type: boolean, enum: [true] }

    StepUpProof:
      type: object
      description: |
        A step-up made in the request itself, when the session has no fresh
        one: a TOTP code, or — from the browser, on an account without a second
        factor — the password.
      properties:
        totp: { type: string }
        password: { type: string, format: password }

    Removed:
      type: object
      properties:
        id: { type: string, format: uuid }
        removed: { type: boolean, enum: [true] }

    MemberLockdownRequest:
      type: object
      required: [reason]
      properties:
        reason: { type: string, maxLength: 200, description: The member is told it }

    McpClient:
      type: object
      description: A delegated MCP client token as the console sees it — never the token.
      properties:
        id: { type: string, format: uuid }
        clientId: { type: string, format: uuid, nullable: true }
        clientName: { type: string, nullable: true }
        label: { type: string, nullable: true }
        caps: { type: array, items: { type: string } }
        resource:
          type: object
          nullable: true
          properties:
            kind: { type: string, enum: [database, store] }
            id: { type: integer }
        teamId: { type: integer, nullable: true }
        createdAt: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        lastSeenAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }
        revokedReason: { type: string, nullable: true, example: user }

    McpDelegatedView:
      type: object
      properties:
        tier: { type: string, enum: [user] }
        caps: { type: array, items: { type: string }, description: Never `admin:*` }
        dormant: { type: array, items: { type: string }, maxItems: 0 }
        session:
          type: object
          properties:
            kind: { type: string, enum: [delegated] }
            binding: { type: string, enum: [bearer] }
            expiresAt: { type: string, format: date-time, nullable: true }
        client:
          type: object
          nullable: true
          properties:
            id: { type: string, nullable: true }
            name: { type: string, nullable: true }
        resource:
          type: object
          nullable: true
          properties:
            kind: { type: string, enum: [database, store] }
            id: { type: integer }
        tools: { type: array, items: { type: string }, description: "The registry tools it may use, in registry order" }
        capabilitiesVersion: { type: string }

    McpToolCall:
      type: object
      required: [tool]
      properties:
        tool: { type: string, example: prorm_list_databases }
        arguments:
          type: object
          additionalProperties: true
          description: The tool's arguments; `confirm` carries a confirmation token for a destructive tool.

    McpForwardRequest:
      type: object
      required: [grant, headers, body]
      properties:
        grant: { type: string, description: The device session's grant (what followed `Klock ` in its Authorization) }
        url: { type: string, format: uri, description: "The URL the device signed; an MCP origin's `/mcp` or `/mcp/account`, no query. Defaults to the first origin's `/mcp`." }
        headers:
          type: object
          description: |
            The signed request's headers. Only `signature`, `signature-input`
            (both required), `content-digest`, `klock-client`,
            `idempotency-key` and `x-prorm-team` are read.
          additionalProperties: { type: string }
        body: { type: string, format: byte, description: Base64 of the exact JSON-RPC `tools/call` bytes the device signed }
        tool: { type: string, description: The Worker's reading of the tool; must match the signed one }
        arguments: { type: object, description: The Worker's reading of the arguments; must match the signed ones }

    PasswordlessScope:
      type: object
      required: [ops]
      properties:
        ops:
          type: array
          minItems: 1
          items: { type: string, enum: [read, write] }
        tables:
          type: array
          items: { type: string }
          description: Absent means every table

    RoleScope:
      type: object
      required: [ops]
      properties:
        ops:
          type: array
          minItems: 1
          items: { type: string, enum: [read, write] }
        tables: { type: array, items: { type: string } }
        columns:
          type: object
          description: A table name (one of `tables`, when given) to the columns it permits
          additionalProperties: { type: array, items: { type: string } }

    PasswordlessSession:
      type: object
      properties:
        id: { type: string, format: uuid }
        role: { type: string }
        entId: { type: string, nullable: true }
        scope: { $ref: "#/components/schemas/RoleScope" }
        label: { type: string, nullable: true }
        auth: { type: string, enum: [secret, tls, kerberos] }
        principal: { type: string, nullable: true, description: The certificate's or Kerberos principal }
        tlsFingerprint: { type: string, nullable: true }
        poolId: { type: integer, nullable: true }
        lastUsedAt: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }

    PasswordlessTlsMaterial:
      type: object
      description: "Present for `auth: tls` — the client certificate, shown once"
      properties:
        ca: { type: string }
        cert: { type: string }
        key: { type: string }

    PasswordlessKerberosMaterial:
      type: object
      description: "Present for `auth: kerberos` — the principal and its keytab, shown once"
      properties:
        realm: { type: string }
        principal: { type: string }
        service: { type: string }
        kdc: { type: string }
        keytab: { type: string }

    ServiceDeviceKeys:
      type: object
      description: A holder's device keys, and the proof it holds the signing key.
      required: [sealPub, sealBinding]
      properties:
        devicePub: { type: string, description: "The Ed25519 signing key, b64url of 32 bytes. Required (or `signPub`)." }
        signPub: { type: string, description: "The same key, under the name `prorm klock upgrade` sends" }
        thumbprint: { type: string, description: "The key's thumbprint as the holder printed it, checked when given" }
        sealPub: { type: string, description: "The sealing key, b64url of 32 bytes" }
        sealBinding: { type: string, description: A JWS by the signing key vouching for the sealing key }
        hostname: { type: string, maxLength: 80 }
        platform: { type: string, maxLength: 40 }
        label: { type: string, maxLength: 120 }
        rotateBy: { description: "A hard key-rotation deadline, unix seconds or ISO date, within a year", oneOf: [{ type: integer }, { type: string, format: date-time }] }
        rotateEverySec: { type: integer, minimum: 3600, maximum: 31536000 }

    ServiceSession:
      type: object
      description: |
        A klock v2 service session (kind `service`) bound to a device key,
        standing for one subject and holding only its capability
        (`passwordless:use`). Its requests are device-signed; its grant is
        good for its subject's data plane and its own check-in and rotation.
      properties:
        id: { type: integer }
        sessionId: { type: string, example: ks_… }
        kind: { type: string, enum: [service] }
        grant: { type: string, description: The API-signed grant (JWS) }
        grantSeq: { type: integer }
        deviceId: { type: string }
        deviceCert: { type: string }
        keyset: { type: object, description: The published keyset document (absent on a rebind) }
        keysetSeq: { type: integer }
        tier: { type: string }
        caps: { type: array, items: { type: string } }
        limits: { type: object }
        checkInBy: { type: integer, description: Unix seconds }
        exp: { type: integer, description: Unix seconds }
        resource:
          type: object
          nullable: true
          properties:
            kind: { type: string, enum: [database, transfer-job] }
            id: { type: string }
        rotateBy: { type: string, format: date-time, nullable: true }

    DatabaseRole:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        scope: { $ref: "#/components/schemas/RoleScope" }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        holders:
          type: object
          description: Live keys out (absent on a deleted role)
          properties:
            grants: { type: integer, description: Passwordless sessions }
            keys: { type: integer, description: Api keys }

    TransferEndpoint:
      type: object
      description: One end of a transfer, as the Migrate library describes it (`kind` plus what that kind needs — a location, a region, data types).
      required: [kind]
      properties:
        kind: { type: string }
      additionalProperties: true

    TransferJobRequest:
      type: object
      required: [strategyId, source, dest]
      properties:
        strategyId: { type: string, description: One of `GET /transfer/strategies` }
        source: { $ref: "#/components/schemas/TransferEndpoint" }
        dest: { $ref: "#/components/schemas/TransferEndpoint" }
        bytes: { type: integer, minimum: 0 }
        safe: { type: boolean, description: Back up the destination first }
        backupDays: { type: integer, minimum: 1, maximum: 365 }
        requiredControls: { type: array, items: { type: string }, description: Controls the strategy must provide }
        teamId: { type: integer, description: Make it the team's job (create only) }
        tables: { type: array, items: { type: string } }
        regimes: { type: array, items: { type: string }, description: "Data-localisation regimes to hold it to (create only)" }
        schedule: { type: string, description: A recurring schedule; the job re-arms after each run }
        execution: { type: string, enum: [cloud, local], default: cloud }
        klock:
          type: string
          enum: [v1, v2]
          default: v1
          description: "A local job's report protocol (create only): v1 hands back a secret once; v2 binds an on-device agent's service session"

    TransferQuote:
      type: object
      properties:
        strategyId: { type: string }
        tier: { type: string, enum: [free, pro, premium] }
        scope: { type: string }
        scopeReasons: { type: array, items: { type: string } }
        execution: { type: string, enum: [cloud, local] }
        direction: { type: string, example: cloud-cloud }
        schedule: { type: string, nullable: true }
        safe: { type: boolean }
        cost:
          type: object
          description: The priced cost; `totalCents` is what `/start` asks to confirm. A local run carries `basis` and `flatBytes` when the local flat rate applies.
          properties:
            totalCents: { type: integer }
          additionalProperties: true

    TransferStrategy:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        group: { type: string }
        transport: { type: string }
        risk: { type: string }
        minTier: { type: string, enum: [free, pro, premium] }
        controls: { type: array, items: { type: string } }
        parallel: { type: boolean }
        resumable: { type: boolean }
        managed: { type: boolean }
        notes: { type: string }

    TransferJob:
      type: object
      properties:
        id: { type: string, format: uuid }
        strategyId: { type: string }
        scope: { type: string }
        execution: { type: string, enum: [cloud, local] }
        direction: { type: string, example: cloud-cloud }
        schedule: { type: string, nullable: true }
        nextRunAt: { type: string, format: date-time, nullable: true }
        lastRunAt: { type: string, format: date-time, nullable: true }
        safe: { type: boolean }
        source: { $ref: "#/components/schemas/TransferEndpoint" }
        dest: { $ref: "#/components/schemas/TransferEndpoint" }
        tables: { type: array, items: { type: string } }
        bytes: { type: integer }
        meteredBytes: { type: integer, description: What klock relay receipts metered for this run }
        cost: { type: object, nullable: true, additionalProperties: true }
        projectedCents: { type: integer, nullable: true, description: The price to confirm at start }
        finalCents: { type: integer, nullable: true }
        confirmedAt: { type: string, format: date-time, nullable: true }
        billingMode: { type: string, enum: [immediate, deferred] }
        billedAt: { type: string, format: date-time, nullable: true }
        teamId: { type: integer, nullable: true }
        status:
          type: string
          enum: [created, queued, backing-up, running, verifying, completed, failed, restoring, restored, cancelled]
        manifestDigest: { type: string, nullable: true }
        certificate:
          type: object
          nullable: true
          description: Signed by the job's transfer klock on completion; `attestation` is platform, relay-metered or self-attested
          additionalProperties: true
        error: { type: string, nullable: true }
        log:
          type: array
          items:
            type: object
            properties:
              at: { type: string, format: date-time }
              status: { type: string }
              message: { type: string }
        startedAt: { type: string, format: date-time, nullable: true }
        finishedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }

    TransferJobAccess:
      type: object
      description: How the caller reaches a job
      properties:
        via: { type: string, example: team }
        teamUuid: { type: string, format: uuid }
        role: { type: string }

    Pool:
      type: object
      properties:
        id: { type: string, format: uuid }
        label: { type: string }
        state: { type: string, example: active }
        maxConnections: { type: integer }
        active: { type: integer, nullable: true, description: Requests in flight now }
        shared: { type: boolean, nullable: true, description: False when the count is this instance's alone }
        requests: { type: integer }
        peak: { type: integer }
        lastUsedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        closedAt: { type: string, format: date-time, nullable: true }

    PoolAllowance:
      type: object
      description: Pools are counted per owner — the account, or the team for a team-owned database.
      properties:
        used: { type: integer }
        max: { type: integer }
        remaining: { type: integer }
        owner: { type: string, enum: [user, team] }

    BillingSubscription:
      type: object
      properties:
        id: { type: integer }
        plan: { type: string }
        status: { type: string, example: active }
        currentPeriodEnd: { type: string, format: date-time, nullable: true }
        cancelAtPeriodEnd: { type: boolean }
        trialEndsAt: { type: string, format: date-time, nullable: true }

    Invoice:
      type: object
      description: Amounts are integers in the currency's smallest unit.
      properties:
        id: { type: integer }
        number: { type: string, description: Stripe's invoice id }
        amountDue: { type: integer }
        amountPaid: { type: integer }
        currency: { type: string }
        status: { type: string }
        url: { type: string, format: uri, nullable: true }
        paidAt: { type: string, format: date-time, nullable: true }

    BillingContact:
      type: object
      nullable: true
      properties:
        id: { type: integer }
        username: { type: string, nullable: true }
        email: { type: string, format: email, nullable: true }
        isOwner: { type: boolean }

    PaymentMethod:
      type: object
      properties:
        id: { type: string, example: pm_1Nxyz }
        type: { type: string, example: card }
        brand: { type: string, nullable: true }
        last4: { type: string, nullable: true }
        expMonth: { type: integer, nullable: true }
        expYear: { type: integer, nullable: true }
        isDefault: { type: boolean }

    UpcomingCharges:
      type: object
      properties:
        lines:
          type: array
          items:
            type: object
            properties:
              key: { type: string, example: "db:1:vcpu" }
              description: { type: string }
              amount: { type: integer, description: Cents }
        total: { type: integer, description: "Cents, across every database" }
        membership: { type: integer, description: Cents }
        currency: { type: string }
        member: { type: boolean }

    TeamBilling:
      type: object
      properties:
        teamId: { type: integer }
        plan: { $ref: "#/components/schemas/Plan" }
        subscription: { allOf: [{ $ref: "#/components/schemas/BillingSubscription" }], nullable: true }
        customer: { type: boolean, description: Whether the team has a Stripe customer yet }
        contact: { $ref: "#/components/schemas/BillingContact" }
        transferBilling: { type: string, enum: [immediate, deferred] }
        pools:
          type: object
          properties:
            used: { type: integer }
            max: { type: integer }
            remaining: { type: integer }
        estimate: { type: object, description: "The team's billable resources priced for the month (`pricing.accountQuote`)", additionalProperties: true }
        configured: { type: boolean, description: Whether billing is configured on this deployment }

    TeamKerberosRealm:
      type: object
      description: A team's realm. On create, `team` names the team; a read answers the realm's public description.
      properties:
        team: { type: string, description: The team's uuid (create only) }
        realm: { type: string }
        kdc: { type: string }
        service: { type: string }
        ca: { type: string }

    TeamSsoDomain:
      type: object
      properties:
        domain: { type: string, example: example.com }
        state: { type: string, enum: [pending, verified] }
        challenge:
          type: object
          properties:
            type: { type: string, enum: [TXT] }
            name: { type: string, example: _prorm-challenge.example.com }
            value: { type: string, example: prorm-domain-verification=4f1c… }
        verifiedAt: { type: string, format: date-time, nullable: true }
        lastCheckedAt: { type: string, format: date-time, nullable: true }
        lastError: { type: string, nullable: true }

    TeamSsoResponse:
      type: object
      properties:
        sso:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/TeamSso"
            - type: object
              properties:
                domainClaims: { type: array, items: { $ref: "#/components/schemas/TeamSsoDomain" } }
        callbackUrl: { type: string, format: uri, description: Register this with the provider as the redirect URI }
        startUrl: { type: string, format: uri, description: Where a sign-in for this team begins }

    StoreConnection:
      type: object
      properties:
        engine: { type: string }
        transport: { type: string, enum: [vpc, https, endpoint] }
        service: { type: string, enum: [firestore, bigquery, gcs] }
        host: { type: string }
        port: { type: integer }
        auth: { type: boolean }
        dsn: { type: string, example: "redis://:<auth>@10.0.0.3:6379" }
        database: { type: string }
        dataset: { type: string }
        bucket: { type: string }
        endpoint: { type: string }
        container: { type: string, nullable: true }
        note: { type: string }

    BatchFailure:
      type: object
      properties:
        error: { type: string }
        failedAt: { type: integer, description: The index that failed }
        completed:
          type: array
          description: What already succeeded, in order
          items: { type: object }

    ProxyStatus:
      type: object
      properties:
        pool:
          type: object
          properties:
            open: { type: integer }
            max: { type: integer }
            idleMs: { type: integer }
            databases:
              type: array
              items:
                type: object
                properties:
                  databaseId: { type: integer }
                  borrowed: { type: integer }
                  idleFor: { type: integer, description: ms }
        breakers:
          type: array
          items:
            type: object
            properties:
              databaseId: { type: integer }
              state: { type: string, enum: [closed, open, half-open] }
              failures: { type: integer }
              retryInMs: { type: integer }
