openapi: 3.1.0
info:
  title: CyberDiner Expo API
  version: 0.4.0
  description: |
    The CyberDiner expeditor (expo) API manages users, jobs, providers, models,
    requests, and device authorization for the distributed inference marketplace.

    This document is generated straight from `apps/expo/src/main.rs`'s router —
    expo embeds this file at compile time and serves it as JSON from
    `GET /openapi.json`, so the two can never quietly diverge. A CI job
    additionally regenerates `src/generated/api-types.ts` from this file and
    fails the build on any diff (see `packages/api-contract/API-CONTRACT.md`).

    ## Version notes

    - **0.4.0** — the last three reads the old consumer app still took
      straight from SpaceTimeDB are now REST (issue #467): `GET
      /beta/tier-configs` and `GET /beta/tier-configs/{name}` (new — a
      config-static catalog, no store behind it), and `GET
      /beta/account-keys/{id}`, which returns the full public key the list
      endpoint masks. Additive only. Models already had a documented read
      surface; it now falls back to expo's compiled-in catalog when the model
      registry is unreachable, so the shape here is unchanged but the endpoint
      no longer 5xxes on a registry outage.
    - **0.3.0** — full-surface audit (issue #444): documented every route
      actually registered on `main` — `/beta/alpha/status`, `/beta/quota`,
      `/beta/kredits/me`, `/beta/account-keys*`, `/beta/providers/gateway`,
      `/beta/consumer/gateway`, `/beta/network/stats`, `/beta/metrics/me`,
      `/beta/metrics/network`, `/beta/admin/status`, `/beta/providers/challenge*`,
      `/beta/device/deny` — and corrected several response shapes that had
      drifted from the wire (see `CreateJobRequest`/`JobResponse`'s `priority`
      field, `TierName`/`ParameterSize` enum values, `DeviceCodeResponse`'s
      casing, and the `201 Created` status on every `create*` operation).
    - **0.2.0** — added `POST /beta/device/refresh`: device sessions now slide
      their 24h expiry forward instead of forcing a re-pair. Additive only;
      `/beta/device/token` already returns `expiresAt` on completion, which is
      what a client schedules its refresh against.
    - **0.1.0** — initial beta surface.

    ## Authentication

    All endpoints under `/beta` require authentication unless noted otherwise
    (`x-auth-level: public`). Three authentication schemes are in play:

    - **Clerk JWT** — Bearer token issued by Clerk (web & desktop apps)
    - **CyberDiner API Key** — Bearer token with `ck_` prefix (programmatic access)
    - **Device session** — Bearer token with `device_session_` prefix, minted by
      the device authorization flow (`/beta/device/*`) and refreshed via
      `POST /beta/device/refresh`
    - **Provider session** — Bearer token minted by the Ed25519 challenge/verify
      flow (`/beta/providers/challenge*`), used on the provider-facing routes
    - **WebSocket subprotocol bearer** — the two `*/gateway` WS upgrade endpoints
      accept the same bearer credential via `Sec-WebSocket-Protocol: bearer, <token>`
      for browser clients that cannot set an `Authorization` header on the
      upgrade handshake
    - **Admin secret** — `GET /beta/admin/status` uses a wholly separate scheme:
      `X-Admin-Secret: <value>` matched against the `ADMIN_SECRET` env var, not
      layered on the auth middleware above at all

    ## Envelope

    Most successful responses are wrapped in `{ "data": <payload> }`, and most
    error responses use `{ "error": { "code": "<ERROR_CODE>", "message":
    "<human-readable>" } }` — but this is a convention, not a framework
    guarantee, and a few endpoints deliberately don't follow it: `GET
    /beta/network/stats`, `GET /beta/metrics/me`, `GET /beta/metrics/network`,
    and `GET /beta/admin/status` return their payload unwrapped at the top
    level, and the provider challenge/verify pair (`POST
    /beta/providers/challenge`, `POST /beta/providers/challenge/verify`) do too.
    Each operation below documents its actual response shape — check the
    schema, not this paragraph, before generating a client.

    ## Casing is inconsistent by design

    Most request/response structs opt into `#[serde(rename_all = "camelCase")]`,
    but several do not and serialize their Rust field names as-is — which are
    snake_case. There is no single rule; each schema below reflects the actual
    wire format of its endpoint. Notably snake_case: `AlphaQuotaResponse`,
    `KreditsMeResponse` (and its ledger entries), `NetworkStatsSnapshot`,
    `MeStatsResponse`, `NetworkMetricsResponse`, `AdminStatusResponse`,
    `ProviderChallengeResponse`/`ProviderChallengeVerifyResponse`, and
    `DeviceCodeResponse`. A client generated from this spec must not assume
    camelCase globally.

    ## Reliability contract

    - `POST /beta/jobs` is **not idempotent** — retrying it on a timeout or
      connection error submits a second job. A client that cannot confirm
      whether its original request landed should list `GET /beta/jobs`
      (newest first) rather than blindly retry the `POST`.
    - Every `GET` in this document is safe to retry and safe to poll —
      including `GET /beta/jobs/{id}` for job status, which is the
      recommended way to track a submitted job's progress alongside (or
      instead of) the `order:{id}` WebSocket topic.
    - `POST /beta/jobs/{id}/result`, `POST /beta/providers/{id}/heartbeat`, and
      `PUT /beta/providers/{id}/status` are idempotent from the caller's
      perspective: re-sending the same report/heartbeat/status after a network
      error is safe and produces the same resulting state (heartbeats and
      status updates are last-write-wins; a duplicate terminal result report
      for an already-terminal job is rejected with `409 Conflict` rather than
      double-applying).
    - `POST /beta/device/refresh` swaps the presented token for a new one
      atomically — retries after a `200` respond with the *old* (now-dead)
      token and must not be retried; retries after a network error before any
      response arrived are safe, since the old token is still live until a
      swap actually completes.
    - A `503 STORE_UNAVAILABLE` means expo could not reach a backing store —
      it is always safe to retry with backoff, and it never means "you are
      unauthenticated" or "that resource does not exist" even on an endpoint
      that would otherwise 401/404 for those cases.
  contact:
    name: CyberDiner
  license:
    name: Proprietary

servers:
  - url: http://localhost:3000
    description: Local development

tags:
  - name: system
    description: Health and metadata
  - name: users
    description: User identity and profile
  - name: jobs
    description: Inference job lifecycle
  - name: alpha
    description: Closed-alpha access gate and per-user quota state
  - name: kredits
    description: Kredit ledger (usage accrual)
  - name: api-keys
    description: API key management
  - name: account-keys
    description: Client-side Ed25519 account keys (ADR-004) used for provider auth
  - name: providers
    description: Compute provider registration and management
  - name: consumer
    description: Consumer-side WebSocket state gateway
  - name: models
    description: Model registry
  - name: tier-configs
    description: Tier catalog — pricing, copy, and the model each tier routes to
  - name: organizations
    description: Organization management
  - name: requests
    description: High-level inference requests
  - name: device-auth
    description: Device authorization flow (Cook app pairing)
  - name: network
    description: Network-wide aggregate stats (recomputed on read)
  - name: metrics
    description: Metrics-pipeline snapshots (read as-written by the aggregation service)
  - name: admin
    description: Operator-only status surface, gated by a shared secret rather than user auth

security:
  - bearer: []

