openapi: 3.1.0
info:
  title: CV Platform API
  version: "1.0.0"
  summary: Computer-vision training and inference platform — REST surface callable with an API key.
  description: |
    Machine-readable counterpart to [AGENT_GUIDE.md](/agents.md).

    This spec covers the full API-key-callable surface — projects, images,
    label sets, training, inference, background sets, workspaces, invites,
    and read-only token state. An API key inherits the user's authority on
    everything the app does and how the user collaborates.

    Account, billing, and key-management actions are managed by signed-in
    users on the website and are not part of the API surface.

    ## Authentication
    Send your API key in the `Authorization` header on every request:
    `Authorization: Bearer mlp_live_xxxxxxxxxxxxxxxxxxxxxxxx`

    Call `GET /me` first to verify the key.

    ## Response envelope
    Every JSON response uses the envelope shape:
    - Success: `{ ok: true, data, requestId }`
    - Error: `{ ok: false, error: { code, message, details? }, requestId }`

    Branch on `error.code` (stable), not on `error.message` (may change).
    The `requestId` mirrors the `X-Request-Id` response header and maps 1:1
    to server-side request log rows.

    File downloads (`GET /training/:id/download`) **do not use this
    envelope** — they return a 302 redirect.

    ## Rate limits
    **60 requests / minute per API key**, with a burst capacity of **120**.
    Exceeding the limit returns `429 RATE_LIMITED` with a `Retry-After` header
    and `error.details.retryAfterSeconds`.

    ## Hard limits
    - JSON body: 100 MB
    - Image file: 50 MB each
    - Files per multipart request: 20
    - Accepted image formats: JPEG, PNG, WebP
  contact:
    name: ML Practitioners
    url: "https://www.mlpractitioners.com"
  license:
    name: Proprietary

servers:
  - url: "https://api.mlpractitioners.com"
    description: Current environment

security:
  - bearerAuth: []

tags:
  - name: Auth
    description: Key verification and liveness
  - name: Workspaces
    description: |
      Containers for one team's projects, members, and tokens. Every user has
      a Personal workspace and can belong to any number of shared workspaces.
      Projects, tokens, and bundle purchases all live in a workspace.
  - name: Invites
    description: |
      Recipient-side workspace invitation endpoints. The user accepts or
      declines invites that other workspace owners have addressed to their
      email.
  - name: Projects
    description: Top-level dataset containers (each lives in a workspace)
  - name: Images
    description: Uploaded images within a project
  - name: LabelSets
    description: Labeling task definitions (classes, type) on top of a project's images
  - name: Training
    description: Training jobs and inference on trained models
  - name: BackgroundSets
    description: Negative-example image collections used during training
  - name: Tokens
    description: Training-token balance, ledger, and usage (workspace-scoped)

