# Digital Photo Gallery API — /v1 contract, GENERATED FROM THE CURRENT CODE.
# Source of truth: config/routes.rb, app/controllers/v1/**, app/serializers/v1/*,
# app/services/operations/serializer.rb, config/locales/v1*.yml.
# All timestamps are unix-epoch integers (int64). Reads paginate with limit/offset.
# Writes require an Idempotency-Key header and return 202 + an Operation to poll.
#
# SCOPE: /v1 currently exposes the image-management surface (images, sets,
# keywords, metadata, uploads, downloads). It is versioned and resource-oriented
# so future account sections (websites, blogs, mailing lists, ...) can be added
# as sibling resources under the same /v1 prefix and the same auth/permission
# model — this is the Digital Photo Gallery account API, not an images-only API.
openapi: 3.1.0

info:
  title: Digital Photo Gallery API
  version: "1.0"
  description: >-
    The Digital Photo Gallery account API. The /v1 surface currently covers image
    management (images, sets, keywords, metadata, uploads, downloads) for the
    native apps and integrations; it is structured to grow into the rest of the
    account surface (websites, blogs, mailing lists, ...) as sibling resources.
    Reads are served locally (account-scoped, serializer-rendered); image writes
    forward (HMAC-signed) to the account's imageprocessor and return an Operation
    (202) the client polls. Every timestamp field is a unix-epoch integer, not an
    ISO-8601 string.


    CONDITIONAL CACHING: every GET returns a content-derived `ETag`. Send it back
    as `If-None-Match` and an unchanged resource responds `304 Not Modified` with
    an empty body (saves bandwidth on slow/metered connections; also makes
    `GET /operations/{uuid}` cheap to poll — 304 while pending, 200 when the status
    changes). Responses are `Cache-Control: private`.


    API ACCESS (default OFF): every token-authenticated endpoint requires the
    account to have API access enabled (admin Settings -> API -> Enabled). A
    brand-new account has it OFF, so all endpoints return `403 api_not_enabled`
    until an account administrator opts in. This is a per-account master switch,
    enforced for both user and service tokens.


    MEDIA CDN: image `url`s are served from the account's configured API CDN
    (Settings -> API -> API CDN: AWS default, Cloudflare, or CloudFront). The host
    in a version's `url` reflects that choice; clients should not assume a fixed host.


    SERVICE-TOKEN LIMITS: a `dpg_api_*` service token may carry a Max Payload
    limit (set when the token is created). A request whose body exceeds it is
    rejected with `413 payload_too_large` before processing. Tokens may also be
    IP-allowlisted and have an expiry; these apply to service tokens only.


    RATE LIMITS: sign-in and MFA are throttled (per IP, per login, and per
    challenged user). A throttled request returns `429 rate_limited` with a
    `Retry-After` header (seconds) — wait that long before retrying, do not hammer.


    PAGINATION: every list response sets `X-Has-More: true|false`; when true and
    the endpoint is offset-paged it also sets `X-Next-Offset: <n>` — pass it as
    `?offset` to fetch the next page. Keep paging while `X-Has-More` is true.
    (`GET /v1/keywords` and `GET /v1/automatic_keywords` are capped, not offset-
    paged: `X-Has-More` there means "narrow with `?q` or raise `?limit`".)


    NEW-AGENT ORIENTATION: fetch `/llms.txt` (or `/llms-full.txt`, which inlines
    this whole spec) for a plain-language guide to auth, the async write->poll
    model, idempotency, and errors before making calls.

servers:
  - url: https://api.digitalphotogallery.com

security:
  - bearerAuth: []

tags:
  - name: Auth
    description: Sign-in, MFA, token rotation / account selection, device management.
  - name: Identity
    description: Current user + scoped account + capabilities; account picker.
  - name: Sets
    description: Image-set reads + membership / ordering / go-live writes.
  - name: Images
    description: Image browse/detail reads + edit/keyword/rating/metadata/crop/enrichment writes.
  - name: Facets
    description: EXIF / image-property faceted browse.
  - name: Keywords
    description: Keyword vocabulary browse + rename.
  - name: Profiles
    description: Master metadata profiles + version (output-size) profiles.
  - name: Uploads
    description: Signed direct-to-imageprocessor upload ticket.
  - name: Downloads
    description: Resolve a download URL for one image version.
  - name: Operations
    description: Async operation status polling + batch fan-out.