paths:
  # ── System ──────────────────────────────────────────────────────────────
  /health:
    get:
      operationId: healthCheck
      summary: Health check / readiness probe
      description: >-
        Doubles as the readiness probe the load balancer polls. From the moment
        the process receives SIGTERM until it exits it answers 503 with
        `{"status":"draining"}`, so the instance leaves rotation while it
        finishes the requests it already has. A 503 here is a normal state
        during a deploy, not an incident — and never a statement about any
        job's state. See `docs/api.md` for the client retry contract.
      tags: [system]
      security: []
      responses:
        "200":
          description: Service is healthy and in rotation
          content:
            application/json:
              schema:
                type: object
                required: [status, version]
                properties:
                  status:
                    type: string
                    example: ok
                  version:
                    type: string
                    example: "0.1.0"
        "503":
          description: >-
            Service is draining for shutdown. Still answering requests it has
            already accepted, but should be taken out of rotation.
          content:
            application/json:
              schema:
                type: object
                required: [status, version]
                properties:
                  status:
                    type: string
                    example: draining
                  version:
                    type: string
                    example: "0.1.0"

  /openapi.json:
    get:
      operationId: getOpenApiSpec
      summary: OpenAPI specification (JSON)
      tags: [system]
      security: []
      responses:
        "200":
          description: OpenAPI 3.1.0 JSON document

  # ── Users ───────────────────────────────────────────────────────────────
  /beta/users/me:
    get:
      operationId: getMe
      summary: Get authenticated user profile
      description: Returns the profile of the currently authenticated user.
      tags: [users]
      x-auth-level: authenticated
      responses:
        "200":
          description: Current user profile
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_UserResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /beta/users:
    get:
      operationId: listUsers
      summary: List all users
      description: Returns all registered users. Requires admin role.
      tags: [users]
      x-auth-level: admin
      responses:
        "200":
          description: List of users
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_UserResponseList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  # ── Jobs ────────────────────────────────────────────────────────────────
  /beta/jobs:
    get:
      operationId: listJobs
      summary: List user's jobs
      description: |
        Returns the authenticated user's jobs, newest first. Safe to poll or
        retry — this is the recommended way to confirm whether a `POST
        /beta/jobs` call that timed out actually landed.
      tags: [jobs]
      x-auth-level: authenticated
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
          description: Maximum rows returned. Clamped to [1, 200]; defaults to 50 when omitted.
      responses:
        "200":
          description: List of jobs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_JobResponseList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createJob
      summary: Create a new inference job
      description: |
        Submits a prompt for inference on the specified tier. The job is
        queued via Pulsar and assigned to an available provider.

        **Not idempotent.** Retrying this call after a timeout or connection
        error submits a second job — there is no client-supplied idempotency
        key. A client unsure whether its original request landed should poll
        `GET /beta/jobs` (newest first) rather than retry the `POST`.
      tags: [jobs]
      x-auth-level: authenticated
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateJobRequest"
      responses:
        "201":
          description: Job created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_JobResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Closed alpha — caller is not on the access list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorResponse"
              example:
                error:
                  code: FORBIDDEN
                  message: "Closed alpha — you are not on the access list"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Alpha submissions are paused, or a backing store is unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorResponse"
              examples:
                alphaPaused:
                  value:
                    error:
                      code: ALPHA_PAUSED
                      message: "Job submission is paused for alpha maintenance. Please try again later."
                storeUnavailable:
                  value:
                    error:
                      code: STORE_UNAVAILABLE
                      message: "Storage is temporarily unavailable. Please try again."

  /beta/jobs/{id}:
    get:
      operationId: getJob
      summary: Get job by ID
      tags: [jobs]
      x-auth-level: authenticated
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Job details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_JobResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /beta/jobs/{id}/cancel:
    post:
      operationId: cancelJob
      summary: Cancel a job
      description: Cancels a pending or in-progress job. Only the job owner or an admin can cancel.
      tags: [jobs]
      x-auth-level: authenticated
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Job cancelled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_JobResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /beta/jobs/{id}/result:
    post:
      operationId: reportJobResult
      summary: Report job result
      description: |
        Called by a provider to report progress (`assigned`/`running`) or a
        terminal result (`completed`/`failed`) for a job. Safe to retry: a
        duplicate report for a job already in its terminal state is rejected
        with `409 Conflict` rather than double-applying (e.g. double-billing
        kredits or re-publishing the result).
      tags: [jobs]
      x-auth-level: provider
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportResultRequest"
      responses:
        "200":
          description: Result accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_JobResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Alpha / Quota ───────────────────────────────────────────────────────
  /beta/alpha/status:
    get:
      operationId: getAlphaStatus
      summary: Get the caller's closed-alpha access status
      description: Whether the authenticated user is on the alpha access allowlist.
      tags: [alpha]
      x-auth-level: authenticated
      responses:
        "200":
          description: Access status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_AlphaStatusResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /beta/quota:
    get:
      operationId: getAlphaQuota
      summary: Get the caller's current alpha quota state
      description: |
        Per-user rate/daily/concurrency quota state enforced on `POST
        /beta/jobs` and `POST /beta/requests` while the alpha gate is active.
        Read-only — this does not consume any quota itself.
      tags: [alpha]
      x-auth-level: authenticated
      responses:
        "200":
          description: Quota snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_AlphaQuotaResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── Kredits ─────────────────────────────────────────────────────────────
  /beta/kredits/me:
    get:
      operationId: getMyKredits
      summary: Get the caller's kredit balance and recent ledger entries
      description: |
        Always the authenticated caller's own account — there is no
        admin "read anyone's ledger" variant. An account with no entries
        reads as a zero balance, never a 404. `enforcement_enabled` reflects
        the `KREDITS_ENFORCE` flag; while it is off (phase 1), usage is
        metered and balances may go negative, but nothing is blocked.
      tags: [kredits]
      x-auth-level: authenticated
      responses:
        "200":
          description: Kredit position
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_KreditsMeResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  # ── API Keys ────────────────────────────────────────────────────────────
  /beta/api-keys:
    get:
      operationId: listApiKeys
      summary: List user's API keys
      tags: [api-keys]
      x-auth-level: authenticated
      responses:
        "200":
          description: List of API keys (masked)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_ApiKeyItemList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createApiKey
      summary: Generate a new API key
      description: |
        Creates a new API key. The full key value is returned **only once** in the
        response — it is not stored and cannot be retrieved later.
      tags: [api-keys]
      x-auth-level: authenticated
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateApiKeyRequest"
      responses:
        "201":
          description: API key created (full key shown once)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_ApiKeyCreateResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /beta/api-keys/{id}:
    delete:
      operationId: revokeApiKey
      summary: Revoke an API key
      tags: [api-keys]
      x-auth-level: authenticated
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: API key revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_ApiKeyRevokeResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Account Keys (ADR-004) ──────────────────────────────────────────────
  /beta/account-keys:
    get:
      operationId: listAccountKeys
      summary: List the caller's account keys
      tags: [account-keys]
      x-auth-level: authenticated
      responses:
        "200":
          description: List of account keys (public key masked)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_AccountKeyListItemList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createAccountKey
      summary: Register an Ed25519 account key
      description: |
        The keypair is generated **client-side** — only the base64-encoded
        public key is submitted; the server never sees or generates a private
        key. Used by `POST /beta/providers/challenge` /
        `POST /beta/providers/challenge/verify` to mint provider sessions
        without a bearer credential ever touching the wire in plaintext.
      tags: [account-keys]
      x-auth-level: authenticated
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAccountKeyRequest"
      responses:
        "201":
          description: Account key registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_AccountKeyCreateResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /beta/account-keys/{id}:
    get:
      operationId: getAccountKey
      summary: Read one of the caller's account keys
      description: |
        Returns the key's **full** base64 public key, unlike
        `GET /beta/account-keys`, which masks it so long values don't have to
        render in a table. Only the public half exists server-side — the
        private key is generated client-side and never sent (ADR-004).

        Scoped to the caller: a key belonging to another user returns `404`,
        identical to a key that does not exist, so the response can't be used
        to probe for valid ids.
      tags: [account-keys]
      x-auth-level: authenticated
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Account key details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_AccountKeyDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: revokeAccountKey
      summary: Revoke an account key
      tags: [account-keys]
      x-auth-level: authenticated
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Account key revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_AccountKeyRevokeResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Providers ───────────────────────────────────────────────────────────
  /beta/providers:
    get:
      operationId: listProviders
      summary: List providers
      description: Returns registered compute providers. Visibility depends on role.
      tags: [providers]
      x-auth-level: authenticated
      responses:
        "200":
          description: List of providers
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_ProviderDetailResponseList"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /beta/providers/register:
    post:
      operationId: registerProvider
      summary: Register a new provider
      description: Registers the authenticated user's machine as a compute provider.
      tags: [providers]
      x-auth-level: provider
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegisterProviderRequest"
      responses:
        "201":
          description: Provider registered (or re-registered — this call is upsert-semantic and preserves lifetime stats on the same row)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_RegisterProviderResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /beta/providers/{id}:
    get:
      operationId: getProvider
      summary: Get provider by ID
      tags: [providers]
      x-auth-level: authenticated
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Provider details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_ProviderDetailResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /beta/providers/{id}/heartbeat:
    post:
      operationId: providerHeartbeat
      summary: Provider heartbeat
      description: Sent periodically by a provider to indicate it is still online and available.
      tags: [providers]
      x-auth-level: provider
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Heartbeat acknowledged
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_HeartbeatResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /beta/providers/{id}/status:
    put:
      operationId: updateProviderStatus
      summary: Update provider status
      description: Sets the provider's status (e.g., online, offline, maintenance).
      tags: [providers]
      x-auth-level: provider
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StatusUpdateRequest"
      responses:
        "200":
          description: Status updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_StatusUpdateResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /beta/providers/gateway:
    get:
      operationId: providerGateway
      summary: Provider job WebSocket gateway (ADR-004 — bridges Pulsar over WS)
      description: |
        **WebSocket upgrade**, not a plain HTTP response — OpenAPI has no
        native WS operation type, so this is documented as a `GET` whose
        success response is the protocol upgrade itself (`101 Switching
        Protocols`), not a body.

        Bridges the Pulsar job queue onto the socket for third-party
        providers (cookd or any conforming node), the same way cook-core's
        native Pulsar consumer does for house nodes:

        - **Pulsar → provider**: each consumed job is forwarded as
          `{"type":"job",...}`. One job in flight per connection; the Pulsar
          message stays un-acked until a terminal result frame arrives.
        - **provider → expo**: result frames
          (`{"type":"result","status":"assigned"|"running"|"completed"|"failed",...}`,
          same fields as `POST /beta/jobs/{id}/result`'s body) drive the exact
          same order-store transitions that endpoint does. `completed`/`failed`
          ack the in-flight Pulsar message; `assigned`/`running` are
          progress-only.
        - **Heartbeat**: expo sends `{"type":"ping","seq":N}` every ~10s; the
          provider echoes `{"type":"pong","seq":N}`. 3 consecutive missed pongs
          closes the socket.
        - On close (clean or error), every still-in-flight job is nacked back
          to Pulsar for redelivery — jobs are never silently dropped.

        **Auth**: standard bearer (Clerk JWT, `ck_` key, or a provider session
        minted by `POST /beta/providers/challenge/verify`) via the
        `Authorization` header, or via the `Sec-WebSocket-Protocol: bearer,
        <token>` subprotocol for clients that can't set headers on the
        upgrade handshake.
      tags: [providers]
      x-auth-level: provider
      responses:
        "101":
          description: Switching Protocols — WebSocket upgrade established
        "401":
          $ref: "#/components/responses/Unauthorized"

  /beta/providers/challenge:
    post:
      operationId: requestProviderChallenge
      summary: Issue a nonce for a registered account key to sign
      description: |
        First half of the Ed25519 challenge/verify flow (ADR-004) —
        **unauthenticated**: the caller proves identity by signing, not by
        presenting a bearer credential. The account key named by `key_id`
        must already exist, be `active`, and have `purpose` `provider` or
        `account`. The nonce expires in `expires_in_secs` and is single-use.

        Response is **not** wrapped in the `{ "data": ... }` envelope.
      tags: [providers]
      x-auth-level: public
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProviderChallengeRequest"
      responses:
        "200":
          description: Nonce issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProviderChallengeResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"

  /beta/providers/challenge/verify:
    post:
      operationId: verifyProviderChallenge
      summary: Verify a signed nonce and mint a provider session token
      description: |
        Second half of the challenge/verify flow. Verifies `signature` against
        the account key's public key over the exact `nonce` bytes issued by
        `POST /beta/providers/challenge`; the nonce is consumed (single-use)
        on the first verify attempt regardless of outcome. On success, mints a
        `session_token` valid for `expires_in_secs` (24h) — send it as
        `Authorization: Bearer <session_token>` on the provider-facing routes.

        Response is **not** wrapped in the `{ "data": ... }` envelope.
      tags: [providers]
      x-auth-level: public
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProviderChallengeVerifyRequest"
      responses:
        "200":
          description: Provider session issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProviderChallengeVerifyResponse"
        "400":
          description: Invalid/expired challenge, signature verification failed, or the account key is revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorResponse"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Consumer ────────────────────────────────────────────────────────────
  /beta/consumer/gateway:
    get:
      operationId: consumerGateway
      summary: Consumer state WebSocket gateway (subscribe/unsubscribe over scoped topics)
      description: |
        **WebSocket upgrade**, documented as a `GET` per the same convention
        as `/beta/providers/gateway`; the success response is the protocol
        upgrade (`101`), not a body.

        The consumer-side sibling of the provider gateway: instead of
        bridging jobs, this carries **server-pushed state**. A client
        subscribes to topics and every change on a subscribed topic is fanned
        out exactly once — no client ever polls.

        All frames are JSON text; binary frames are ignored.

        Client → server:
        ```jsonc
        {"type":"subscribe","topic":"dashboard-stats"}
        {"type":"unsubscribe","topic":"order:42"}
        {"type":"pong","seq":7}
        ```

        Server → client:
        ```jsonc
        {"type":"welcome","account_id":"user_2ab…","heartbeat_interval_secs":10,"max_subscriptions":64}
        {"type":"subscribed","topic":"order:42"}
        {"type":"unsubscribed","topic":"order:42"}
        {"type":"event","topic":"order:42","data":{ }}
        {"type":"error","code":"forbidden","message":"…","topic":"order:42"}
        {"type":"ping","seq":7}
        ```

        `error` frames are advisory — a rejected `subscribe` never closes the
        socket. Error `code`s: `malformed_frame`, `unknown_topic`,
        `malformed_topic`, `forbidden`, `not_found`, `too_many_subscriptions`.

        **Topics:**

        | Topic wire form                 | Scope |
        |----------------------------------|-------|
        | `dashboard-stats`                 | Network-wide; any authenticated caller. |
        | `provider-presence`               | The caller's own account (implicit). |
        | `provider-presence:{account}`     | That account only — `forbidden` unless it's the caller's or the caller is admin. |
        | `order:{job_id}`                  | The job's owner only — rejected as `not_found` (never `forbidden`, so a non-owner can't probe existence) unless owned by the caller or the caller is admin. |

        Heartbeat/idle-expiry conventions are identical to the provider
        gateway's: a ping every 10s, socket closed after 3 missed pongs, and
        independently closed as idle after 120s with no inbound frame at all.

        **Auth**: standard bearer, or the `Sec-WebSocket-Protocol: bearer,
        <token>` subprotocol (the mechanism browser `WebSocket` clients use,
        since they cannot set an `Authorization` header on the handshake).
      tags: [consumer]
      x-auth-level: authenticated
      responses:
        "101":
          description: Switching Protocols — WebSocket upgrade established
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── Models ──────────────────────────────────────────────────────────────
  /beta/models:
    get:
      operationId: listModels
      summary: List all models
      description: Returns all registered models in the registry.
      tags: [models]
      x-auth-level: authenticated
      responses:
        "200":
          description: List of models
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_ModelResponseList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: registerModel
      summary: Register a model
      description: Adds a new model to the registry. Requires admin role.
      tags: [models]
      x-auth-level: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegisterModelRequest"
      responses:
        "201":
          description: Model registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_ModelResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /beta/models/{id}:
    get:
      operationId: getModel
      summary: Get model by ID
      tags: [models]
      x-auth-level: authenticated
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Model details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_ModelResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /beta/models/{id}/status:
    put:
      operationId: updateModelStatus
      summary: Update model status
      description: Updates a model's status (Active, Deprecated, Disabled). Admin only.
      tags: [models]
      x-auth-level: admin
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateModelStatusRequest"
      responses:
        "200":
          description: Model status updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_ModelResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Tier configs ────────────────────────────────────────────────────────
  /beta/tier-configs:
    get:
      operationId: listTierConfigs
      summary: List the tier catalog
      description: |
        Every tier the marketplace offers — pricing, display copy, the model
        it routes to, and its advertised execution budget — ordered by
        `priority` (lowest first).

        The catalog is **config-static**: it is compiled into the expo binary
        from the same product definition as `packages/config/src/tiers.ts`, so
        this endpoint reads no store and cannot fail on a backend being
        unavailable. A tier change ships as a deploy, not as a write — there
        is deliberately no create/update counterpart.
      tags: [tier-configs]
      x-auth-level: authenticated
      responses:
        "200":
          description: The tier catalog, ordered by priority
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_TierConfigResponseList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /beta/tier-configs/{name}:
    get:
      operationId: getTierConfig
      summary: Get one tier by name
      tags: [tier-configs]
      x-auth-level: authenticated
      parameters:
        - name: name
          in: path
          required: true
          description: Tier name, e.g. `cheap`
          schema:
            $ref: "#/components/schemas/TierName"
      responses:
        "200":
          description: Tier details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_TierConfigResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Requests ────────────────────────────────────────────────────────────
  /beta/requests:
    get:
      operationId: listRequests
      summary: List user's requests
      description: Returns requests belonging to the authenticated user.
      tags: [requests]
      x-auth-level: authenticated
      responses:
        "200":
          description: List of requests
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_RequestResponseList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createRequest
      summary: Create a new request
      description: Creates a high-level inference request that may produce one or more jobs.
      tags: [requests]
      x-auth-level: authenticated
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateRequestPayload"
      responses:
        "201":
          description: Request created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_RequestResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Closed alpha — caller is not on the access list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Alpha submissions are paused
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorResponse"

  /beta/requests/{id}:
    get:
      operationId: getRequest
      summary: Get request by ID
      tags: [requests]
      x-auth-level: authenticated
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Request details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_RequestResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Organizations ───────────────────────────────────────────────────────
  /beta/organizations:
    get:
      operationId: listOrganizations
      summary: List user's organizations
      tags: [organizations]
      x-auth-level: authenticated
      responses:
        "200":
          description: List of organizations
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_OrganizationResponseList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createOrganization
      summary: Create an organization
      tags: [organizations]
      x-auth-level: authenticated
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrganizationRequest"
      responses:
        "201":
          description: Organization created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_OrganizationResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /beta/organizations/{id}:
    get:
      operationId: getOrganization
      summary: Get organization by ID
      tags: [organizations]
      x-auth-level: authenticated
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: Organization details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_OrganizationResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Network ─────────────────────────────────────────────────────────────
  /beta/network/stats:
    get:
      operationId: getNetworkStats
      summary: Network stats (public-safe aggregate, windowed with per-tier backpressure)
      description: |
        Auth-gated like every `/beta` route, but **not admin-gated** — every
        field is a public-safe aggregate. Recomputed from raw job rows on
        every request (cached 15s per named window); distinct from the
        as-written `GET /beta/metrics/network`.

        **Response is not wrapped** in the `{ "data": ... }` envelope — the
        snapshot is the top-level JSON body.
      tags: [network]
      x-auth-level: authenticated
      parameters:
        - name: window
          in: query
          required: false
          schema:
            type: string
            enum: ["1h", "24h", "7d", "30d", week, month, ytd]
            default: "24h"
          description: |
            Aggregation window. Ignored (and unvalidated) when `from`/`to` are
            both supplied. 400 on any other value. Calendar-anchored windows
            are UTC: `week` starts at the current ISO week's Monday 00:00 UTC,
            `month` at 00:00 UTC on the 1st of the current calendar month, and
            `ytd` at 00:00 UTC on Jan 1 of the current year — each runs
            through the current instant.
        - name: from
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: RFC 3339 start of a custom range. Must be supplied with `to`; overrides `window` entirely. 400 if supplied alone or malformed.
        - name: to
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: RFC 3339 end of a custom range. Must be supplied with `from`, and strictly after it (400 otherwise).
      responses:
        "200":
          description: Network stats snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NetworkStatsSnapshot"
        "400":
          description: Invalid `window` value; `from`/`to` malformed, supplied without the other, or `from` not strictly before `to`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── Metrics ─────────────────────────────────────────────────────────────
  /beta/metrics/me:
    get:
      operationId: getMyMetrics
      summary: Caller's metrics-pipeline rollups (24h/7d) plus a live in-progress job count
      description: |
        Reads the `user_metrics_rollup` table written by the metrics
        aggregation service, scoped strictly to the authenticated caller — as
        written, no recomputation (distinct from the richer, recomputed `GET
        /beta/network/stats`). Missing windows render as zeros with
        `updated_at: null`, never a 500. `version` is the response-shape
        schema version, bumped only on a breaking change.

        **Response is not wrapped** in the `{ "data": ... }` envelope.
      tags: [metrics]
      x-auth-level: authenticated
      responses:
        "200":
          description: Metrics snapshot for the caller
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeStatsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /beta/metrics/network:
    get:
      operationId: getNetworkMetrics
      summary: Network-wide metrics-pipeline snapshot (live + 24h/7d) with per-tier backpressure
      description: |
        Reads the `network_metrics` table as-written by the aggregation
        service — no recomputation. Auth-gated, not admin-gated: every field
        is public-safe aggregate data. Missing windows render as zeros with
        `updated_at: null`, never a 500.

        **Response is not wrapped** in the `{ "data": ... }` envelope.
      tags: [metrics]
      x-auth-level: authenticated
      responses:
        "200":
          description: Network-wide metrics snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NetworkMetricsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── Device Auth ─────────────────────────────────────────────────────────
  /beta/device/code:
    post:
      operationId: requestDeviceCode
      summary: Request a device authorization code
      description: |
        Initiates the device authorization flow. The Cook app calls this to get a
        user code to display, then polls `/device/token` until the user verifies it.
      tags: [device-auth]
      security: []
      x-auth-level: public
      responses:
        "200":
          description: Device code issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_DeviceCodeResponse"

  /beta/device/token:
    post:
      operationId: pollDeviceToken
      summary: Poll for device authorization token
      description: |
        Called by the Cook app to check if the user has verified the device code.
        Returns pending, complete (with token), expired, or denied.
      tags: [device-auth]
      security: []
      x-auth-level: public
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [deviceCode]
              properties:
                deviceCode:
                  type: string
                  description: The device code from the initial request
      responses:
        "200":
          description: Authorization status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_DeviceAuthStatus"

  /beta/device/verify:
    post:
      operationId: verifyDeviceCode
      summary: Verify a device code
      description: |
        Called by the web app when a signed-in user enters the device code shown
        by the Cook app. Completes the device authorization flow.
      tags: [device-auth]
      x-auth-level: authenticated
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceVerifyRequest"
      responses:
        "200":
          description: Device verified
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_DeviceVerifyResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /beta/device/deny:
    post:
      operationId: denyDeviceCode
      summary: Deny a device code
      description: |
        Called by the web app when a signed-in user rejects the code shown by
        the Cook app, instead of verifying it. The device flow's terminal
        `denied` status is then what `POST /beta/device/token` returns to the
        polling Cook app.

        Both the request and response use snake_case field names — this
        endpoint's structs are not `camelCase`-renamed, unlike most of this
        API.
      tags: [device-auth]
      x-auth-level: authenticated
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceDenyRequest"
      responses:
        "200":
          description: Code denied
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_DeviceDenyResponse"
        "400":
          description: Unknown or already-resolved user code
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /beta/device/refresh:
    post:
      operationId: refreshDeviceSession
      summary: Refresh a device session token
      description: |
        Exchanges a **valid, unexpired** `device_session_*` bearer for a brand-new
        token with a fresh 24h expiry (sliding window). Send the current token in
        the `Authorization` header; there is no request body.

        The old token is invalidated **atomically** with the issuance of the new
        one — there is no window in which both authenticate, and two concurrent
        refreshes of the same token produce exactly one winner (the loser gets
        `401`). Clients must therefore replace their stored token with the value
        returned here and retry nothing on a `401`.

        Expired sessions are **not** renewable: they `401` like any other invalid
        bearer, and recovery is re-pairing through `/beta/device/code`. Non-device
        bearers (Clerk JWTs, `ck_` API keys) are refused with `400` — they have
        their own lifecycles and are never upgraded into a device session.
      tags: [device-auth]
      x-auth-level: authenticated
      responses:
        "200":
          description: New session token issued; the presented token is now dead
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope_DeviceSessionRefresh"
        "400":
          description: Bearer is not a device session token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorResponse"
              example:
                error:
                  code: INVALID_TOKEN_TYPE
                  message: "This endpoint refreshes device session tokens only."
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── Admin ───────────────────────────────────────────────────────────────
  /beta/admin/status:
    get:
      operationId: getAdminStatus
      summary: System health snapshot (provider counts, job throughput, success/failure rates, p50/p95 durations)
      description: |
        Answers the alpha launch success criteria in `docs/alpha-scope.md`:
        online/distinct-active provider counts and the rolling 7-day success
        rate. **Not** part of the standard bearer auth surface at all — this
        route sits on its own router with no `auth_middleware` layer, gated
        solely by the `adminSecret` scheme below. `403 FORBIDDEN` (not `401`)
        on a missing/wrong secret, including when `ADMIN_SECRET` isn't
        configured server-side at all.

        **Response is not wrapped** in the `{ "data": ... }` envelope, and
        every field is snake_case.
      tags: [admin]
      x-auth-level: admin-secret
      security:
        - adminSecret: []
      responses:
        "200":
          description: System health snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminStatusResponse"
        "403":
          description: Missing/invalid `X-Admin-Secret`, or `ADMIN_SECRET` is not configured server-side
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorResponse"
              example:
                error:
                  code: FORBIDDEN
                  message: "Invalid or missing X-Admin-Secret header"