paths:
  # ── Auth / identity ───────────────────────────────────────────────────────
  /me:
    get:
      tags: [Auth]
      summary: Verify API key and return caller identity
      description: |
        Call this first to confirm the API key is valid. Returns
        `{ userId, email, authSource }`. `authSource` will be `"apiKey"` for
        Bearer-authenticated requests.
      responses:
        "200":
          description: Key is valid
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/MeResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /health:
    get:
      tags: [Auth]
      summary: Liveness probe (public, no auth)
      security: []
      responses:
        "200":
          description: Service is alive
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          status: { type: string, example: "ok" }

  # ── Workspaces ────────────────────────────────────────────────────────────
  /workspaces:
    get:
      tags: [Workspaces]
      summary: List workspaces the user belongs to
      description: |
        Returns every workspace the caller is a member of, including their
        Personal workspace plus any shared workspaces they own or have been
        invited to. Each entry carries the caller's `role` (`owner` or
        `member`), `isPersonal`, and counts.
      responses:
        "200":
          description: Workspace list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          workspaces:
                            type: array
                            items: { $ref: "#/components/schemas/Workspace" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
    post:
      tags: [Workspaces]
      summary: Create a shared workspace
      description: The caller becomes the workspace owner.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Workspace" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }

  /workspaces/{id}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
    get:
      tags: [Workspaces]
      summary: Workspace detail
      description: Membership required.
      responses:
        "200":
          description: Workspace
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Workspace" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Workspaces]
      summary: Rename a workspace (owner only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Workspace" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Workspaces]
      summary: Delete a workspace (owner only)
      description: |
        Permanently deletes the workspace and all its projects, members,
        invites, and tokens. Personal workspaces can be deleted too; an empty
        Personal workspace is recreated on the user's next sign-in.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          deleted: { type: boolean }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{id}/members:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
    get:
      tags: [Workspaces]
      summary: List workspace members
      description: Membership required.
      responses:
        "200":
          description: Member list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          members:
                            type: array
                            items: { $ref: "#/components/schemas/WorkspaceMember" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{id}/members/{userId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - in: path
        name: userId
        required: true
        description: User ID of the member to remove
        schema: { type: string }
    delete:
      tags: [Workspaces]
      summary: Remove a member (or leave as a non-owner)
      description: |
        - The owner may remove any non-owner member.
        - A non-owner may remove only themselves (self-leave).
        - The owner cannot remove themselves; delete the workspace instead.
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          workspaceId: { type: string }
                          userId: { type: string }
                          removed: { type: boolean }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{id}/invites:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
    get:
      tags: [Workspaces]
      summary: List pending invites for a workspace (owner only)
      responses:
        "200":
          description: Pending invites
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          invites:
                            type: array
                            items: { $ref: "#/components/schemas/WorkspaceInvite" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Workspaces]
      summary: Invite an existing user by email (owner only)
      description: |
        The invitee must already have an account on the platform — there is
        no signup-by-invite flow. Works for any workspace the caller owns,
        including their Personal workspace.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "201":
          description: Invite created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          email: { type: string, format: email }
                          createdAt: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: |
            Workspace not found, or no account exists for the given email
            (`USER_NOT_FOUND`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "409":
          description: |
            Either the user is already a member (`ALREADY_A_MEMBER`) or an
            invite for that email is already pending
            (`INVITE_ALREADY_PENDING`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /workspaces/{id}/invites/{inviteId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - in: path
        name: inviteId
        required: true
        description: Pending invite ID
        schema: { type: string }
    delete:
      tags: [Workspaces]
      summary: Cancel a pending invite (owner only)
      responses:
        "200":
          description: Cancelled
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          cancelled: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Invites (recipient-side) ──────────────────────────────────────────────
  /invites/me:
    get:
      tags: [Invites]
      summary: List pending invites addressed to the caller
      description: |
        Returns every workspace invite addressed to the caller's email that
        has not yet been accepted or declined. Surface these to the user as
        a notification badge.
      responses:
        "200":
          description: Invite list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          invites:
                            type: array
                            items: { $ref: "#/components/schemas/MyInvite" }
        "401": { $ref: "#/components/responses/Unauthenticated" }

  /invites/{id}/accept:
    parameters:
      - in: path
        name: id
        required: true
        description: Invite ID
        schema: { type: string }
    post:
      tags: [Invites]
      summary: Accept an invite and join the workspace
      responses:
        "200":
          description: Joined
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          workspaceId: { type: string }
                          role: { type: string, example: member }
                          accepted: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403":
          description: "Invite is not addressed to the caller's email. `code: FORBIDDEN`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "Already a member of the workspace. `code: ALREADY_A_MEMBER`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /invites/{id}/decline:
    parameters:
      - in: path
        name: id
        required: true
        description: Invite ID
        schema: { type: string }
    post:
      tags: [Invites]
      summary: Decline (and remove) an invite
      responses:
        "200":
          description: Declined
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          declined: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403":
          description: "Invite is not addressed to the caller's email. `code: FORBIDDEN`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Projects ──────────────────────────────────────────────────────────────
  /projects:
    get:
      tags: [Projects]
      summary: List the user's projects
      description: |
        Deleted projects are excluded. Without `workspaceId`, the list
        unions across every workspace the user belongs to. With `workspaceId`,
        the list is restricted to that workspace (the user must be a member).
        Each item carries `workspace_id`, `workspace_name`, and `is_shared`.
      parameters:
        - in: query
          name: workspaceId
          required: false
          description: Optional workspace filter. User must be a member.
          schema: { type: string }
      responses:
        "200":
          description: Project list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Projects]
      summary: Create a project
      description: |
        Project name must be unique within a workspace. Without `workspaceId`,
        the project is created in the caller's Personal workspace; with
        `workspaceId`, the caller must be a member of that workspace.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, example: warehouse-pallets }
                workspaceId:
                  type: string
                  description: Optional target workspace. Defaults to the caller's Personal workspace.
                  nullable: true
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Project name already taken in this workspace
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /projects/{id}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      summary: Project detail
      responses:
        "200":
          description: Project
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Projects]
      summary: Rename a project
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1 }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Project name conflict
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    delete:
      tags: [Projects]
      summary: Delete a project
      description: Also deletes all child images and label sets.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          deleted: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /projects/{id}/images:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects, Images]
      summary: List images in a project (with download URLs)
      responses:
        "200":
          description: Image list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Image" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Projects, Images]
      summary: Bulk-upload images (multipart)
      description: |
        - `files` — up to **20 files per request**, max **50 MB per file**.
          Send multiple files by repeating the field (`-F "files=@a.jpg" -F "files=@b.jpg"`), **not** with `files[]`.
        - `thumbnails` — optional, one per `files` in the same order. If
          omitted, the server auto-generates a JPEG thumbnail (≤512px) from
          each uploaded image and returns it as `thumbnail_url`.
        - Supported formats: JPEG, PNG, WebP.

        Returns **201** on full success or **207** when some items failed.
        On 207, retry only the filenames in `data.errors` — the files in
        `data.results` are already saved.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [files]
              properties:
                files:
                  type: array
                  items:
                    type: string
                    format: binary
                  maxItems: 20
                thumbnails:
                  type: array
                  items:
                    type: string
                    format: binary
                  maxItems: 20
      responses:
        "201":
          description: All files uploaded successfully
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ImageUploadResult" }
        "207":
          description: Partial success — some files failed
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ImageUploadResult" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "500": { $ref: "#/components/responses/UploadFailed" }

  /projects/{id}/labelsets:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects, LabelSets]
      summary: List label sets for a project
      description: Includes `label_count` and `training_count` for each set.
      responses:
        "200":
          description: Label set list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/LabelSet" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Projects, LabelSets]
      summary: Create a label set
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LabelSetCreate" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/LabelSet" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Label set name already taken in this project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /projects/{id}/background-sets:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects, BackgroundSets]
      summary: List background sets for a project
      responses:
        "200":
          description: Background set list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/BackgroundSet" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Projects, BackgroundSets]
      summary: Create a background set
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1 }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/BackgroundSet" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Background set name already taken in this project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  # ── Images ────────────────────────────────────────────────────────────────
  /images:
    post:
      tags: [Images]
      summary: Single-image upload via base64 JSON
      description: |
        Prefer the multipart bulk endpoint `POST /projects/:id/images` for
        anything more than a single file — it's faster. A thumbnail is
        auto-generated server-side and returned as `thumbnail_url`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [projectId, filename, contentType, data]
              properties:
                projectId: { type: string }
                filename: { type: string }
                contentType:
                  type: string
                  enum: [image/jpeg, image/png, image/webp]
                data:
                  type: string
                  format: byte
                  description: Base64-encoded image bytes
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Image" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }

  /images/bulk:
    delete:
      tags: [Images]
      summary: Delete many images at once
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image_ids]
              properties:
                image_ids:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          deleted_count: { type: integer }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /images/split:
    patch:
      tags: [Images]
      summary: Bulk-assign split (TRAIN/VAL/TEST) to many images
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image_ids, split]
              properties:
                image_ids:
                  type: array
                  items: { type: string }
                split: { $ref: "#/components/schemas/ImageSplit" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          updated_count: { type: integer }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /images/{id}:
    parameters:
      - $ref: "#/components/parameters/ImageId"
    get:
      tags: [Images]
      summary: Image detail + download URL
      responses:
        "200":
          description: Image
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Image" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Images]
      summary: Delete one image
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          deleted: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /images/{id}/split:
    parameters:
      - $ref: "#/components/parameters/ImageId"
    patch:
      tags: [Images]
      summary: Assign split for one image
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [split]
              properties:
                split: { $ref: "#/components/schemas/ImageSplit" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Image" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /images/{id}/labels:
    parameters:
      - $ref: "#/components/parameters/ImageId"
    get:
      tags: [Images, LabelSets]
      summary: Get labels on an image across all its label sets
      responses:
        "200":
          description: Labels grouped by label set
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            labelset_id: { type: string }
                            labels:
                              type: array
                              items: { $ref: "#/components/schemas/Label" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Label Sets ────────────────────────────────────────────────────────────
  /label-sets:
    post:
      tags: [LabelSets]
      summary: Create a label set (alternative to the project-scoped route)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/LabelSetCreate"
                - type: object
                  required: [projectId]
                  properties:
                    projectId: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/LabelSet" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Label set name already taken
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /label-sets/{id}:
    parameters:
      - $ref: "#/components/parameters/LabelSetId"
    get:
      tags: [LabelSets]
      summary: Label set detail
      description: |
        Does **not** include `label_count`. Use `GET /projects/:id/labelsets`
        if you need that aggregate.
      responses:
        "200":
          description: Label set
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/LabelSet" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [LabelSets]
      summary: Update label set name or classes
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1 }
                classes:
                  type: array
                  items: { $ref: "#/components/schemas/LabelClass" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/LabelSet" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [LabelSets]
      summary: Delete a label set
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          deleted: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /label-sets/{id}/training:
    parameters:
      - $ref: "#/components/parameters/LabelSetId"
    get:
      tags: [LabelSets, Training]
      summary: List training jobs for a label set
      responses:
        "200":
          description: Training job list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/TrainingJob" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [LabelSets, Training]
      summary: Create a training job (status `pending`)
      description: |
        Defaults are sensible: `model_size: "n"`, `epochs: 50`,
        `batch_size: 16`, `image_size: 640`. Omit unless the user requests
        specifics. The job is created but not started — call
        `POST /training/:id/start` next.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TrainingJobCreate" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/TrainingJob" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /label-sets/{id}/training-dataset:
    parameters:
      - $ref: "#/components/parameters/LabelSetId"
    get:
      tags: [LabelSets, Training]
      summary: Preview the dataset that would be submitted to training
      description: Useful for debugging — shows the images, splits, and label payload shape.
      responses:
        "200":
          description: Dataset preview
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        additionalProperties: true
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Training ──────────────────────────────────────────────────────────────
  /training/{id}:
    parameters:
      - $ref: "#/components/parameters/TrainingId"
    get:
      tags: [Training]
      summary: Training job detail (and refresh for active jobs)
      description: |
        For active jobs this call also polls the upstream training backend
        and updates `status`, `progress`, `metrics`, and `log` before
        responding.
      responses:
        "200":
          description: Training job
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/TrainingJob" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /training/{id}/start:
    parameters:
      - $ref: "#/components/parameters/TrainingId"
    post:
      tags: [Training]
      summary: Start a pending or failed training job
      description: |
        Builds the dataset, submits it to the training backend, and
        transitions the job through `preparing` → `queued` → `training`.
        Consumes tokens.
      responses:
        "200":
          description: Job started
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/TrainingJob" }
        "400":
          description: Invalid training state, or insufficient tokens
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /training/{id}/download:
    parameters:
      - $ref: "#/components/parameters/TrainingId"
      - in: query
        name: format
        schema: { $ref: "#/components/schemas/ModelFormat" }
        description: Default `onnx`.
    get:
      tags: [Training]
      summary: Download the trained model
      description: |
        **Does not use the JSON envelope on success.** Responds with a 302
        redirect to a temporary download URL. Follow with `curl -L` and save
        the binary body.
      responses:
        "302":
          description: Redirect to a temporary download URL
          headers:
            Location:
              description: Temporary download URL (short-lived)
              schema: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Training not found, or not yet completed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /training/{id}/infer/{imageId}:
    parameters:
      - $ref: "#/components/parameters/TrainingId"
      - in: path
        name: imageId
        required: true
        schema: { type: string }
    post:
      tags: [Training]
      summary: Synchronous inference on a single project image
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                conf_thre:
                  type: number
                  minimum: 0
                  maximum: 1
                  default: 0.25
      responses:
        "200":
          description: Predictions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/InferenceResult" }
        "400":
          description: Training not finished, or other invalid state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/UpstreamError" }

  /training/{id}/infer-all:
    parameters:
      - $ref: "#/components/parameters/TrainingId"
    post:
      tags: [Training]
      summary: Start batch inference across the project's images
      description: Asynchronous — poll `GET /training/:id/infer-all/status`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                split:
                  type: string
                  enum: [TRAIN, VAL, TEST, ALL]
                  default: ALL
      responses:
        "200":
          description: Batch inference started
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          inference_job_id: { type: string }
                          status: { type: string, example: running }
        "400":
          description: No images for the requested split, or invalid state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: A batch inference for this training is already running
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /training/{id}/infer-all/status:
    parameters:
      - $ref: "#/components/parameters/TrainingId"
      - in: query
        name: inference_job_id
        required: true
        schema: { type: string }
    get:
      tags: [Training]
      summary: Check progress of a batch inference job
      responses:
        "200":
          description: Status
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          inference_job_id: { type: string }
                          status:
                            type: string
                            enum: [running, completed, failed]
                          progress: { type: number, minimum: 0, maximum: 1 }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /training/{id}/infer-all/results:
    parameters:
      - $ref: "#/components/parameters/TrainingId"
      - in: query
        name: inference_job_id
        required: true
        schema: { type: string }
      - in: query
        name: min_conf
        schema: { type: number, minimum: 0, maximum: 1, default: 0.25 }
    get:
      tags: [Training]
      summary: Per-image predictions for a completed batch inference
      responses:
        "200":
          description: Results
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/InferenceResult" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /training/{id}/evaluate/results:
    parameters:
      - $ref: "#/components/parameters/TrainingId"
      - in: query
        name: inference_job_id
        required: true
        schema: { type: string }
    get:
      tags: [Training]
      summary: Evaluation metrics produced by a batch inference run
      responses:
        "200":
          description: Evaluation metrics
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        additionalProperties: true
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /training/{id}/inference-jobs:
    parameters:
      - $ref: "#/components/parameters/TrainingId"
    get:
      tags: [Training]
      summary: List past batch-inference jobs for this training
      responses:
        "200":
          description: Inference job list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/InferenceJob" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Background Sets ───────────────────────────────────────────────────────
  /background-sets/{id}:
    parameters:
      - $ref: "#/components/parameters/BackgroundSetId"
    delete:
      tags: [BackgroundSets]
      summary: Delete a background set
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          deleted: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /background-sets/{id}/images:
    parameters:
      - $ref: "#/components/parameters/BackgroundSetId"
    get:
      tags: [BackgroundSets]
      summary: List images in a background set
      responses:
        "200":
          description: Image list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Image" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [BackgroundSets]
      summary: Upload images to a background set
      description: Same format/limits as `POST /projects/:id/images`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [files]
              properties:
                files:
                  type: array
                  items: { type: string, format: binary }
                  maxItems: 20
                thumbnails:
                  type: array
                  items: { type: string, format: binary }
                  maxItems: 20
      responses:
        "201":
          description: Uploaded
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ImageUploadResult" }
        "207":
          description: Partial success
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ImageUploadResult" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }

  /background-sets/{id}/images/{imageId}:
    parameters:
      - $ref: "#/components/parameters/BackgroundSetId"
      - in: path
        name: imageId
        required: true
        schema: { type: string }
    delete:
      tags: [BackgroundSets]
      summary: Remove one image from a background set
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string }
                          deleted: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Tokens ────────────────────────────────────────────────────────────────
  /tokens/summary:
    get:
      tags: [Tokens]
      summary: Token balance summary
      description: |
        Returns `{ monthly, purchased, totalAvailable, renewal }` for the
        caller's personal token pool. Check before starting training.
        Tokens are personal: when training runs in a shared workspace,
        the workspace owner's pool pays — so the same balance covers
        every workspace the caller owns, and members of someone else's
        workspace see only their own pool here.
      responses:
        "200":
          description: Token summary
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/TokenSummary" }
        "401": { $ref: "#/components/responses/Unauthenticated" }

  /tokens/monthly:
    get:
      tags: [Tokens]
      summary: Monthly allowance detail
      description: |
        Returns the caller's personal monthly allowance for the current
        billing period. Without an active monthly subscription, `cap` and
        `remaining` are `0`.
      responses:
        "200":
          description: Monthly allowance
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          remaining: { type: integer }
                          periodEnd: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthenticated" }

  /tokens/purchased:
    get:
      tags: [Tokens]
      summary: Purchased (bundle) token detail
      description: |
        Returns the caller's personal bundle balance. Bundles never
        expire and accumulate across the caller's lifetime purchases.
      responses:
        "200":
          description: Purchased tokens
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          remaining: { type: integer }
        "401": { $ref: "#/components/responses/Unauthenticated" }

  /tokens/history:
    get:
      tags: [Tokens]
      summary: Token transaction ledger
      description: |
        Returns the caller's personal token ledger. Each entry includes
        an optional `actor` field — the user who triggered the spend.
        For training runs the caller themselves triggered, `actor` is
        absent; for spends triggered by a member of one of the caller's
        shared workspaces, `actor` is that member.
      parameters:
        - in: query
          name: limit
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - in: query
          name: offset
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        "200":
          description: Ledger page
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          entries:
                            type: array
                            items: { $ref: "#/components/schemas/TokenLedgerEntry" }
                          limit: { type: integer }
                          offset: { type: integer }
                          total: { type: integer }
        "401": { $ref: "#/components/responses/Unauthenticated" }

# ── Reusable components ─────────────────────────────────────────────────────
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: mlp_live_xxx

  parameters:
    ProjectId:
      in: path
      name: id
      required: true
      description: Project ID
      schema: { type: string }
    ImageId:
      in: path
      name: id
      required: true
      description: Image ID
      schema: { type: string }
    LabelSetId:
      in: path
      name: id
      required: true
      description: Label set ID
      schema: { type: string }
    TrainingId:
      in: path
      name: id
      required: true
      description: Training job ID
      schema: { type: string }
    BackgroundSetId:
      in: path
      name: id
      required: true
      description: Background set ID
      schema: { type: string }
    WorkspaceId:
      in: path
      name: id
      required: true
      description: Workspace ID
      schema: { type: string }
    WorkspaceIdQuery:
      in: query
      name: workspaceId
      required: false
      description: Optional workspace target. Defaults to the caller's Personal workspace.
      schema: { type: string }

  schemas:
    # ── Envelope ──
    SuccessEnvelope:
      type: object
      required: [ok, requestId]
      properties:
        ok: { type: boolean, const: true }
        data: {}
        requestId:
          type: string
          description: Mirrors the `X-Request-Id` response header
      description: Wraps every successful JSON response (200 / 201 / 207).

    ErrorEnvelope:
      type: object
      required: [ok, error, requestId]
      properties:
        ok: { type: boolean, const: false }
        error:
          type: object
          required: [code, message]
          properties:
            code: { $ref: "#/components/schemas/ErrorCode" }
            message: { type: string }
            details:
              description: "Code-specific structured context (e.g. `{ fields: [...] }` for validation, `{ retryAfterSeconds }` for rate limits)."
              oneOf:
                - type: object
                  additionalProperties: true
                - type: array
                  items: {}
        requestId: { type: string }

    ErrorCode:
      type: string
      description: Stable machine-readable error code. Branch on this, not on `message`.
      enum:
        - UNAUTHENTICATED
        - API_KEY_INVALID
        - API_KEY_REVOKED
        - API_KEY_EXPIRED
        - FORBIDDEN
        - SUBSCRIPTION_REQUIRED
        - RATE_LIMITED
        - VALIDATION_FAILED
        - PAYLOAD_TOO_LARGE
        - UNSUPPORTED_MEDIA_TYPE
        - NOT_FOUND
        - PROJECT_NOT_FOUND
        - IMAGE_NOT_FOUND
        - LABEL_SET_NOT_FOUND
        - BACKGROUND_SET_NOT_FOUND
        - TRAINING_NOT_FOUND
        - INFERENCE_JOB_NOT_FOUND
        - USER_NOT_FOUND
        - PROJECT_NAME_TAKEN
        - LABEL_SET_NAME_TAKEN
        - BACKGROUND_SET_NAME_TAKEN
        - BATCH_INFERENCE_ALREADY_RUNNING
        - ALREADY_A_MEMBER
        - INVITE_ALREADY_PENDING
        - INSUFFICIENT_TOKENS
        - INVALID_TRAINING_STATE
        - NO_IMAGES_FOR_INFERENCE
        - TRAINING_NOT_FINISHED
        - UPSTREAM_ERROR
        - UPLOAD_FAILED
        - INTERNAL

    # ── Domain enums ──
    ImageSplit:
      type: string
      enum: [TRAIN, VAL, TEST]
    LabelSetType:
      type: string
      enum: [object_detection, segmentation, classification, pose, obb]
    TrainingStatus:
      type: string
      enum: [pending, preparing, queued, training, completed, failed]
    ModelSize:
      type: string
      enum: [n, s, m, l, x]
      description: YOLO model size — n (nano, fastest) → x (largest, most accurate)
    ModelFormat:
      type: string
      enum: [onnx, pth]

    # ── Auth / user ──
    MeResponse:
      type: object
      properties:
        userId: { type: string }
        email: { type: string, format: email, nullable: true }
        authSource:
          type: string
          enum: [apiKey, session]

    # ── Workspaces / invites ──
    Workspace:
      type: object
      description: A container for projects, members, and tokens.
      properties:
        id: { type: string }
        name: { type: string }
        ownerId:
          type: string
          description: User ID of the workspace owner.
        isPersonal:
          type: boolean
          description: True if this is the user's auto-created Personal workspace.
        role:
          type: string
          enum: [owner, member]
          description: The caller's role in this workspace.
        memberCount: { type: integer }
        projectCount: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    WorkspaceMember:
      type: object
      properties:
        userId: { type: string }
        email: { type: string, format: email, nullable: true }
        role:
          type: string
          enum: [owner, member]
        joinedAt: { type: string, format: date-time }

    WorkspaceInvite:
      type: object
      description: A pending invite, as seen by the workspace owner.
      properties:
        id: { type: string }
        email: { type: string, format: email }
        invitedBy:
          type: object
          properties:
            id: { type: string }
            email: { type: string, format: email, nullable: true }
        createdAt: { type: string, format: date-time }

    MyInvite:
      type: object
      description: A pending invite addressed to the caller.
      properties:
        id: { type: string }
        workspace:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
        invitedBy:
          type: object
          properties:
            id: { type: string }
            email: { type: string, format: email, nullable: true }
        createdAt: { type: string, format: date-time }

    # ── Projects / images / label sets ──
    Project:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        workspace_id:
          type: string
          description: ID of the workspace this project belongs to.
        workspace_name:
          type: string
          description: Display name of the owning workspace.
        is_shared:
          type: boolean
          description: True when the workspace is shared (not the caller's Personal workspace).
        image_count: { type: integer }
        labelset_count: { type: integer }
        model_count: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Image:
      type: object
      properties:
        id: { type: string }
        filename: { type: string }
        width: { type: integer, nullable: true }
        height: { type: integer, nullable: true }
        split: { $ref: "#/components/schemas/ImageSplit" }
        createdAt: { type: string, format: date-time }
        url:
          type: string
          format: uri
          description: Temporary download URL (short-lived)
        thumbnail_url:
          type: string
          format: uri
          nullable: true

    ImageUploadResult:
      type: object
      properties:
        results:
          type: array
          items: { $ref: "#/components/schemas/Image" }
        errors:
          type: array
          items:
            type: object
            properties:
              filename: { type: string }
              error: { type: string }

    LabelClass:
      type: object
      required: [name]
      properties:
        name: { type: string }
        color:
          type: string
          description: Hex color used only in the labeler UI
          example: "#FF5722"

    LabelSet:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        type: { $ref: "#/components/schemas/LabelSetType" }
        classes:
          type: array
          items: { $ref: "#/components/schemas/LabelClass" }
        label_count: { type: integer }
        training_count: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    LabelSetCreate:
      type: object
      required: [name, type]
      properties:
        name: { type: string, minLength: 1 }
        type: { $ref: "#/components/schemas/LabelSetType" }
        classes:
          type: array
          items: { $ref: "#/components/schemas/LabelClass" }

    Label:
      type: object
      description: One annotation on one image for one label set.
      additionalProperties: true
      properties:
        id: { type: string }
        class:
          type: string
          description: The class name from the label set
        x: { type: number }
        y: { type: number }
        width: { type: number }
        height: { type: number }

    BackgroundSet:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        image_count: { type: integer }
        createdAt: { type: string, format: date-time }

    # ── Training / inference ──
    TrainingJobCreate:
      type: object
      properties:
        name: { type: string }
        model_size: { $ref: "#/components/schemas/ModelSize" }
        epochs: { type: integer, minimum: 1, default: 50 }
        batch_size: { type: integer, minimum: 1, default: 16 }
        image_size: { type: integer, minimum: 64, default: 640 }
        background_set_ids:
          type: array
          items: { type: string }
        augmentation:
          type: object
          additionalProperties: true

    TrainingJob:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        label_set_id: { type: string }
        status: { $ref: "#/components/schemas/TrainingStatus" }
        base_model:
          type: string
          example: yolo11n
        epochs: { type: integer }
        batch_size: { type: integer }
        image_size: { type: integer }
        metrics:
          type: object
          additionalProperties: true
        log:
          type: string
          description: Newline-joined training log (may be long)
        progress:
          type: number
          minimum: 0
          maximum: 1
        error:
          type: string
          nullable: true
          description: Populated when `status === "failed"`
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    InferenceResult:
      type: object
      properties:
        image_id: { type: string }
        predictions:
          type: array
          items:
            type: object
            properties:
              class:
                type: string
                description: Predicted class name
              confidence: { type: number, minimum: 0, maximum: 1 }
              bbox:
                type: array
                description: "[x, y, w, h]"
                minItems: 4
                maxItems: 4
                items: { type: number }

    InferenceJob:
      type: object
      properties:
        id: { type: string }
        training_id: { type: string }
        status:
          type: string
          enum: [running, completed, failed]
        split:
          type: string
          enum: [TRAIN, VAL, TEST, ALL]
        progress: { type: number, minimum: 0, maximum: 1 }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    # ── Tokens ──
    TokenSummary:
      type: object
      properties:
        monthly:
          type: object
          properties:
            remaining: { type: integer }
            periodEnd: { type: string, format: date-time }
        purchased:
          type: object
          properties:
            remaining: { type: integer }
        totalAvailable: { type: integer }
        renewal: { type: string, format: date-time }

    TokenLedgerEntry:
      type: object
      properties:
        id: { type: string }
        type:
          type: string
          enum: [purchase, usage, refund, monthly_grant, adjustment]
        tokens:
          type: integer
          description: Signed delta — negative for usage, positive for credits
        description:
          type: string
          nullable: true
        trainingJobId:
          type: string
          nullable: true
        actor:
          type: object
          nullable: true
          description: |
            For shared workspaces, the user who triggered this entry (e.g. the
            member who started a training run that consumed tokens). `null`
            for monthly grants and for personal-workspace usage.
          properties:
            id: { type: string }
            email: { type: string, format: email, nullable: true }
        createdAt: { type: string, format: date-time }

  responses:
    Unauthenticated:
      description: Missing, invalid, revoked, or expired credentials. `code` is one of `UNAUTHENTICATED`, `API_KEY_INVALID`, `API_KEY_REVOKED`, or `API_KEY_EXPIRED`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Forbidden:
      description: "The resource exists but is not owned by this user. `code: FORBIDDEN`."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    SubscriptionRequired:
      description: "Subscription is inactive or expired. `code: SUBSCRIPTION_REQUIRED`."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    NotFound:
      description: Resource not found or deleted. `code` is resource-specific (e.g. `PROJECT_NOT_FOUND`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    ValidationFailed:
      description: "Missing, empty, or bad field. `code: VALIDATION_FAILED` with `error.details.fields`."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    RateLimited:
      description: Exceeded 60 req/min (burst 120). Includes a `Retry-After` header.
      headers:
        Retry-After:
          description: Seconds to wait before retrying
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    PayloadTooLarge:
      description: "JSON body > 100 MB, or file > 50 MB. `code: PAYLOAD_TOO_LARGE`."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    UnsupportedMediaType:
      description: "Wrong `Content-Type` or unaccepted image format. `code: UNSUPPORTED_MEDIA_TYPE`."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    UpstreamError:
      description: "Training / inference backend unreachable. `code: UPSTREAM_ERROR`. Retryable once after 30 s."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    UploadFailed:
      description: "The upload failed, or all files in a batch failed. `code: UPLOAD_FAILED`."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