paths:
  # ==========================================================================
  # AUTH (no bearer token required)
  # ==========================================================================
  /v1/auth/sign_in:
    post:
      tags: [Auth]
      summary: Password sign-in (may return an MFA challenge)
      description: >-
        Verifies login + password. When the user has a usable TOTP device,
        returns an MFA challenge and issues no token. Otherwise registers/finds
        the device, issues an account-less refresh token, and returns the
        accounts the user can pick from. NO access token is issued at sign-in.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [login, password]
              properties:
                login: { type: string }
                password: { type: string }
                device: { $ref: '#/components/schemas/DeviceInput' }
      responses:
        '200':
          description: MFA challenge OR sign-in success (account-less refresh + accounts).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/MfaChallenge'
                  - $ref: '#/components/schemas/SignInSuccess'
        '400': { $ref: '#/components/responses/Error' }
        '401':
          $ref: '#/components/responses/Error'
        '403':
          description: locked or unconfirmed account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: >-
            rate_limited — throttled per source IP (20 / 5 min) AND per login
            (10 failures / 15 min, fleet-wide; cleared on a correct password).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/auth/mfa:
    post:
      tags: [Auth]
      summary: Verify a TOTP code against the sign-in MFA challenge
      description: >-
        Verifies the signed mfa_token and TOTP code, then registers/finds the
        device and returns the same success shape as a no-MFA sign-in.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mfa_token, code]
              properties:
                mfa_token: { type: string }
                code: { type: string, description: TOTP code }
                device: { $ref: '#/components/schemas/DeviceInput' }
      responses:
        '200':
          description: Sign-in success (account-less refresh token + accounts list).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SignInSuccess' }
        '400': { $ref: '#/components/responses/Error' }
        '401':
          description: mfa_invalid (bad/expired token or code).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: locked or unconfirmed account (re-checked at MFA).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: rate_limited — too many TOTP attempts (5 / 5 min per challenged user).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/auth/token:
    post:
      tags: [Auth]
      summary: Rotate the refresh token; optionally select an account for an access token
      description: >-
        Always rotates the refresh token (rotation + reuse detection). With
        account_uuid, re-checks membership and mints an account-scoped access
        token (returns TokenSuccess). Without it, returns the rotated refresh
        token plus the accounts list (SignInSuccess shape).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
                account_uuid:
                  type: string
                  description: When present, mint an access token scoped to this account.
      responses:
        '200':
          description: Access token (account selected) OR rotated refresh + accounts.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TokenSuccess'
                  - $ref: '#/components/schemas/SignInSuccess'
        '401':
          description: refresh_token_invalid or refresh_token_reused.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: account_forbidden (not a member of account_uuid).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/auth/sign_out:
    delete:
      tags: [Auth]
      summary: Revoke the device that owns the presented refresh token
      description: >-
        Idempotent. Revokes the device (and all its refresh tokens). An unknown,
        expired, or rotated token still returns signed_out:true.
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token: { type: string }
      responses:
        '200':
          description: Always signed out.
          content:
            application/json:
              schema:
                type: object
                properties:
                  signed_out: { type: boolean, const: true }

  # ==========================================================================
  # DEVICES
  # ==========================================================================
  /v1/auth/devices:
    get:
      tags: [Auth]
      summary: List the current user's active devices
      responses:
        '200':
          description: Object-wrapped device list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  devices:
                    type: array
                    items: { $ref: '#/components/schemas/Device' }
        '401':
          $ref: '#/components/responses/Error'

  /v1/auth/devices/{uuid}:
    delete:
      tags: [Auth]
      summary: Revoke a device (and its refresh tokens) by uuid
      parameters:
        - name: uuid
          in: path
          required: true
          schema: { type: string }
          description: Device uuid.
      responses:
        '200':
          description: Device revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked: { type: boolean, const: true }
                  uuid: { type: string }
        '401':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  # ==========================================================================
  # IDENTITY
  # ==========================================================================
  /v1/me:
    get:
      tags: [Identity]
      summary: Current user + scoped account + capabilities
      description: >-
        For a user token, user is populated and capabilities are the granted
        privilege keys. For a dpg_api_* service token, user is null,
        service_token is present, and capabilities are the token's api.* scopes.
      responses:
        '200':
          description: Identity payload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  account:
                    oneOf:
                      - $ref: '#/components/schemas/Account'
                      - type: 'null'
                  user:
                    oneOf:
                      - $ref: '#/components/schemas/User'
                      - type: 'null'
                  service_token:
                    type: object
                    description: Present only for service tokens.
                    properties:
                      uuid: { type: string }
                      name: { type: string }
                  capabilities:
                    type: array
                    items: { type: string }
        '401':
          $ref: '#/components/responses/Error'

  /v1/accounts:
    get:
      tags: [Identity]
      summary: Accounts the user can access (bare array)
      description: A service token returns only its single scoped account.
      responses:
        '200':
          description: Bare JSON array of accounts.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Account' }
        '401':
          $ref: '#/components/responses/Error'

  # ==========================================================================
  # SETS
  # ==========================================================================
  /v1/sets/tree:
    get:
      tags: [Sets]
      summary: The entire nested set hierarchy in one call
      description: >-
        The account's full folder tree — every set nested under its parent, in one
        request (instead of walking level-by-level with ?parent_public_uuid). Each
        node's children are ordered by that node's own child_sort, matching the
        website. Lightweight nodes (incl. lead_image_public_uuid); use GET
        /v1/sets/{public_uuid} for a single set's counts / website binding.
      parameters:
        - name: visible
          in: query
          schema: { type: boolean }
          description: Omit for BOTH (admin default); true = only live sets; false = only hidden.
      responses:
        '200':
          description: Bare JSON array of root nodes, each with nested children.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SetTreeNode' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }

  /v1/sets/{public_uuid}/tree:
    get:
      tags: [Sets]
      summary: The nested subtree under one set
      description: >-
        Like GET /v1/sets/tree but rooted at {public_uuid} — returns that set's children,
        each with their descendants. For browsing one branch of a large library.
      parameters:
        - { name: public_uuid, in: path, required: true, schema: { type: string } }
        - name: visible
          in: query
          schema: { type: boolean }
          description: Omit for BOTH (admin default); true = only live sets; false = only hidden.
      responses:
        '200':
          description: Bare JSON array of child nodes, each with nested children.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SetTreeNode' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404':
          description: set_not_found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/sets:
    get:
      tags: [Sets]
      summary: List sets (bare array)
      description: >-
        Defaults to root sets (no parent). Children of a parent come back in the
        PARENT's configured child order (see child_sort on the set) — usually
        alphabetical or hand-arranged — matching the website tree; override with
        ?sort + ?order. limit default 50, max 200.
      parameters:
        - name: parent_public_uuid
          in: query
          schema: { type: string }
          description: List children of this set instead of roots.
        - name: type
          in: query
          schema: { type: string }
          description: Filter by set_type.
        - name: sort
          in: query
          schema: { type: string, enum: [position, name, start_date, created_at, updated_at] }
          description: Override the child order (default = the parent's child_sort; roots default to position).
        - name: order
          in: query
          schema: { type: string, enum: [asc, desc] }
          description: Direction for ?sort (defaults to the parent's configured direction).
        - name: visible
          in: query
          schema: { type: boolean }
          description: >-
            Filter by the set's own hidden flag. Omit for BOTH (admin default);
            true = only live sets; false = only hidden/embargoed.
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
      responses:
        '200':
          description: Bare JSON array of sets.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ImageSet' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404':
          description: set_not_found (parent_public_uuid not owned).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    post:
      tags: [Sets]
      summary: Create a set (forward -> create_set)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 191 }
                parent_public_uuid: { type: string, description: Nest under this set (public_uuid). }
                colour:
                  type: string
                  enum: ['#5fc7ff', '#8ed623', '#ffe01d', '#fa9916', '#fb410e', '#ffd0d8', '#ac82e4', '#e6e6e6', '#424242']
                  description: One of the admin set-colour palette (case-insensitive).
                description: { type: string, maxLength: 65535 }
                type: { type: string, enum: [static, dynamic], description: set_type }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/sets/reorder:
    patch:
      tags: [Sets]
      summary: Reorder sets (forward -> update_set_order)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [set_public_uuids]
              properties:
                set_public_uuids:
                  type: array
                  minItems: 1
                  items: { type: string }
                  description: Set public_uuids in the desired order (non-empty; de-duped).
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/sets/{public_uuid}:
    parameters:
      - { $ref: '#/components/parameters/SetUuid' }
    get:
      tags: [Sets]
      summary: One set (bare object)
      responses:
        '200':
          description: A single set.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImageSet' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
    patch:
      tags: [Sets]
      summary: Update a set (forward -> update_set)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 191 }
                description: { type: string, maxLength: 65535 }
                colour:
                  type: string
                  enum: ['#5fc7ff', '#8ed623', '#ffe01d', '#fa9916', '#fb410e', '#ffd0d8', '#ac82e4', '#e6e6e6', '#424242']
                lead_image_public_uuid: { type: string, description: Cover image (public_uuid). }
                parent_public_uuid: { type: string, description: 'Re-parent under this set (public_uuid). A cycle (self/descendant) is rejected 400.' }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Sets]
      summary: Delete a set (forward -> destroy_sets)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/sets/{public_uuid}/images:
    parameters:
      - { $ref: '#/components/parameters/SetUuid' }
    get:
      tags: [Sets]
      summary: Images in a set (bare array)
      description: >-
        Ordered by the set's OWN configured order by default — including a
        hand-arranged ("custom") position sequence — so this matches exactly what
        the public gallery renders. Discover that order on the set object
        (image_sort). Override with ?sort + ?order to pull the same images in any
        order you like.
      parameters:
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
        - name: sort
          in: query
          schema:
            type: string
            enum: [position, created_at, updated_at, creation_date, title, original_filename, image_rating, original_file_size, original_megapixels]
          description: >-
            Override the set's configured image order. `position` is the
            hand-arranged/custom sequence. Omit to use the set's own order.
        - name: order
          in: query
          schema: { type: string, enum: [asc, desc] }
          description: Direction for ?sort (defaults to the set's configured direction).
        - name: visible
          in: query
          schema: { type: boolean }
          description: >-
            Filter by the per-set embargo flag (image_sets_images.hidden). Omit
            for BOTH (admin default); true = only live (what the public gallery
            shows); false = only hidden/embargoed.
      responses:
        '200':
          description: Bare JSON array of images.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Image' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
    post:
      tags: [Sets]
      summary: Add images to a set (forward -> add_to_set)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ImageUuidsBody' }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Sets]
      summary: Remove images from a set (forward -> remove_from_set)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ImageUuidsBody' }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/sets/{public_uuid}/order:
    parameters:
      - { $ref: '#/components/parameters/SetUuid' }
    patch:
      tags: [Sets]
      summary: Reorder images within a set (forward -> update_set_image_order)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ImageUuidsBody' }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/sets/{public_uuid}/visibility:
    parameters:
      - { $ref: '#/components/parameters/SetUuid' }
    patch:
      tags: [Sets]
      summary: Set image visibility / go-live (forward -> update_set_image_visibility)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image_public_uuids, hidden]
              properties:
                image_public_uuids:
                  type: array
                  minItems: 1
                  items: { type: string }
                hidden:
                  type: boolean
                  description: Must be an unambiguous boolean (embargo switch).
                activation_time:
                  type: string
                  description: Optional ISO-8601 or unix-epoch embargo release time.
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/sets/{public_uuid}/snapshots:
    parameters:
      - { $ref: '#/components/parameters/SetUuid' }
    get:
      tags: [Sets]
      summary: A set's restore/undo history (bare array)
      description: >-
        Arrangement snapshots (image order + visibility + activation times) saved
        automatically before each reorder / go-live change, newest first. Restore
        one with POST /v1/sets/{public_uuid}/restore. `name` is optional and
        usually null (auto-saved, not hand-named); tell them apart by created_at.
      responses:
        '200':
          description: Bare JSON array of snapshots.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    uuid: { type: string }
                    name: { type: [string, 'null'] }
                    image_count: { type: [integer, 'null'] }
                    created_at: { $ref: '#/components/schemas/Epoch' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }

  /v1/sets/{public_uuid}/restore:
    parameters:
      - { $ref: '#/components/parameters/SetUuid' }
    post:
      tags: [Sets]
      summary: Restore a set to a snapshot (forward -> restore_set_image_state)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [snapshot_uuid]
              properties:
                snapshot_uuid: { type: string, description: "A uuid from the set's snapshots list." }
                restore_positions: { type: boolean, default: true }
                restore_hidden: { type: boolean, default: true }
                restore_activation_times: { type: boolean, default: true }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/sets/{public_uuid}/versions:
    parameters:
      - { $ref: '#/components/parameters/SetUuid' }
    get:
      tags: [Sets]
      summary: Distinct servable versions across the set (bare array)
      responses:
        '200':
          description: Bare JSON array of version rollups.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    name: { type: [string, 'null'] }
                    image_type: { type: [string, 'null'] }
                    image_subtype: { type: [string, 'null'] }
                    version: { type: [string, 'null'] }
                    image_count: { type: integer }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }

  # ==========================================================================
  # IMAGES
  # ==========================================================================
  /v1/images:
    get:
      tags: [Images]
      summary: List images (bare array)
      description: >-
        Newest first by default, paginated (limit default 50, max 200) — the
        response advertises `X-Has-More` and `X-Next-Offset` headers so you can
        page without a count. Filters combine (AND). ?query full-text search
        requires the images_search privilege and, when present, ranks by
        relevance unless ?sort overrides.
      parameters:
        - name: keyword
          in: query
          schema: { type: string }
          description: >-
            Exact stored keyword. For a namespaced keyword pass the FULL form
            (`artist:Marina`) OR — more usefully — pass the bare value here and
            the axis in `namespace` (see below), which mirrors what the Keyword
            object shows you.
        - name: namespace
          in: query
          schema: { type: string }
          description: >-
            Keyword namespace (the festival browse axis — artist / stage / day /
            photographer / ...). Alone -> images with any keyword in it; paired
            with `keyword` -> that bare value in this axis, i.e. the stored
            `namespace:keyword` (e.g. namespace=artist&keyword=Marina matches
            the stored "artist:Marina"). What you see in a Keyword object is
            exactly what you filter with.
        - name: automatic_keyword
          in: query
          schema: { type: string }
          description: AI/vision keyword (see GET /v1/automatic_keywords); pair with min_score.
        - name: min_score
          in: query
          schema: { type: number, default: 0.5 }
          description: Minimum confidence for automatic_keyword (0..1).
        - name: rating
          in: query
          schema: { type: integer }
          description: Exact editorial rating 0..5.
        - name: min_rating
          in: query
          schema: { type: integer }
          description: Editorial rating N and up (unrated images excluded).
        - name: has_video
          in: query
          schema: { type: boolean }
          description: >-
            Filter by whether the image has a servable video clip. true = only
            images with an MP4/WebM rendition; false = only images without.
        - name: has_geo
          in: query
          schema: { type: boolean }
          description: >-
            Filter by whether the image is geotagged (EXIF GPS). true = only
            images with coordinates; false = only images without.
        - name: bbox
          in: query
          schema: { type: string }
          description: >-
            Only images whose capture location falls inside a map bounding box.
            Format `west,south,east,north` (min_lng,min_lat,max_lng,max_lat —
            GeoJSON order), e.g. `-1.1,51.4,-0.9,51.5`.
        - name: query
          in: query
          schema: { type: string }
          description: >-
            Full-text search over the precomputed, FULLTEXT-indexed search cache
            (title + keywords + filename + date + EXIF + metadata), ranked by
            relevance. Boolean operators: +required -excluded "exact phrase"
            wild*. Defaults to relevance order (override with ?sort). Needs images_search.
        - name: sort
          in: query
          schema: { type: string, enum: [created_at, updated_at, creation_date, rating] }
          description: Sort column (default newest first; ignored default when ?query ranks by relevance).
        - name: order
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
        # --- three INDEPENDENT time axes, each an inclusive from/to range ---
        # Value may be a unix epoch, an ISO-8601 timestamp, or a bare YYYY-MM-DD
        # (a bare date as the _to bound covers the WHOLE day, so from=to=a date
        # returns that entire day). Pick the axis deliberately — they answer
        # different questions.
        - name: creation_date_from
          in: query
          schema: { type: string }
          description: >-
            When the photo was TAKEN (EXIF), lower bound. This is the axis PRs
            browse by — "everything shot on the 15th" / a festival day.
        - name: creation_date_to
          in: query
          schema: { type: string }
          description: When the photo was TAKEN (EXIF), upper bound.
        - name: created_at_from
          in: query
          schema: { type: string }
          description: When it was UPLOADED to DPG (ingest / change feed), lower bound.
        - name: created_at_to
          in: query
          schema: { type: string }
          description: When it was UPLOADED to DPG, upper bound.
        - name: updated_at_from
          in: query
          schema: { type: string }
          description: When the record was LAST MODIFIED (sync), lower bound.
        - name: updated_at_to
          in: query
          schema: { type: string }
          description: When the record was LAST MODIFIED, upper bound.
        - name: created_since
          in: query
          schema: { type: string }
          description: Alias of created_at_from (legacy change-feed name).
        - name: updated_since
          in: query
          schema: { type: string }
          description: Alias of updated_at_from (legacy change-feed name; also flips the default sort to updated_at).
        - name: exif_property
          in: query
          schema: { type: string }
          description: EXIF facet key (see GET /v1/exif); pair with exif_value.
        - name: exif_value
          in: query
          schema: { type: string }
        - name: property
          in: query
          schema: { type: string }
          description: Original-version property key (see GET /v1/properties); pair with property_value.
        - name: property_value
          in: query
          schema: { type: string }
        - name: metadata_property
          in: query
          schema: { type: string }
          description: IPTC/metadata facet key (see GET /v1/metadata, e.g. creator); pair with metadata_value.
        - name: metadata_value
          in: query
          schema: { type: string }
          description: e.g. metadata_property=creator&metadata_value=<photographer>.
        - name: duplicate_method
          in: query
          schema: { type: string, enum: [duplicate_files, duplicate_metadata] }
          description: Only images flagged as duplicates (see GET /v1/duplicates).
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
      responses:
        '200':
          description: Bare JSON array of images.
          headers:
            X-Has-More:
              schema: { type: boolean }
              description: Whether more results exist beyond this page.
            X-Next-Offset:
              schema: { type: integer }
              description: offset for the next page (present only when X-Has-More is true).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Image' }
        '401': { $ref: '#/components/responses/Error' }
        '403':
          description: forbidden (?query without images_search).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/images/{public_uuid}:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    get:
      tags: [Images]
      summary: Full image (bare object, includes note)
      responses:
        '200':
          description: A single image (with note).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Image' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
    patch:
      tags: [Images]
      summary: Update image fields (forward -> update)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, maxLength: 191 }
                description: { type: string, maxLength: 65535 }
                creation_date: { type: string, description: 'Parseable date/time (e.g. 2026-07-03 or 2026-07-03T18:30:00Z). Not epoch on writes.' }
                rotation: { type: [integer, string], enum: [0, 90, 180, 270, '0', '90', '180', '270'] }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Images]
      summary: Delete an image (forward -> destroy)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/versions:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    get:
      tags: [Images]
      summary: Servable versions for an image (bare array)
      description: Only versions with an aws_profile (a live URL) are returned.
      responses:
        '200':
          description: Bare JSON array of versions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ImageVersion' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/videos:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    get:
      tags: [Images]
      summary: Servable video renditions for an image (bare array)
      description: >-
        The image's DPG-hosted MP4/WebM video clips, one array element per codec
        rendition, ordered by browser codec preference (hevc, vp9, h264). Drop
        each into a <video> as <source src={url} type={source_type}>, and use one
        of the image's version urls as the poster. Only servable renditions (with
        a live URL) are returned; empty for images with no clip.
      responses:
        '200':
          description: Bare JSON array of video renditions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ImageVideo' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/keywords:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    post:
      tags: [Images]
      summary: Add keywords (forward -> add_keywords)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keywords]
              properties:
                keywords:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items: { type: string, maxLength: 191 }
                  description: >-
                    Keyword strings (max 200 per call, each <= 191 chars). To
                    namespace one, colon-qualify it ("artist:Marina"); the engine
                    derives the axis from the colon.
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Images]
      summary: Remove a keyword (forward -> remove_keyword)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword]
              properties:
                keyword:
                  type: string
                  maxLength: 191
                  description: >-
                    Full stored form ("artist:Marina") or the bare value when
                    `namespace` is supplied. Qualified form must be <= 191 chars.
                namespace:
                  type: string
                  maxLength: 191
                  description: >-
                    Optional axis; when present `keyword` is treated as the bare
                    value and qualified to "namespace:value".
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/rating:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    put:
      tags: [Images]
      summary: Set the image rating (forward -> update_rating)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rating]
              properties:
                rating: { type: integer, minimum: 0, maximum: 5 }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/metadata:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    put:
      tags: [Images]
      summary: Update IPTC/metadata fields (forward -> update_metadata_profile)
      description: Send any subset of the whitelisted fields; nil fields are dropped.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MetadataFields' }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/regenerate:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    post:
      tags: [Images]
      summary: Regenerate versions (forward -> generate_versions)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/note:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    put:
      tags: [Images]
      summary: Set the image note (forward -> update_image_note)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                text: { type: string, maxLength: 65535 }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/crop:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    put:
      tags: [Images]
      summary: Set a crop (forward -> update_image_crop)
      description: At least one crop field required; pixel fields must be numeric.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CropFields' }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Images]
      summary: Reset the crop (forward -> reset_image_crop)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                aspect_ratio:
                  type: string
                  default: unchanged
                  enum: [unchanged, square, '16:9', '9:16', '4:3', '4:1', '3:1', '2:1']
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/extract_metadata:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    post:
      tags: [Images]
      summary: Extract embedded metadata into editable fields (forward -> extract_metadata)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/alt_text:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    put:
      tags: [Images]
      summary: Set alt text (forward -> update_image_alternative_text_attribute)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                text: { type: string, maxLength: 65535 }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/images/{public_uuid}/credits:
    parameters:
      - { $ref: '#/components/parameters/ImageUuid' }
    post:
      tags: [Images]
      summary: Add a credit (forward -> add_image_credit)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                role: { type: string, maxLength: 191 }
                name: { type: string, minLength: 1, maxLength: 191 }
                url: { type: string, maxLength: 191 }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Images]
      summary: Remove a credit (forward -> remove_image_credit)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [credit_uuid]
              properties:
                credit_uuid: { type: string }
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  # ==========================================================================
  # FACETS
  # ==========================================================================
  /v1/exif:
    get:
      tags: [Facets]
      summary: Curated EXIF facet list (bare array)
      responses:
        '200':
          description: Bare JSON array of facet descriptors.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/FacetDescriptor' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }

  /v1/exif/{property}:
    get:
      tags: [Facets]
      summary: Distinct values + counts for an EXIF facet (object)
      parameters:
        - name: property
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Facet with values.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FacetValues' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404':
          description: facet_not_found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: facet_unavailable (backing table not readable).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/properties:
    get:
      tags: [Facets]
      summary: Original-version property facet list (bare array)
      responses:
        '200':
          description: Bare JSON array of facet descriptors (format, aspect_ratio).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/FacetDescriptor' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }

  /v1/properties/{key}:
    get:
      tags: [Facets]
      summary: Distinct values + counts for a property facet (object)
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Facet with values.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FacetValues' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404':
          description: facet_not_found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/duplicates:
    get:
      tags: [Facets]
      summary: Duplicate-image counts (bare array)
      description: >-
        Two methods — duplicate_files (same bytes) and duplicate_metadata (same
        embedded metadata) — each with how many images are flagged. Drill in with
        GET /v1/images?duplicate_method=duplicate_files.
      responses:
        '200':
          description: Bare JSON array of {method, name, image_count}.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    method: { type: string, enum: [duplicate_files, duplicate_metadata] }
                    name: { type: string }
                    image_count: { type: integer }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }

  /v1/checksums/check:
    post:
      tags: [Images]
      summary: Import dedup pre-check
      description: >-
        Given file checksums, returns which already exist (so an importing agent
        can skip re-uploading duplicates). Matches image_versions.checksum,
        account-wide or scoped to a set. Max 1000 checksums.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [checksums]
              properties:
                checksums:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items: { type: string, maxLength: 128 }
                  description: File checksum hex digests (max 1000, each <= 128 chars).
                set_public_uuid: { type: string, description: Scope matching to this set only. }
      responses:
        '200':
          description: The subset of the supplied checksums already present.
          content:
            application/json:
              schema:
                type: object
                properties:
                  present:
                    type: array
                    items: { type: string }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404':
          description: set_not_found (bad set_public_uuid).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/metadata:
    get:
      tags: [Facets]
      summary: IPTC/metadata facet list (bare array)
      description: >-
        Metadata browse axes (currently `creator` = photographer). Drill in with
        GET /v1/metadata/{key}, then filter with
        GET /v1/images?metadata_property=creator&metadata_value=<name>.
      responses:
        '200':
          description: Bare JSON array of facet descriptors.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/FacetDescriptor' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }

  /v1/metadata/{key}:
    get:
      tags: [Facets]
      summary: Distinct values + counts for a metadata facet (e.g. every photographer)
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Facet with values.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FacetValues' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404':
          description: facet_not_found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ==========================================================================
  # KEYWORDS
  # ==========================================================================
  /v1/keywords:
    get:
      tags: [Keywords]
      summary: Account keyword vocabulary (bare array)
      description: >-
        Merges applied image_keywords + account_predefined_keywords. limit
        default 100, max 500.
      parameters:
        - name: namespace
          in: query
          schema: { type: string }
        - name: q
          in: query
          schema: { type: string }
          description: Case-insensitive substring match.
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
      responses:
        '200':
          description: Bare JSON array of keywords (each carries a count).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Keyword' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }

  /v1/keywords/namespaces:
    get:
      tags: [Keywords]
      summary: List keyword namespaces (the browse dimensions)
      description: >-
        The account's keyword namespaces — for a festival these are the
        navigation axes (artist, stage, day, photographer, year, area, ...). Each
        carries how many distinct keywords it holds and how many images carry a
        keyword in it. Drill in with GET /v1/keywords?namespace=<ns>, then pull
        images with GET /v1/images?namespace=<ns>&keyword=<value>.
      responses:
        '200':
          description: Bare JSON array of {namespace, keyword_count, image_count}.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    namespace: { type: string }
                    keyword_count: { type: integer }
                    image_count: { type: integer }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }

  /v1/automatic_keywords:
    get:
      tags: [Keywords]
      summary: Browse AI/vision keywords (discovery)
      description: >-
        The machine-generated (vision) keyword layer — the highest-value
        discovery axis when human keywording is sparse. A HIGH-CONFIDENCE list:
        each keyword carries an image_count and top_score. Filter images at any
        confidence with GET /v1/images?automatic_keyword=<kw>&min_score= (fast,
        index-only). ETag-cached.
      parameters:
        - name: min_score
          in: query
          schema: { type: number, default: 0.9, minimum: 0.5, maximum: 1 }
          description: Count applications at/above this confidence (clamped to 0.5..1.0).
        - name: q
          in: query
          schema: { type: string }
          description: Case-insensitive substring match.
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
      responses:
        '200':
          description: Bare JSON array of {keyword, image_count, top_score}, by image_count desc.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    keyword: { type: string }
                    image_count: { type: integer }
                    top_score: { type: number }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }

  /v1/keywords/rename:
    post:
      tags: [Keywords]
      summary: Rename a keyword across images (forward -> edit_keyword)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image_public_uuids, from, to]
              properties:
                image_public_uuids:
                  type: array
                  minItems: 1
                  items: { type: string }
                from:
                  type: string
                  maxLength: 191
                  description: >-
                    Existing keyword. Full stored form ("artist:Marina") or the
                    bare value when `namespace` is supplied. Qualified form <= 191.
                to:
                  type: string
                  maxLength: 191
                  description: New keyword (same bare/full rules as `from`; qualified <= 191).
                namespace:
                  type: string
                  maxLength: 191
                  description: >-
                    Optional axis. When present, `from`/`to` are treated as bare
                    values and qualified as "namespace:value" (lowercased axis),
                    so what you browse is what you rename.
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  # ==========================================================================
  # PROFILES
  # ==========================================================================
  /v1/metadata_profiles:
    get:
      tags: [Profiles]
      summary: Master metadata profiles (bare array)
      responses:
        '200':
          description: Bare JSON array of master (image_id IS NULL) profiles.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/MetadataProfile' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
    post:
      tags: [Profiles]
      summary: Create a master metadata profile (forward -> create_metadata_profile)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/MetadataFields'
                - type: object
                  required: [name]
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/version_profiles:
    get:
      tags: [Profiles]
      summary: Version (output-size) profiles (bare array)
      responses:
        '200':
          description: Bare JSON array of version profiles.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/VersionProfile' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }

  # ==========================================================================
  # UPLOADS
  # ==========================================================================
  /v1/uploads:
    post:
      tags: [Uploads]
      summary: Signed upload ticket (client posts the file directly to imageprocessor)
      description: >-
        Returns a short-lived HMAC-signed ticket. ALL metadata (filename, title,
        description, keywords, creation_date, set_public_uuid, read_metadata) is
        passed in THIS request's JSON body — it is validated and baked into the
        signed `fields`, so the client only echoes `fields` and can never send
        unvalidated free text straight to the engine. The client then POSTs
        multipart/form-data to upload_url with every entry of fields, a file part
        named file_field, and (optionally) any optional_fields (engine part
        names). imageprocessor responds with { result, transaction_uuid,
        image_public_uuid, image_uuid, image_rule_matches } — use
        image_public_uuid to GET /v1/images/{that} (image_uuid is the legacy id).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                filename: { type: string, maxLength: 191 }
                title: { type: string, maxLength: 191 }
                description:
                  type: string
                  maxLength: 65535
                  description: "Up to 65535 BYTES (MySQL TEXT); multi-byte characters count as their byte length."
                keywords:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items: { type: string, maxLength: 191 }
                  description: 'Colon-qualify a keyword to namespace it ("artist:Marina").'
                read_metadata:
                  type: boolean
                  description: Whether the engine should read embedded IPTC/EXIF metadata on ingest.
                set_public_uuid:
                  type: string
                  description: Set to file the image into (validated; 404 set_not_found if unknown).
                creation_date:
                  type: string
                  description: "Capture date/time (e.g. 2026-07-03 or 2026-07-03T18:30:00Z); parseable, baked as image_creation_date."
      responses:
        '201':
          description: Signed upload ticket.
          content:
            application/json:
              schema:
                type: object
                properties:
                  upload_url: { type: string }
                  file_field: { type: string, description: "Multipart field name for the file bytes (always 'file')." }
                  transaction_uuid: { type: string }
                  fields:
                    type: object
                    description: HMAC-signed envelope to include verbatim in the multipart POST.
                    properties:
                      access_key: { type: string }
                      timestamp: { type: string }
                      hmac_signature: { type: string }
                      account: { type: string }
                      account_uuid: { type: string }
                      user_uuid: { type: string }
                      request: { type: string }
                      client_uuid: { type: string }
                      response_format: { type: string }
                      transaction_uuid: { type: string }
                  optional_fields:
                    type: array
                    items: { type: string }
                    description: >-
                      Extra multipart parts the client MAY add (engine vocabulary).
                      Now only ["metadata_profile"] — title / description / keywords /
                      read_metadata / creation_date / set are validated and baked into
                      `fields` from THIS request, so pass them here, not as raw parts.
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '503':
          description: processing_server_unavailable (no access key / server).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ==========================================================================
  # DOWNLOADS
  # ==========================================================================
  /v1/downloads:
    post:
      tags: [Downloads]
      summary: Resolve a download URL for one image version
      description: >-
        Local read (no Operation). Without version, the largest version is
        chosen; with version, matches by public_uuid, version_name, then name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image_public_uuid]
              properties:
                image_public_uuid: { type: string }
                version: { type: string, description: Version public_uuid, version_name, or name. }
      responses:
        '200':
          description: >-
            Presigned, expiring download. `url` is a presigned S3 attachment URL
            valid until `expires_at`, and works for public AND private renditions
            (including the original). `access` echoes the version's visibility.
          content:
            application/json:
              schema:
                type: object
                required: [url, expires_at]
                properties:
                  url: { type: string, description: Presigned S3 attachment URL. }
                  filename: { type: [string, 'null'] }
                  access: { type: [string, 'null'], enum: [public, private, null] }
                  expires_at: { $ref: '#/components/schemas/Epoch' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '503':
          description: Presigned URL could not be generated (download_unavailable).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: image_not_found or version not_found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ==========================================================================
  # ARCHIVES
  # ==========================================================================
  /v1/archives:
    get:
      tags: [Images]
      summary: List the account's live archive bundles
      description: >-
        Previously-built ZIP archives that haven't expired, newest first, each
        with a shareable download url, size, filename, image_count and expiry.
        (POST /v1/archives builds a new one.)
      responses:
        '200':
          description: Bare JSON array of archives.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    uuid: { type: string }
                    filename: { type: [string, 'null'] }
                    file_size: { type: [integer, 'null'] }
                    mime_type: { type: [string, 'null'] }
                    image_count: { type: integer }
                    url: { type: [string, 'null'], description: Shareable https download link. }
                    expires_at: { $ref: '#/components/schemas/Epoch' }
                    created_at: { $ref: '#/components/schemas/Epoch' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
    post:
      tags: [Operations]
      summary: Build a ZIP archive of images (forward -> create_archive)
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image_public_uuids]
              properties:
                image_public_uuids:
                  type: array
                  minItems: 1
                  items: { type: string }
                version_profile_uuid:
                  type: string
                  description: Defaults to the account setting when omitted.
                filename:
                  type: string
                  maxLength: 150
                  description: >-
                    Archive filename (without extension), max 150 chars (headroom
                    under the engine's de-dup suffix + .zip). Defaults to a
                    timestamped "archive-YYYYMMDD-HHMMSS" name when omitted.
      responses:
        '202': { $ref: '#/components/responses/Accepted' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404':
          description: version_profile_not_found or image_not_found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409': { $ref: '#/components/responses/Error' }

  # ==========================================================================
  # OPERATIONS
  # ==========================================================================
  /v1/operations/batch:
    post:
      tags: [Operations]
      summary: Fan out a batch of write ops (max 50)
      description: >-
        Atomic admission: every entry is validated (known op, owned target,
        granted privilege, valid fields) before any forward. Each entry then
        forwards to a child Operation. A per-child idempotency conflict is
        reported inline, not aborting the batch.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operations]
              properties:
                operations:
                  type: array
                  maxItems: 50
                  items: { $ref: '#/components/schemas/BatchEntry' }
      responses:
        '202':
          description: Batch rollup + per-entry results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [succeeded, failed, partial, processing]
                  idempotency_key: { type: string }
                  operations:
                    type: array
                    items:
                      oneOf:
                        - $ref: '#/components/schemas/Operation'
                        - $ref: '#/components/schemas/BatchConflictEntry'
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }

  /v1/operations/{uuid}:
    get:
      tags: [Operations]
      summary: Poll one operation (bare object)
      parameters:
        - name: uuid
          in: path
          required: true
          schema: { type: string }
          description: Operation uuid.
      responses:
        '200':
          description: The operation.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Operation' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }

# ============================================================================
# COMPONENTS
# ============================================================================
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Authorization: Bearer <token>. Two token kinds: an opaque signed access
        token (from POST /v1/auth/token with account_uuid), or a dpg_api_*
        service token. Not required on the /v1/auth/* endpoints.

  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      description: Page size (default 50, max 200; out-of-range values are clamped).
    Offset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, maximum: 9223372036854775807, default: 0 }
      description: Row offset (default 0; clamped to a safe maximum).
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        maxLength: 191
        pattern: '^[A-Za-z0-9\-_.:+/=]+$'
      description: >-
        Required on all writes. Max 191 chars, charset [A-Za-z0-9-_.:+/=] (UUID /
        ULID / base64 all fit). A reused key with a DIFFERENT body is a
        409 idempotency_key_conflict.
    SetUuid:
      name: public_uuid
      in: path
      required: true
      schema: { type: string }
      description: Set public_uuid.
    ImageUuid:
      name: public_uuid
      in: path
      required: true
      schema: { type: string }
      description: Image public_uuid.

  responses:
    Error:
      description: Error envelope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Accepted:
      description: Accepted; poll the returned Operation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Operation' }

  schemas:
    Epoch:
      type: [integer, 'null']
      format: int64
      description: Unix epoch seconds.

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              type: string
              description: Stable machine-readable error code.
              enum:
                - unauthorized
                - forbidden
                - api_not_enabled
                - rate_limited
                - payload_too_large
                - not_found
                - validation
                - internal
                - method_not_allowed
                - not_acceptable
                - unsupported_media_type
                - invalid_credentials
                - locked
                - unconfirmed
                - mfa_invalid
                - mfa_token_invalid
                - refresh_token_invalid
                - refresh_token_reused
                - account_forbidden
                - idempotency_key_conflict
                - operation_failed
                - upload_incomplete
                - upload_checksum_mismatch
                - image_not_found
                - set_not_found
                - version_not_found
                - version_profile_not_found
                - facet_not_found
                - facet_unavailable
                - download_unavailable
                - processing_server_unavailable
            message: { type: string }
            detail: { type: [string, 'null'] }
            field: { type: [string, 'null'] }
            request_id: { type: string }

    Operation:
      type: object
      description: >-
        The public form of an AppOperation. Returned (202) by every write and by
        GET /v1/operations/{uuid}. http_status is stripped from result. Poll
        GET /v1/operations/{uuid} until `status` is `succeeded` or `failed`.
      example:
        uuid: "b1e2c3d4-0000-4a1b-9c2d-3e4f5a6b7c8d"
        status: "succeeded"
        type: "update"
        idempotency_key: "3f9c1a70-1111-4b2c-8d3e-4f5a6b7c8d9e"
        engine_transaction_uuid: "9a8b7c6d-2222-4e5f-a1b2-c3d4e5f6a7b8"
        image_public_uuids: ["c0ffee00-3333-4d5e-8f90-a1b2c3d4e5f6"]
        result: { message: "Updated." }
        error: null
        progress: null
        created_at: 1783000000
        updated_at: 1783000002
        completed_at: 1783000002
      properties:
        uuid: { type: string }
        status:
          type: string
          enum: [queued, succeeded, failed]
        type: { type: string, description: Engine op name (e.g. update, add_to_set, create_set). }
        idempotency_key: { type: string }
        engine_transaction_uuid: { type: [string, 'null'] }
        image_public_uuids:
          type: array
          items: { type: string }
        result: { type: [object, 'null'] }
        error: { type: [string, 'null'] }
        progress: { type: [number, 'null'] }
        created_at: { $ref: '#/components/schemas/Epoch' }
        updated_at: { $ref: '#/components/schemas/Epoch' }
        completed_at: { $ref: '#/components/schemas/Epoch' }

    Account:
      type: object
      properties:
        uuid: { type: string }
        name: { type: string }
        unique_id: { type: [string, 'null'] }

    User:
      type: object
      properties:
        uuid: { type: string }
        email: { type: [string, 'null'] }
        username: { type: [string, 'null'] }
        display_name: { type: [string, 'null'] }
        avatar_url: { type: [string, 'null'] }
        locale: { type: [string, 'null'] }

    Device:
      type: object
      properties:
        uuid: { type: string }
        platform: { type: [string, 'null'] }
        device_name: { type: [string, 'null'] }
        device_model: { type: [string, 'null'] }
        os_version: { type: [string, 'null'] }
        app_version: { type: [string, 'null'] }
        last_seen_at: { $ref: '#/components/schemas/Epoch' }
        last_seen_ip: { type: [string, 'null'] }
        created_at: { $ref: '#/components/schemas/Epoch' }
        current: { type: boolean }

    DeviceInput:
      type: object
      description: Client device attributes captured at sign-in / MFA.
      properties:
        platform: { type: string }
        device_name: { type: string }
        device_model: { type: string }
        os_version: { type: string }
        app_version: { type: string }
        apns_push_token: { type: string }

    MfaChallenge:
      type: object
      properties:
        mfa_required: { type: boolean, const: true }
        mfa_token: { type: string }
        expires_in: { type: integer, description: Seconds until the challenge expires. }

    SignInSuccess:
      type: object
      description: Account-less refresh token + the accounts the user can pick from.
      example:
        refresh_token: "rt_9f8e7d6c5b4a3210fedcba9876543210"
        expires_in_refresh: 5184000
        accounts:
          - { uuid: "a1b2c3d4-4444-4a5b-8c9d-0e1f2a3b4c5d", name: "My Festival", unique_id: "myfest" }
      properties:
        refresh_token: { type: string }
        expires_in_refresh: { type: [integer, 'null'], description: Seconds until the refresh token expires. }
        accounts:
          type: array
          items: { $ref: '#/components/schemas/Account' }

    TokenSuccess:
      type: object
      description: Access token minted after selecting an account.
      example:
        access_token: "eyJfcmFpbHMi...signed-blob...abc123"
        expires_in: 900
        refresh_token: "rt_0011223344556677889900aabbccddeeff"
      properties:
        access_token: { type: string }
        expires_in: { type: integer, description: Seconds until the access token expires. }
        refresh_token: { type: string }

    Keyword:
      type: object
      description: >-
        A namespaced keyword presented three ways so display and filtering line
        up. DPG stores the value as "namespace:value"; this object splits it.
      properties:
        keyword:
          type: string
          description: The bare value with any namespace prefix stripped ("Marina", "sunset").
        namespace:
          type: [string, 'null']
          description: The browse axis ("artist"), or null for an un-namespaced keyword.
        namespaced_keyword:
          type: string
          description: >-
            The full stored form ("artist:Marina", or just "sunset" when
            un-namespaced). Pass keyword+namespace to GET /v1/images to filter.
        count:
          type: integer
          description: Number of the account's images carrying this keyword (omitted on image-bound keywords).

    MetadataFields:
      type: object
      description: >-
        IPTC-ish fields accepted by metadata writes; send any subset. String
        fields are varchar(191); copyright_notice and usage_terms are TEXT (up to
        65535 BYTES); copyright_marked is a strict boolean.
      properties:
        name: { type: string, maxLength: 191 }
        creator: { type: string, maxLength: 191 }
        copyright_url: { type: string, maxLength: 191 }
        copyright_notice: { type: string, maxLength: 65535 }
        copyright_marked: { type: boolean }
        usage_terms: { type: string, maxLength: 65535 }
        contact_email: { type: string, maxLength: 191 }
        contact_telephone: { type: string, maxLength: 191 }
        creator_address: { type: string, maxLength: 191 }
        creator_city: { type: string, maxLength: 191 }
        creator_region: { type: string, maxLength: 191 }
        creator_postal_code: { type: string, maxLength: 191 }
        creator_country: { type: string, maxLength: 191 }
        instagram_handle: { type: string, maxLength: 191 }
        twitter_handle: { type: string, maxLength: 191 }

    MetadataProfile:
      type: object
      properties:
        uuid: { type: string }
        name: { type: [string, 'null'] }
        creator: { type: [string, 'null'] }
        copyright_url: { type: [string, 'null'] }
        copyright_notice: { type: [string, 'null'] }
        copyright_marked: { type: [boolean, 'null'] }
        usage_terms: { type: [string, 'null'] }
        contact_email: { type: [string, 'null'] }
        contact_telephone: { type: [string, 'null'] }
        creator_address: { type: [string, 'null'] }
        creator_city: { type: [string, 'null'] }
        creator_region: { type: [string, 'null'] }
        creator_postal_code: { type: [string, 'null'] }
        creator_country: { type: [string, 'null'] }
        instagram_handle: { type: [string, 'null'] }
        twitter_handle: { type: [string, 'null'] }

    VersionProfile:
      type: object
      properties:
        uuid: { type: string }
        name: { type: [string, 'null'] }
        image_type: { type: [string, 'null'] }
        image_subtype: { type: [string, 'null'] }
        version: { type: [string, 'null'] }
        width: { type: [integer, 'null'] }
        height: { type: [integer, 'null'] }
        format: { type: [string, 'null'] }
        access: { type: [string, 'null'] }
        pixel_density: { type: [number, 'null'] }

    CropFields:
      type: object
      description: >-
        At least one field required. Pixel fields are whole non-negative integers
        (floats are rejected); dimensions (width/height) must be >= 1; every pixel
        value is <= 100000. aspect_ratio is from the fixed vocabulary.
      properties:
        aspect_ratio: { type: string, enum: [unchanged, square, '16:9', '9:16', '4:3', '4:1', '3:1', '2:1'] }
        image_width: { type: [integer, string], minimum: 1, maximum: 100000 }
        image_height: { type: [integer, string], minimum: 1, maximum: 100000 }
        crop_x: { type: [integer, string], minimum: 0, maximum: 100000 }
        crop_y: { type: [integer, string], minimum: 0, maximum: 100000 }
        crop_width: { type: [integer, string], minimum: 1, maximum: 100000 }
        crop_height: { type: [integer, string], minimum: 1, maximum: 100000 }

    FacetDescriptor:
      type: object
      properties:
        key: { type: string }
        name: { type: string }
        unit: { type: string, description: Present only for facets with a unit. }

    FacetValues:
      type: object
      properties:
        key: { type: string }
        name: { type: string }
        unit: { type: string }
        values:
          type: array
          items:
            type: object
            properties:
              value: { type: string }
              count: { type: integer }

    ImageSet:
      type: object
      properties:
        public_uuid: { type: string }
        name: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        set_type: { type: [string, 'null'] }
        parent_public_uuid: { type: [string, 'null'] }
        lead_image_public_uuid:
          type: [string, 'null']
          description: The set's cover/lead image (for rendering a set grid), or null if none set.
        children_count: { type: integer }
        image_count: { type: integer }
        colour: { type: [string, 'null'] }
        hidden: { type: [boolean, 'null'] }
        image_sort:
          type: object
          description: >-
            How this set's images are ordered — the same order GET .../images
            returns by default, matching the public gallery.
          properties:
            order:
              type: string
              description: Sort key (e.g. position, creation_date, created_at). Echo back as ?sort=.
            direction: { type: string, enum: [asc, desc] }
            custom:
              type: boolean
              description: true when order == position (a hand-arranged sequence).
        child_sort:
          type: object
          description: >-
            How this set's CHILD sets are ordered — the default order GET
            /v1/sets?parent_public_uuid=<this> returns, matching the website tree.
          properties:
            order:
              type: string
              description: Sort key (position, name, start_date, created_at, updated_at). Echo back as ?sort=.
            direction: { type: string, enum: [asc, desc] }
            custom:
              type: boolean
              description: true when order == position (hand-arranged children).
        created_at: { $ref: '#/components/schemas/Epoch' }
        updated_at: { $ref: '#/components/schemas/Epoch' }
        website:
          type: object
          properties:
            bound: { type: boolean }
            name: { type: [string, 'null'] }

    SetTreeNode:
      type: object
      description: A node in GET /v1/sets/tree — labels + nested children.
      properties:
        public_uuid: { type: string }
        name: { type: [string, 'null'] }
        set_type: { type: [string, 'null'] }
        colour: { type: [string, 'null'] }
        hidden: { type: [boolean, 'null'] }
        lead_image_public_uuid:
          type: [string, 'null']
          description: The set's cover/lead image, or null if none set.
        children:
          type: array
          items: { $ref: '#/components/schemas/SetTreeNode' }

    ImageVersion:
      type: object
      description: >-
        A servable rendition, identified in the account's own version-profile
        terms. `version_profile_uuid` links to GET /v1/version_profiles (the
        account's catalog). `access` is "public" or "private": a PUBLIC version
        exposes a direct `url` (https CDN); a PRIVATE version (e.g. the original,
        stored private on S3) has `url: null` and must be fetched via
        POST /v1/downloads, which returns a presigned, expiring URL. Versions with
        no live storage location are omitted from Image.versions entirely.
      properties:
        public_uuid: { type: string }
        version_profile_uuid:
          type: [string, 'null']
          description: The account version profile this rendition was built from.
        name: { type: [string, 'null'] }
        image_type: { type: [string, 'null'] }
        image_subtype: { type: [string, 'null'] }
        version: { type: [string, 'null'] }
        pixel_density:
          type: [integer, 'null']
          description: Retina factor (1, 2, 3) so the client can pick the right @Nx asset.
        access:
          type: [string, 'null']
          enum: [public, private, null]
          description: public -> use `url` directly; private -> `url` is null, use POST /v1/downloads.
        format: { type: [string, 'null'] }
        width: { type: [integer, 'null'] }
        height: { type: [integer, 'null'] }
        file_size: { type: [integer, 'null'] }
        aspect_ratio: { type: [string, 'null'] }
        orientation: { type: [string, 'null'] }
        megapixels: { type: [number, 'null'] }
        average_colour: { type: [string, 'null'] }
        number_of_colours: { type: [integer, 'null'] }
        checksum: { type: [string, 'null'] }
        url:
          type: [string, 'null']
          description: https CDN URL for PUBLIC versions; null for private (see `access`).
        last_generated: { $ref: '#/components/schemas/Epoch' }
        created_at: { $ref: '#/components/schemas/Epoch' }
        updated_at: { $ref: '#/components/schemas/Epoch' }

    ImageVideo:
      type: object
      description: >-
        A DPG-hosted MP4/WebM video rendition attached to an image. An image may
        carry several (different codecs) for browser coverage; build a <video>
        with one <source src={url} type={source_type}> per rendition, in order,
        and use an image version url as the poster.
      properties:
        public_uuid: { type: string }
        name: { type: [string, 'null'] }
        url:
          type: [string, 'null']
          description: https CDN URL (host follows the account's api_cdn), or null if not servable.
        mime_type:
          type: [string, 'null']
          description: Container type, e.g. video/mp4 or video/webm.
        source_type:
          type: [string, 'null']
          description: >-
            Ready-made value for a <source type="...">, e.g. 'video/webm;
            codecs=vp9,opus' (bare 'video/mp4' for h264).
        width: { type: [integer, 'null'] }
        height: { type: [integer, 'null'] }
        duration: { type: [number, 'null'], description: Seconds. }
        frame_rate: { type: [number, 'null'] }
        video_codec: { type: [string, 'null'], description: e.g. h264, hevc, vp9. }
        audio_codec: { type: [string, 'null'], description: e.g. aac, opus; null if silent. }
        bitrate: { type: [integer, 'null'] }
        file_size: { type: [integer, 'null'], description: Bytes. }
        position: { type: [integer, 'null'] }

    Image:
      type: object
      description: >-
        uuid IS the public_uuid (there is no separate public_uuid or
        thumbnail_url field). versions lists only SERVABLE versions (those with
        an aws_profile / a live URL); versions without one are omitted, matching
        the websites. note is present only on GET /v1/images/{public_uuid}.
      properties:
        public_uuid: { type: string }
        title: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        alt_text:
          type: [string, 'null']
          description: Accessibility alt (stored value, else falls back to the title).
        original_filename: { type: [string, 'null'] }
        unique_name: { type: [string, 'null'] }
        creation_date: { $ref: '#/components/schemas/Epoch' }
        rotation: { type: [integer, 'null'] }
        processing: { type: [boolean, 'null'] }
        verified: { type: [boolean, 'null'] }
        live: { type: boolean, description: processing == false && verified == true. }
        rating: { type: [integer, 'null'] }
        keywords:
          type: array
          items: { $ref: '#/components/schemas/Keyword' }
        credits:
          type: array
          description: Additional credit lines (role/name/url); the photographer is separate.
          items:
            type: object
            properties:
              uuid: { type: string }
              role: { type: [string, 'null'] }
              name: { type: [string, 'null'] }
              url: { type: [string, 'null'] }
        metadata:
          oneOf:
            - $ref: '#/components/schemas/MetadataProfile'
            - type: 'null'
        geo:
          type: [object, 'null']
          description: >-
            Capture location from EXIF GPS (decimal degrees), or null if the photo
            isn't geotagged. Plot with the ?has_geo / ?bbox finder filters.
          properties:
            latitude: { type: number }
            longitude: { type: number }
        versions:
          type: array
          items: { $ref: '#/components/schemas/ImageVersion' }
        videos:
          type: array
          description: >-
            Servable MP4/WebM video renditions attached to this image (a DPG
            video-clip-on-a-photo), codec-ordered. Empty for most images.
          items: { $ref: '#/components/schemas/ImageVideo' }
        sets:
          type: array
          items:
            type: object
            properties:
              set_public_uuid: { type: [string, 'null'] }
              position: { type: [integer, 'null'] }
              hidden: { type: [boolean, 'null'] }
              activation_time: { $ref: '#/components/schemas/Epoch' }
        created_at: { $ref: '#/components/schemas/Epoch' }
        updated_at: { $ref: '#/components/schemas/Epoch' }
        note:
          type: [string, 'null']
          description: Only on GET /v1/images/{public_uuid}.
        automatic_keywords:
          type: array
          description: >-
            Top automatic (AI/vision) keywords for this image, highest-confidence
            first. Only on GET /v1/images/{public_uuid} (detail).
          items:
            type: object
            properties:
              keyword: { type: string }
              score: { type: number }

    ImageUuidsBody:
      type: object
      required: [image_public_uuids]
      properties:
        image_public_uuids:
          type: array
          minItems: 1
          items: { type: string }
          description: Image public_uuids (non-empty; duplicates are de-duped).

    BatchEntry:
      type: object
      required: [op, target]
      description: >-
        One batch operation. target is an image public_uuid, except for
        add_to_set / remove_from_set where target is a set public_uuid and the
        images travel in images[]. Only the op-relevant fields below are read.
      properties:
        op:
          type: string
          enum:
            - update
            - destroy
            - add_keywords
            - remove_keyword
            - update_rating
            - add_to_set
            - remove_from_set
            - update_metadata_profile
            - generate_versions
            - update_image_crop
        target: { type: string }
        images:
          type: array
          items: { type: string }
          description: Image public_uuids (add_to_set / remove_from_set).
        keywords:
          type: array
          minItems: 1
          maxItems: 200
          items: { type: string, maxLength: 191 }
          description: add_keywords.
        keyword: { type: string, maxLength: 191, description: 'remove_keyword (bare value or "namespace:value").' }
        namespace: { type: string, maxLength: 191, description: 'remove_keyword: optional axis; `keyword` is then the bare value, qualified to "namespace:value".' }
        rating: { type: integer, minimum: 0, maximum: 5, description: update_rating. }
        title: { type: string, maxLength: 191, description: update. }
        description: { type: string, maxLength: 65535, description: 'update (up to 65535 BYTES).' }
        creation_date: { type: string, description: 'update: parseable date/time.' }
        rotation: { type: [integer, string], enum: [0, 90, 180, 270, '0', '90', '180', '270'], description: update. }
        name: { type: string, description: update_metadata_profile. }
        creator: { type: string, description: update_metadata_profile. }
        copyright_url: { type: string, description: update_metadata_profile. }
        copyright_notice: { type: string, description: update_metadata_profile. }
        copyright_marked: { type: boolean, description: update_metadata_profile. }
        usage_terms: { type: string, description: update_metadata_profile. }
        contact_email: { type: string, description: update_metadata_profile. }
        contact_telephone: { type: string, description: update_metadata_profile. }
        creator_address: { type: string, description: update_metadata_profile. }
        creator_city: { type: string, description: update_metadata_profile. }
        creator_region: { type: string, description: update_metadata_profile. }
        creator_postal_code: { type: string, description: update_metadata_profile. }
        creator_country: { type: string, description: update_metadata_profile. }
        instagram_handle: { type: string, description: update_metadata_profile. }
        twitter_handle: { type: string, description: update_metadata_profile. }
        aspect_ratio: { type: string, enum: [unchanged, square, '16:9', '9:16', '4:3', '4:1', '3:1', '2:1'], description: update_image_crop. }
        image_width: { type: [integer, string], minimum: 1, maximum: 100000, description: update_image_crop. }
        image_height: { type: [integer, string], minimum: 1, maximum: 100000, description: update_image_crop. }
        crop_x: { type: [integer, string], minimum: 0, maximum: 100000, description: update_image_crop. }
        crop_y: { type: [integer, string], minimum: 0, maximum: 100000, description: update_image_crop. }
        crop_width: { type: [integer, string], minimum: 1, maximum: 100000, description: update_image_crop. }
        crop_height: { type: [integer, string], minimum: 1, maximum: 100000, description: update_image_crop. }

    BatchConflictEntry:
      type: object
      description: A batch entry whose per-child idempotency key conflicted.
      properties:
        op: { type: string }
        target: { type: string }
        status: { type: string, const: conflict }
        error: { type: string, const: idempotency_key_conflict }