components:
  # ── Security Schemes ────────────────────────────────────────────────────
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: "JWT or API key (ck_...)"
      description: |
        Clerk JWT token, CyberDiner API key (`ck_` prefix), device session
        (`device_session_` prefix), or provider session (minted by
        `POST /beta/providers/challenge/verify`) — all passed as Bearer
        tokens. WebSocket upgrade endpoints also accept this credential via
        the `Sec-WebSocket-Protocol: bearer, <token>` subprotocol.
    adminSecret:
      type: apiKey
      in: header
      name: X-Admin-Secret
      description: |
        Shared secret matched against the `ADMIN_SECRET` environment
        variable. Wholly separate from the `bearer` scheme above — used only
        by `GET /beta/admin/status`.

  # ── Parameters ──────────────────────────────────────────────────────────
  parameters:
    ResourceId:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: Resource identifier

  # ── Shared Responses ────────────────────────────────────────────────────
  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorResponse"
          example:
            error:
              code: VALIDATION_ERROR
              message: "Prompt is required"

    Unauthorized:
      description: Authentication required or token invalid
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorResponse"
          example:
            error:
              code: UNAUTHORIZED
              message: "Missing or invalid authentication token"

    Forbidden:
      description: Insufficient permissions
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorResponse"
          example:
            error:
              code: FORBIDDEN
              message: "Admin role required"

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorResponse"
          example:
            error:
              code: NOT_FOUND
              message: "Resource not found"

    Conflict:
      description: Conflict — action not allowed in current state
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorResponse"
          example:
            error:
              code: CONFLICT
              message: "Job is already completed"

    RateLimited:
      description: Too many requests
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorResponse"
          example:
            error:
              code: RATE_LIMITED
              message: "Too many requests. Try again in 60 seconds."

    ServiceUnavailable:
      description: |
        A backing store could not answer. Always safe to retry with backoff —
        this is deliberately distinct from 401/404 so a database blip never
        reads to the caller as "you are not authenticated" or "that resource
        does not exist."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorResponse"
          example:
            error:
              code: STORE_UNAVAILABLE
              message: "Storage is temporarily unavailable. Please try again."

  # ── Schemas ─────────────────────────────────────────────────────────────
  schemas:
    # ── Enums ──────────────────────────────────────────────────────────
    AuthRole:
      type: string
      enum: [admin, consumer, provider, power_user]
      description: System-level user role

    JobStatus:
      type: string
      enum: [queued, assigned, running, completed, failed, cancelled]
      description: Lifecycle status of an inference job

    ProviderStatus:
      type: string
      enum: [online, offline, maintenance, suspended]
      description: Current status of a compute provider

    ModelStatus:
      type: string
      enum: [Active, Deprecated, Disabled]
      description: Registry status of a model

    RequestStatus:
      type: string
      enum: [pending, processing, completed, failed, cancelled]
      description: Status of a high-level inference request

    ApiKeyStatus:
      type: string
      enum: [active, revoked]
      description: Status of an API key

    TierName:
      type: string
      enum: [cheap, good, alpha]
      description: |
        Inference tier. `cheap` and `good` are the provider-facing tiers
        (`cheap` routes to the 4B model, `good` to the 14B model). `alpha` is
        a closed-alpha-only tier gated separately by the alpha access list —
        legacy composite names (`fast-cheap`, `good-fast`, etc.) are rejected.

    Priority:
      type: string
      enum: [fast, normal]
      default: normal
      description: Optional priority modifier. `fast` carries a price premium and requires provider fast-eligibility.

    ParameterSize:
      type: string
      enum: ["4B", "14B"]
      description: |
        Model parameter count bucket. `3B`/`8B`/`7B`/`1B` are retired names —
        `cheap` moved to a 4B model — and are rejected by `POST /beta/models`.

    # ── Error ──────────────────────────────────────────────────────────
    ApiErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: |
                Machine-readable error code. The full set expo can return,
                by HTTP status (`apps/expo/src/errors.rs` is authoritative):

                - **400**: `INVALID_TIER`, `INVALID_PRIORITY`, `VALIDATION_ERROR`,
                  `INVALID_ERROR_PAYLOAD`, `INVALID_TOKEN_TYPE`, `INVALID_CODE`
                - **401**: `UNAUTHORIZED`
                - **402**: `INSUFFICIENT_KREDITS`
                - **403**: `FORBIDDEN`
                - **404**: `NOT_FOUND`, `JOB_NOT_FOUND`
                - **409**: `CONFLICT`
                - **413**: `PAYLOAD_TOO_LARGE`
                - **429**: `RATE_LIMITED` (adds `limit`/`remaining`/`reset`/`kind`
                  fields alongside `code`/`message`, plus a `Retry-After` header)
                - **500**: `SPACETIMEDB_UPDATE_FAILED`, `INTERNAL_ERROR`
                - **503**: `ALPHA_PAUSED`, `DISPATCH_FAILED`, `STORE_UNAVAILABLE`
                  (never leaks backend detail — connection strings, table
                  names, etc. — into `message`)
              example: VALIDATION_ERROR
            message:
              type: string
              description: Human-readable error description
              example: "Prompt is required"

    # ── Users ──────────────────────────────────────────────────────────
    UserResponse:
      type: object
      required: [userId, role, createdAt, lastSeenAt]
      properties:
        userId:
          type: string
          description: Clerk user ID
          example: user_2abc123
        email:
          type: string
          nullable: true
          format: email
        displayName:
          type: string
          nullable: true
        role:
          $ref: "#/components/schemas/AuthRole"
        createdAt:
          type: string
          format: date-time
        lastSeenAt:
          type: string
          format: date-time

    # ── Jobs ───────────────────────────────────────────────────────────
    CreateJobRequest:
      type: object
      required: [prompt, tierName]
      properties:
        prompt:
          type: string
          minLength: 1
          description: The inference prompt
        tierName:
          $ref: "#/components/schemas/TierName"
        priority:
          allOf:
            - $ref: "#/components/schemas/Priority"
          description: Optional; omitted or null defaults to `normal` server-side.
          nullable: true

    ReportResultRequest:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [completed, failed]
          description: Final status of the job
        result:
          type: string
          nullable: true
          description: Inference output (when completed)
        tokensProcessed:
          type: integer
          nullable: true
          minimum: 0
          description: Tokens generated (when completed)
        durationMs:
          type: integer
          nullable: true
          minimum: 0
          description: Processing duration in milliseconds (when completed)
        errorCode:
          type: string
          nullable: true
          description: Error code (when failed)
        errorMessage:
          type: string
          nullable: true
          description: Error description (when failed)

    JobResponse:
      type: object
      required: [jobId, status, tierName, priority, prompt, createdAt, tokensProcessed]
      description: |
        Deliberately excludes any consumer-identifying field (no `userId`,
        `email`, etc.) — this is the same payload a provider receives back
        when it reports a result, and a provider must never learn who
        submitted the job it served.
      properties:
        jobId:
          type: string
        status:
          $ref: "#/components/schemas/JobStatus"
        tierName:
          $ref: "#/components/schemas/TierName"
        priority:
          $ref: "#/components/schemas/Priority"
        prompt:
          type: string
        createdAt:
          type: string
          format: date-time
        result:
          type: string
          nullable: true
        tokensProcessed:
          type: integer
          minimum: 0
        durationMs:
          type: integer
          nullable: true
          minimum: 0
        completedAt:
          type: string
          format: date-time
          nullable: true
        assignedAt:
          type: string
          format: date-time
          nullable: true
          description: When a provider claimed the job. Null while still queued.
        errorCode:
          type: string
          nullable: true
        errorMessage:
          type: string
          nullable: true
        providerId:
          type: string
          nullable: true
          description: |
            The provider currently (or finally) assigned to this job; `null`
            while pending. Not consumer-identifying, so it's safe to echo back
            to the serving provider and to show a consumer their own job.

    # ── API Keys ───────────────────────────────────────────────────────
    CreateApiKeyRequest:
      type: object
      properties:
        name:
          type: string
          nullable: true
          description: Optional human-readable name for the key

    ApiKeyCreateResponse:
      type: object
      required: [keyId, key, name, createdAt]
      properties:
        keyId:
          type: string
        key:
          type: string
          description: Full API key value (shown only once)
        name:
          type: string
        createdAt:
          type: string
          format: date-time

    ApiKeyItem:
      type: object
      required: [keyId, name, maskedKey, status, createdAt]
      properties:
        keyId:
          type: string
        name:
          type: string
        maskedKey:
          type: string
          description: "Masked key showing only prefix and suffix (e.g. ck_abc...xyz)"
        status:
          $ref: "#/components/schemas/ApiKeyStatus"
        createdAt:
          type: string
          format: date-time
        lastUsedAt:
          type: string
          format: date-time
          nullable: true

    ApiKeyRevokeResponse:
      type: object
      required: [keyId, status]
      properties:
        keyId:
          type: string
        status:
          type: string
          enum: [revoked]

    # ── Providers ──────────────────────────────────────────────────────
    RegisterProviderRequest:
      type: object
      required: [eligibleTiers]
      properties:
        hardwareInfo:
          type: object
          nullable: true
          additionalProperties: true
          description: |
            Free-form hardware fingerprint (CPU, GPU, RAM, etc.) as a JSON
            object — sent as structured data, not a pre-serialized string. A
            machine-scoped `provider_id` (`{account}__{machine_key}`) is
            derived from this server-side; when it's absent or unparseable,
            registration falls back to the legacy bare-account id.
        eligibleTiers:
          type: array
          items:
            $ref: "#/components/schemas/TierName"
          minItems: 1
          description: Tiers this provider can serve
        displayName:
          type: string
          nullable: true

    RegisterProviderResponse:
      type: object
      required: [providerId, eligibleTiers]
      properties:
        providerId:
          type: string
        eligibleTiers:
          type: array
          items:
            $ref: "#/components/schemas/TierName"

    ProviderDetailResponse:
      type: object
      required:
        - providerId
        - displayName
        - status
        - eligibleTiers
        - lastHeartbeat
        - registeredAt
        - jobsCompleted
        - tokensGenerated
      properties:
        providerId:
          type: string
        displayName:
          type: string
        status:
          $ref: "#/components/schemas/ProviderStatus"
        eligibleTiers:
          type: array
          items:
            $ref: "#/components/schemas/TierName"
        hardwareInfo:
          type: string
          nullable: true
        lastHeartbeat:
          type: string
          format: date-time
        registeredAt:
          type: string
          format: date-time
        jobsCompleted:
          type: integer
          minimum: 0
        tokensGenerated:
          type: integer
          minimum: 0

    HeartbeatResponse:
      type: object
      required: [acknowledged]
      properties:
        acknowledged:
          type: boolean

    StatusUpdateRequest:
      type: object
      required: [status]
      properties:
        status:
          $ref: "#/components/schemas/ProviderStatus"

    StatusUpdateResponse:
      type: object
      required: [status]
      properties:
        status:
          $ref: "#/components/schemas/ProviderStatus"

    # ── Models ─────────────────────────────────────────────────────────
    RegisterModelRequest:
      type: object
      required: [modelId, displayName, family, parameterSize, contextWindow, eligibleTiers]
      properties:
        modelId:
          type: string
          description: Unique model identifier (e.g. "llama-3.2-3b")
        displayName:
          type: string
        family:
          type: string
          description: Model family (e.g. "llama", "qwen")
        parameterSize:
          $ref: "#/components/schemas/ParameterSize"
        contextWindow:
          type: integer
          minimum: 1
        eligibleTiers:
          type: array
          items:
            $ref: "#/components/schemas/TierName"
          minItems: 1
        routingPriority:
          type: integer
          minimum: 0
          nullable: true
          description: Priority for model routing (higher = preferred). Defaults to 0.

    ModelResponse:
      type: object
      required:
        - modelId
        - displayName
        - family
        - parameterSize
        - contextWindow
        - status
        - eligibleTiers
        - routingPriority
        - createdAt
        - updatedAt
      properties:
        modelId:
          type: string
        displayName:
          type: string
        family:
          type: string
        parameterSize:
          $ref: "#/components/schemas/ParameterSize"
        contextWindow:
          type: integer
        status:
          $ref: "#/components/schemas/ModelStatus"
        eligibleTiers:
          type: array
          items:
            $ref: "#/components/schemas/TierName"
        routingPriority:
          type: integer
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    UpdateModelStatusRequest:
      type: object
      required: [status]
      properties:
        status:
          $ref: "#/components/schemas/ModelStatus"

    # ── Tier configs ───────────────────────────────────────────────────
    TierConfigResponse:
      type: object
      description: |
        One tier from the config-static catalog. Replaces the `tier_config`
        SpaceTimeDB table the old consumer app subscribed to, with the same
        fields plus the `fast` price (previously left to the client to
        compute) and the advertised execution budget.
      required:
        - tierName
        - displayName
        - description
        - pricePer100MillionTokens
        - fastPricePer100MillionTokens
        - modelName
        - priority
        - maxExecutionSecs
      properties:
        tierName:
          $ref: "#/components/schemas/TierName"
        displayName:
          type: string
        description:
          type: string
        pricePer100MillionTokens:
          type: integer
          minimum: 0
          description: Base price per 100M tokens, at the default `normal` priority.
        fastPricePer100MillionTokens:
          type: integer
          minimum: 0
          description: |
            Price per 100M tokens with the `fast` priority modifier applied —
            the base price plus a flat premium.
        modelName:
          type: string
          description: |
            The model this tier routes to, or `auto` when no single model
            claims it (`alpha` is an access program rather than a size).
        priority:
          type: integer
          minimum: 0
          description: Display order, lowest first. The list endpoint is already sorted by it.
        maxExecutionSecs:
          type: integer
          minimum: 1
          description: |
            Advertised per-job execution budget in seconds. A hint for sizing
            prompts, not a guarantee: an individual provider may be configured
            higher, and a slower one may still time out under this budget.

    # ── Organizations ──────────────────────────────────────────────────
    CreateOrganizationRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1

    OrganizationResponse:
      type: object
      required: [orgId, name, ownerUserId, createdAt, updatedAt]
      properties:
        orgId:
          type: string
        name:
          type: string
        ownerUserId:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    # ── Requests ───────────────────────────────────────────────────────
    CreateRequestPayload:
      type: object
      required: [prompt, tierName]
      properties:
        prompt:
          type: string
          minLength: 1
        tierName:
          $ref: "#/components/schemas/TierName"
        orgId:
          type: string
          nullable: true
          description: Organization context for this request

    RequestResponse:
      type: object
      required: [requestId, userId, prompt, tierName, status, createdAt]
      properties:
        requestId:
          type: string
        userId:
          type: string
        orgId:
          type: string
          nullable: true
        prompt:
          type: string
        tierName:
          $ref: "#/components/schemas/TierName"
        status:
          $ref: "#/components/schemas/RequestStatus"
        createdAt:
          type: string
          format: date-time
        completedAt:
          type: string
          format: date-time
          nullable: true
        errorMessage:
          type: string
          nullable: true

    # ── Device Auth ────────────────────────────────────────────────────
    DeviceCodeResponse:
      type: object
      description: |
        Field names are snake_case on the wire — this struct is not
        `camelCase`-renamed, unlike most of this API.
      required: [device_code, user_code, verification_url, expires_in, interval]
      properties:
        device_code:
          type: string
          description: Opaque device code for polling
        user_code:
          type: string
          description: Short code the user enters in the web app
        verification_url:
          type: string
          format: uri
          description: URL where the user verifies the code
        expires_in:
          type: integer
          minimum: 1
          description: Seconds until expiration
        interval:
          type: integer
          minimum: 1
          description: Recommended polling interval in seconds

    DeviceAuthStatus:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [pending, complete, expired, denied]
        token:
          type: string
          description: Session token (when status = complete)
        userId:
          type: string
          description: Authenticated user ID (when status = complete)
        expiresAt:
          type: string
          format: date-time
          description: Token expiration (when status = complete)

    DeviceSessionRefresh:
      type: object
      required: [token, expiresAt, userId]
      properties:
        token:
          type: string
          description: The replacement session token. The presented token is dead.
        expiresAt:
          type: string
          format: date-time
          description: |
            Expiry of the replacement token. Clients schedule their next refresh
            against this value rather than assuming a fixed lifetime.
        userId:
          type: string
          description: Authenticated user ID the session belongs to

    DeviceVerifyRequest:
      type: object
      required: [userCode]
      properties:
        userCode:
          type: string

    DeviceVerifyResponse:
      type: object
      required: [success]
      properties:
        success:
          type: boolean

    DeviceDenyRequest:
      type: object
      description: Field name is snake_case on the wire, unlike most of this API.
      required: [user_code]
      properties:
        user_code:
          type: string

    DeviceDenyResponse:
      type: object
      required: [success]
      properties:
        success:
          type: boolean

    # ── Alpha / Quota ──────────────────────────────────────────────────
    AlphaStatusResponse:
      type: object
      required: [access]
      properties:
        access:
          type: string
          enum: [allowed, denied]

    QuotaDimension:
      type: object
      description: Field names are snake_case on the wire, unlike most of this API.
      required: [limit, remaining, reset]
      properties:
        limit:
          type: integer
        remaining:
          type: integer
        reset:
          type: integer
          nullable: true
          description: Unix seconds until this dimension resets. Always null for `concurrent_cap`, which has no time-based reset.

    AlphaQuotaResponse:
      type: object
      description: |
        Field names (`rate_limit`, `daily_quota`, `concurrent_cap`) are
        snake_case on the wire — this struct is not `camelCase`-renamed,
        unlike most of this API.
      required: [rate_limit, daily_quota, concurrent_cap]
      properties:
        rate_limit:
          allOf:
            - $ref: "#/components/schemas/QuotaDimension"
          description: Per-minute submission rate limit.
        daily_quota:
          allOf:
            - $ref: "#/components/schemas/QuotaDimension"
          description: Rolling daily submission quota.
        concurrent_cap:
          allOf:
            - $ref: "#/components/schemas/QuotaDimension"
          description: Concurrently in-flight job cap.

    # ── Kredits ────────────────────────────────────────────────────────
    KreditEntryResponse:
      type: object
      description: Field names are snake_case on the wire, unlike most of this API.
      required: [id, kind, amount_micro_kredits, reference_id, memo, created_at]
      properties:
        id:
          type: string
        kind:
          type: string
          enum: [Earn, Spend, Purchase, Adjustment]
        amount_micro_kredits:
          type: integer
          description: Signed micro-kredits — positive credits the account, negative debits it. Always an integer, never a float.
        reference_id:
          type: string
          nullable: true
          description: The order (or payment) this entry is about, when it has one.
        memo:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time

    KreditsMeResponse:
      type: object
      description: |
        Field names are snake_case on the wire, unlike most of this API.
        Every amount is integer micro-kredits; `balance_kredits` is a
        floor-rounded-toward-negative-infinity convenience for display and is
        never the number to do arithmetic on.
      required:
        - version
        - account_id
        - balance_micro_kredits
        - balance_kredits
        - earned_micro_kredits
        - spent_micro_kredits
        - purchased_micro_kredits
        - adjusted_micro_kredits
        - entry_count
        - enforcement_enabled
        - entries
      properties:
        version:
          type: integer
          description: Response-shape schema version. Bumped only on a breaking change.
        account_id:
          type: string
        balance_micro_kredits:
          type: integer
        balance_kredits:
          type: integer
        earned_micro_kredits:
          type: integer
        spent_micro_kredits:
          type: integer
          description: Lifetime spent, as a positive magnitude.
        purchased_micro_kredits:
          type: integer
        adjusted_micro_kredits:
          type: integer
        entry_count:
          type: integer
          description: The whole ledger's entry count, not the length of `entries`.
        enforcement_enabled:
          type: boolean
          description: True when `KREDITS_ENFORCE` is armed. Off in phase 1 — usage is metered, balances may go negative, nothing is blocked.
        entries:
          type: array
          description: Newest first, capped at 50 rows.
          items:
            $ref: "#/components/schemas/KreditEntryResponse"

    # ── Account Keys (ADR-004) ─────────────────────────────────────────
    CreateAccountKeyRequest:
      type: object
      required: [publicKey]
      properties:
        label:
          type: string
          nullable: true
          description: Defaults to "Account key" server-side when omitted.
        purpose:
          type: string
          nullable: true
          enum: [provider, request, account]
          default: account
        publicKey:
          type: string
          description: Base64-encoded Ed25519 public key. The private key never leaves the client.

    AccountKeyCreateResponse:
      type: object
      required: [keyId, label, purpose, algorithm, publicKey, status, createdAt]
      properties:
        keyId:
          type: string
        label:
          type: string
        purpose:
          type: string
          enum: [provider, request, account]
        algorithm:
          type: string
          enum: [ed25519]
        publicKey:
          type: string
        status:
          type: string
          enum: [active, revoked]
        createdAt:
          type: string
          format: date-time

    AccountKeyListItem:
      type: object
      required: [keyId, label, purpose, algorithm, maskedPublicKey, status, createdAt, lastUsedAt]
      properties:
        keyId:
          type: string
        label:
          type: string
        purpose:
          type: string
          enum: [provider, request, account]
        algorithm:
          type: string
          enum: [ed25519]
        maskedPublicKey:
          type: string
          description: "Head/tail only, e.g. \"AbCdEfGh...wXyZ\"."
        status:
          type: string
          enum: [active, revoked]
        createdAt:
          type: string
          format: date-time
        lastUsedAt:
          type: string
          format: date-time
          nullable: true

    AccountKeyDetail:
      type: object
      description: |
        One account key with its full public key material. The `publicKey`
        here is the unmasked counterpart of `AccountKeyListItem`'s
        `maskedPublicKey`; there is no private-key field because the private
        key never leaves the client (ADR-004).
      required: [keyId, label, purpose, algorithm, publicKey, status, createdAt, lastUsedAt]
      properties:
        keyId:
          type: string
        label:
          type: string
        purpose:
          type: string
          enum: [provider, request, account]
        algorithm:
          type: string
          enum: [ed25519]
        publicKey:
          type: string
          description: Base64-encoded Ed25519 public key, in full.
        status:
          type: string
          enum: [active, revoked]
        createdAt:
          type: string
          format: date-time
        lastUsedAt:
          type: string
          format: date-time
          nullable: true

    AccountKeyRevokeResponse:
      type: object
      required: [keyId, status]
      properties:
        keyId:
          type: string
        status:
          type: string
          enum: [revoked]

    # ── Provider auth (challenge/verify) ───────────────────────────────
    ProviderChallengeRequest:
      type: object
      description: Field name is snake_case on the wire, unlike most of this API.
      required: [key_id]
      properties:
        key_id:
          type: string

    ProviderChallengeResponse:
      type: object
      description: |
        Field names are snake_case on the wire. **Not** wrapped in the
        `{ "data": ... }` envelope.
      required: [nonce, expires_in_secs]
      properties:
        nonce:
          type: string
        expires_in_secs:
          type: integer

    ProviderChallengeVerifyRequest:
      type: object
      description: Field names are snake_case on the wire, unlike most of this API.
      required: [key_id, nonce, signature]
      properties:
        key_id:
          type: string
        nonce:
          type: string
        signature:
          type: string
          description: Base64-encoded Ed25519 signature over the raw `nonce` bytes.

    ProviderChallengeVerifyResponse:
      type: object
      description: |
        Field names are snake_case on the wire. **Not** wrapped in the
        `{ "data": ... }` envelope.
      required: [session_token, expires_in_secs]
      properties:
        session_token:
          type: string
        expires_in_secs:
          type: integer
          description: Always 86400 (24h) currently.

    # ── Network stats ──────────────────────────────────────────────────
    NetworkStatsTierBackpressure:
      type: object
      required: [backlog_count, queue_wait_p50_ms, queue_wait_p95_ms, providers_serving]
      properties:
        backlog_count:
          type: integer
        queue_wait_p50_ms:
          type: integer
        queue_wait_p95_ms:
          type: integer
        providers_serving:
          type: integer

    NetworkStatsSnapshot:
      type: object
      description: |
        Field names are snake_case on the wire — this struct is not
        `camelCase`-renamed, unlike most of this API. **Not** wrapped in the
        `{ "data": ... }` envelope: this is the top-level response body.
      required:
        - generated_at
        - window
        - range_start
        - range_end
        - providers_online
        - jobs_completed
        - jobs_in_progress
        - success_rate_pct
        - median_duration_ms
        - p95_duration_ms
        - tokens_processed
        - tiers
        - your_share_pct
      properties:
        generated_at:
          type: string
          format: date-time
        window:
          type: string
          enum: ["1h", "24h", "7d", "30d", week, month, ytd, custom]
          description: Echoes the requested (or default) named window, or "custom" when `from`/`to` were used.
        range_start:
          type: string
          format: date-time
          description: Resolved start of the aggregation range actually used.
        range_end:
          type: string
          format: date-time
          description: Resolved end of the aggregation range actually used.
        providers_online:
          type: integer
        jobs_completed:
          type: integer
        jobs_in_progress:
          type: integer
          description: Sum of pending + running + assigned jobs in the window.
        success_rate_pct:
          type: number
        median_duration_ms:
          type: integer
          nullable: true
        p95_duration_ms:
          type: integer
          nullable: true
        tokens_processed:
          type: integer
        tiers:
          type: object
          description: Keyed by tier name (cheap, good); both keys always present, zeros on empty data.
          additionalProperties:
            $ref: "#/components/schemas/NetworkStatsTierBackpressure"
        your_share_pct:
          type: number
          nullable: true
          description: Caller's completed-jobs-as-provider share of the window's total completed jobs. Null unless the caller has a provider registration.

    # ── Metrics pipeline ───────────────────────────────────────────────
    NetworkMetricsWindow:
      type: object
      description: Field names are snake_case on the wire, unlike most of this API.
      required: [cooks_online, total_capacity, tokens_served_cheap, tokens_served_good, jobs_completed, jobs_failed, updated_at]
      properties:
        cooks_online:
          type: integer
        total_capacity:
          type: integer
        tokens_served_cheap:
          type: integer
        tokens_served_good:
          type: integer
        jobs_completed:
          type: integer
        jobs_failed:
          type: integer
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: Null when the aggregation service hasn't written this window yet.

    MetricsTierBackpressure:
      type: object
      required: [queue_depth]
      properties:
        queue_depth:
          type: integer

    NetworkMetricsResponse:
      type: object
      description: |
        Field names are snake_case on the wire. **Not** wrapped in the
        `{ "data": ... }` envelope.
      required: [version, generated_at, live, backpressure, rollup_24h, rollup_7d]
      properties:
        version:
          type: integer
          description: Response-shape schema version. Bumped only on a breaking change.
        generated_at:
          type: string
          format: date-time
        live:
          $ref: "#/components/schemas/NetworkMetricsWindow"
        backpressure:
          type: object
          description: Keyed by tier name (cheap, good); both keys always present.
          additionalProperties:
            $ref: "#/components/schemas/MetricsTierBackpressure"
        rollup_24h:
          $ref: "#/components/schemas/NetworkMetricsWindow"
        rollup_7d:
          $ref: "#/components/schemas/NetworkMetricsWindow"

    UserMetricsWindow:
      type: object
      description: Field names are snake_case on the wire, unlike most of this API.
      required: [tokens_served_cheap, tokens_served_good, jobs_completed, jobs_failed, updated_at]
      properties:
        tokens_served_cheap:
          type: integer
        tokens_served_good:
          type: integer
        jobs_completed:
          type: integer
        jobs_failed:
          type: integer
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: Null when the aggregation service hasn't written this window yet.

    MeStatsResponse:
      type: object
      description: |
        Field names are snake_case on the wire. **Not** wrapped in the
        `{ "data": ... }` envelope.
      required: [version, generated_at, user_id, live, rollup_24h, rollup_7d]
      properties:
        version:
          type: integer
          description: Response-shape schema version. Bumped only on a breaking change.
        generated_at:
          type: string
          format: date-time
        user_id:
          type: string
        live:
          type: object
          required: [jobs_in_progress]
          properties:
            jobs_in_progress:
              type: integer
              description: Computed on read from the order store, not a rollup table row.
        rollup_24h:
          $ref: "#/components/schemas/UserMetricsWindow"
        rollup_7d:
          $ref: "#/components/schemas/UserMetricsWindow"

    # ── Admin ──────────────────────────────────────────────────────────
    AdminStatusResponse:
      type: object
      description: |
        Hand-assembled `serde_json::json!` — every field is snake_case, and
        this is **not** wrapped in the `{ "data": ... }` envelope.
      required: [generated_at, providers, queue_depth, jobs_24h, jobs_7d, duration_ms, alpha_criteria]
      properties:
        generated_at:
          type: string
          format: date-time
        providers:
          type: object
          required: [online, offline, total, quarantined, by_provider]
          properties:
            online:
              type: integer
            offline:
              type: integer
            total:
              type: integer
            quarantined:
              type: integer
              description: Count whose per-machine quarantine health (issue #362) is currently Unhealthy.
            by_provider:
              type: array
              items:
                type: object
                properties:
                  provider_id:
                    type: string
                  display_name:
                    type: string
                  status:
                    type: string
                  jobs_completed_lifetime:
                    type: integer
                  tokens_generated_lifetime:
                    type: integer
                  last_heartbeat:
                    type: string
                  registered_at:
                    type: string
                  health:
                    type: string
                    enum: [healthy, unhealthy]
        queue_depth:
          type: object
          properties:
            job_dispatch:
              type: string
              description: Always the literal string "unavailable" today — real-time Pulsar admin queue depth is not yet wired up for this field.
        jobs_24h:
          $ref: "#/components/schemas/AdminJobsWindow"
        jobs_7d:
          allOf:
            - $ref: "#/components/schemas/AdminJobsWindow"
          description: Adds `distinct_providers_active` and `by_provider` on top of the 24h shape.
        duration_ms:
          type: object
          required: [p50, p95, sample_count, window]
          properties:
            p50:
              type: integer
              nullable: true
            p95:
              type: integer
              nullable: true
            sample_count:
              type: integer
            window:
              type: string
              enum: [7d_complete_jobs]
        alpha_criteria:
          type: object
          description: Alpha exit criteria status per docs/alpha-scope.md.
          required: [external_supply_ok, success_rate_ok, notes]
          properties:
            external_supply_ok:
              type: boolean
              description: At least 1 provider online.
            success_rate_ok:
              type: boolean
              description: ≥95% success rate over 7d with ≥2 distinct providers active and ≥10 jobs.
            notes:
              type: object
              properties:
                external_supply:
                  type: string
                success_rate:
                  type: string

    AdminJobsWindow:
      type: object
      required: [pending, running, complete, failed, cancelled, total, success_rate_pct]
      properties:
        pending:
          type: integer
        running:
          type: integer
        complete:
          type: integer
        failed:
          type: integer
        cancelled:
          type: integer
        total:
          type: integer
        success_rate_pct:
          type: number
        failure_rate_pct:
          type: number
          description: 24h window only.
        distinct_providers_active:
          type: integer
          description: 7d window only.
        by_provider:
          type: array
          description: 7d window only; sorted by jobs_complete descending.
          items:
            type: object
            properties:
              provider_id:
                type: string
              jobs_complete:
                type: integer
              jobs_failed:
                type: integer

    # ── Dashboard ──────────────────────────────────────────────────────
    DashboardStats:
      type: object
      required: [totalJobs, activeProviders, registeredModels, pendingRequests]
      properties:
        totalJobs:
          type: integer
          minimum: 0
        activeProviders:
          type: integer
          minimum: 0
        registeredModels:
          type: integer
          minimum: 0
        pendingRequests:
          type: integer
          minimum: 0

    # ── Envelope wrappers ──────────────────────────────────────────────
    # These define the { data: T } wrapper for each response type.
    Envelope_UserResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/UserResponse"

    Envelope_UserResponseList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/UserResponse"

    Envelope_JobResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/JobResponse"

    Envelope_JobResponseList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/JobResponse"

    Envelope_ApiKeyItem:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/ApiKeyItem"

    Envelope_ApiKeyItemList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/ApiKeyItem"

    Envelope_ApiKeyCreateResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/ApiKeyCreateResponse"

    Envelope_ApiKeyRevokeResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/ApiKeyRevokeResponse"

    Envelope_ProviderDetailResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/ProviderDetailResponse"

    Envelope_ProviderDetailResponseList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/ProviderDetailResponse"

    Envelope_RegisterProviderResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/RegisterProviderResponse"

    Envelope_HeartbeatResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/HeartbeatResponse"

    Envelope_StatusUpdateResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/StatusUpdateResponse"

    Envelope_ModelResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/ModelResponse"

    Envelope_ModelResponseList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/ModelResponse"

    Envelope_TierConfigResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/TierConfigResponse"

    Envelope_TierConfigResponseList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/TierConfigResponse"

    Envelope_OrganizationResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/OrganizationResponse"

    Envelope_OrganizationResponseList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/OrganizationResponse"

    Envelope_RequestResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/RequestResponse"

    Envelope_RequestResponseList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/RequestResponse"

    Envelope_DeviceCodeResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/DeviceCodeResponse"

    Envelope_DeviceAuthStatus:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/DeviceAuthStatus"

    Envelope_DeviceSessionRefresh:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/DeviceSessionRefresh"

    Envelope_DeviceVerifyResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/DeviceVerifyResponse"

    Envelope_DashboardStats:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/DashboardStats"

    Envelope_AlphaStatusResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/AlphaStatusResponse"

    Envelope_AlphaQuotaResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/AlphaQuotaResponse"

    Envelope_KreditsMeResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/KreditsMeResponse"

    Envelope_AccountKeyCreateResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/AccountKeyCreateResponse"

    Envelope_AccountKeyListItemList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/AccountKeyListItem"

    Envelope_AccountKeyDetail:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/AccountKeyDetail"

    Envelope_AccountKeyRevokeResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/AccountKeyRevokeResponse"

    Envelope_DeviceDenyResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/DeviceDenyResponse"
