> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gumnut.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload a new asset

> Uploads a new asset (image or video) and its metadata as multipart form data, returning the created asset with 201. Uploads are deduplicated per library by the file's SHA-256 checksum: re-uploading a file whose bytes already exist in the target library stores nothing and returns the existing asset with 200. Storage caps are checked before the duplicate lookup, so an upload is refused with 507 whenever the account or the target library is already at its storage cap — even when the bytes would have deduplicated to an existing asset. A transient upstream storage error returns 502 — retryable after the `Retry-After` interval. When `library_id` is omitted and no default library can be chosen (the account has multiple live libraries), the request is refused with 400. Image metadata is extracted before the response returns; the rest of processing (thumbnails, search indexing, face detection, and video metadata extraction) continues asynchronously after the response.



## OpenAPI

````yaml https://api.gumnut.ai/openapi.json post /api/assets
openapi: 3.1.0
info:
  title: Gumnut API
  description: >-
    The Gumnut API manages photo and video libraries: upload and organize
    assets, search by content, group detected faces into people, and curate
    albums and stacks.


    Headless clients authenticate by sending either a Gumnut API key
    (`apikey_...`) or an OAuth access token as a Bearer token in the
    `Authorization` header. The Gumnut web and mobile apps sign in with a
    first-party session (Clerk session JWT) instead, which is also the only
    credential that can manage API keys.
  contact:
    name: Gumnut
    url: https://www.gumnut.ai/contact
  version: 1.0.0
servers:
  - url: https://api.gumnut.ai
    description: Production
security:
  - apiKeyAuth: []
  - oauthJwtAuth: []
tags:
  - name: assets
    description: >-
      Photos and videos in a library: upload, list and filter, update metadata,
      trash and restore.
  - name: search
    description: >-
      Content-based search over a library's assets, with the same filters as
      asset listing.
  - name: albums
    description: User-curated collections of assets.
  - name: album-assets
    description: Link records connecting albums to their member assets.
  - name: stacks
    description: >-
      Groups of related shots of the same moment, presented as a single unit
      with a cover asset.
  - name: people
    description: Named people, each built from clustered faces.
  - name: faces
    description: Detected faces and their assignment to people.
  - name: libraries
    description: Top-level containers that own assets, albums, people, and everything else.
  - name: events
    description: Change-event feed for keeping client state in sync.
  - name: tasks
    description: Status of background processing tasks.
  - name: users
    description: The authenticated user's profile.
  - name: api-keys
    description: >-
      Create and manage Gumnut API keys. Requires a first-party Gumnut app
      session — API keys and OAuth tokens cannot manage credentials.
  - name: oauth
    description: OAuth flow endpoints for obtaining and refreshing access tokens.
  - name: server
    description: Service health.
paths:
  /api/assets:
    post:
      tags:
        - assets
      summary: Upload a new asset
      description: >-
        Uploads a new asset (image or video) and its metadata as multipart form
        data, returning the created asset with 201. Uploads are deduplicated per
        library by the file's SHA-256 checksum: re-uploading a file whose bytes
        already exist in the target library stores nothing and returns the
        existing asset with 200. Storage caps are checked before the duplicate
        lookup, so an upload is refused with 507 whenever the account or the
        target library is already at its storage cap — even when the bytes would
        have deduplicated to an existing asset. A transient upstream storage
        error returns 502 — retryable after the `Retry-After` interval. When
        `library_id` is omitted and no default library can be chosen (the
        account has multiple live libraries), the request is refused with 400.
        Image metadata is extracted before the response returns; the rest of
        processing (thumbnails, search indexing, face detection, and video
        metadata extraction) continues asynchronously after the response.
      operationId: create_asset
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - asset_data
                - device_asset_id
                - device_id
                - file_created_at
                - file_modified_at
              properties:
                asset_data:
                  type: string
                  format: binary
                  description: >-
                    The image or video file, sent as a binary multipart part
                    with a filename. The file's MIME type is derived from the
                    filename extension and must be an image or video type; files
                    with an unrecognized or non-media extension are rejected
                    with 422. The filename is stored as the asset's original
                    file name (maximum 1024 characters). The API imposes no
                    fixed per-file size limit; uploads are constrained only by
                    the storage caps.
                device_asset_id:
                  type: string
                  description: >-
                    Identifier of this asset on the uploading device, chosen by
                    the client (for example, the device's local asset ID).
                    Stored verbatim and usable for device-based existence
                    checks; plays no part in upload-time duplicate detection.
                  example: IMG_0421
                device_id:
                  type: string
                  description: >-
                    Identifier of the uploading device or client, chosen by the
                    client. Paired with `device_asset_id` for device-based
                    existence checks.
                  example: teds-iphone
                file_created_at:
                  type: string
                  format: date-time
                  description: >-
                    When the file was created on the uploading device, as an ISO
                    8601 datetime. Also serves as the fallback for the asset's
                    local capture time when the file's embedded metadata carries
                    no usable timestamp.
                  example: '2026-05-04T10:30:00+10:00'
                file_modified_at:
                  type: string
                  format: date-time
                  description: >-
                    When the file was last modified on the uploading device, as
                    an ISO 8601 datetime.
                  example: '2026-05-04T10:30:00+10:00'
                library_id:
                  type: string
                  nullable: true
                  description: >-
                    Library to upload into. For an all-library credential, omit
                    to use the account's sole live library or create a fresh
                    default when there are no live libraries; pass explicitly
                    when the account has multiple live libraries. For a
                    selected-library credential, omit to use its sole selected
                    library; pass explicitly when it selects multiple libraries.
                  example: lib_2c6ihMLYJUt3vctZWi9SNP
      responses:
        '200':
          description: >-
            The file already exists in the target library (matched by SHA-256
            checksum); the existing asset is returned and nothing new is stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssetResponse'
        '201':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssetResponse'
        '400':
          description: >-
            No target library could be resolved: `library_id` was omitted but
            the account has multiple live libraries, so no default could be
            chosen. Retry with an explicit `library_id`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Missing, invalid, or expired credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            The credentials are valid but not authorized for this operation —
            for example an API key whose action or library scope excludes it, or
            a credential type this operation does not accept.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: >-
            The multipart body is invalid: a required field is missing, a
            datetime is malformed, the file part has no filename, or the
            filename is over-long or not an image or video type.
        '429':
          description: >-
            Rate limit exceeded. Retry after the interval in the `Retry-After`
            header.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '502':
          description: >-
            Transient upstream storage error while persisting the file.
            Retryable: retry the upload after the interval in the `Retry-After`
            header. The response detail carries the stable `error_code`
            `"transient_storage_error"` plus `asset_id` and `part_number` keys
            (possibly null).
        '507':
          description: >-
            The account or the target library is at its storage cap. Not
            retryable until space is freed or the cap is raised.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
components:
  schemas:
    AssetResponse:
      properties:
        id:
          type: string
          title: Id
          description: Unique asset identifier with 'asset_' prefix
        mime_type:
          type: string
          title: Mime Type
          description: >-
            MIME type of the current rendering (e.g., 'image/jpeg',
            'video/mp4').
        original_file_name:
          type: string
          title: Original File Name
          description: Filename the asset was uploaded under.
        local_datetime:
          type: string
          format: date-time
          title: Local Datetime
          description: When the photo/video was taken, in the device's local timezone
        file_data:
          anyOf:
            - $ref: '#/components/schemas/FileDataResponse'
            - type: 'null'
          description: >-
            File/provenance scalars (device IDs, file timestamps, checksums,
            file size) grouped into one nested object. `null` when not requested
            via `include=file_data`; when present, every field is populated
            (`checksum_sha1` may still be `null` for legacy rows).
        created_at:
          type: string
          format: date-time
          title: Created At
          description: When this asset record was created in the database
        updated_at:
          type: string
          format: date-time
          title: Updated At
          description: When this asset record was last updated
        metadata:
          anyOf:
            - $ref: '#/components/schemas/MetadataResponse'
            - type: 'null'
          description: >-
            Asset metadata — camera/EXIF fields, GPS, and location names. `null`
            when not requested via `include=metadata`.
        metrics:
          anyOf:
            - additionalProperties:
                anyOf:
                  - type: number
                  - type: 'null'
              type: object
            - type: 'null'
          title: Metrics
          description: >-
            ML-generated quality scores and other metrics. `null` when not
            requested via `include=metrics`.
        asset_urls:
          anyOf:
            - additionalProperties:
                $ref: '#/components/schemas/AssetVariant'
              type: object
            - type: 'null'
          title: Asset Urls
          description: >-
            Named asset variants. Images: 'original', 'thumbnail', 'small',
            'preview', 'fullsize'. Videos: 'original', plus 'thumbnail_image',
            'small_image', 'preview_image', 'fullsize_image' pointing at the
            extracted still. 'original' is served with a Content-Disposition
            attachment header (signed 'dl' filename param) so a top-level
            navigation saves it to disk, while inline subresource loads
            (<video>, fetch) still render it. Variant URLs are stable: a derived
            variant may briefly 404 until its artifact is generated, then serve
            from the same URL.
        description:
          anyOf:
            - type: string
            - type: 'null'
          title: Description
          description: >-
            AI-generated description of the asset's content, quality, and
            composition. null means description generation has not yet run;
            empty string means the model refused to describe the asset. Distinct
            from metadata.description (camera-embedded EXIF metadata).
        thumbhash:
          anyOf:
            - type: string
            - type: 'null'
          title: Thumbhash
          description: >-
            Base64-encoded ThumbHash placeholder (~28 chars). Clients decode
            with the `thumbhash` library (JS / Swift / Kotlin) to render an
            instant blurred preview before the CDN thumbnail arrives. `null`
            while generation is pending.
        faces:
          anyOf:
            - items:
                $ref: '#/components/schemas/FaceResponse'
              type: array
            - type: 'null'
          title: Faces
          description: >-
            All faces detected in this asset. `null` when not requested via
            `include=faces`; `[]` when requested but the asset has no faces.
        people:
          anyOf:
            - items:
                $ref: '#/components/schemas/PersonResponse'
              type: array
            - type: 'null'
          title: People
          description: >-
            All unique people identified in this asset (deduplicated from
            faces). `null` when not requested via `include=people`; `[]` when
            requested but none are identified.
        kind:
          type: string
          title: Kind
          description: >-
            What produced the current rendering: `original` (the upload), `edit`
            (a client-baked edit), or `external:<service>`. The namespace is
            open — derive edited-ness as `kind != "original"`.
        current_version_id:
          type: string
          title: Current Version Id
          description: >-
            ID (`asset_version_` prefix) of the current version, which the
            top-level rendering fields describe. Pass it as the expected current
            version on version writes so a racing write is rejected.
        width:
          type: integer
          title: Width
          description: Width of the current rendering in pixels.
          default: 0
        height:
          type: integer
          title: Height
          description: Height of the current rendering in pixels.
          default: 0
        duration:
          anyOf:
            - type: number
            - type: 'null'
          title: Duration
          description: >-
            Video length in seconds. `null` for images and for videos whose
            duration has not been extracted yet.
        trashed_at:
          anyOf:
            - type: string
              format: date-time
            - type: 'null'
          title: Trashed At
          description: >-
            When this asset was moved to trash (ISO 8601, UTC). `null` for live
            assets. Trashed assets are excluded from default list/search results
            and are purged after the configured retention window.
        stack_id:
          anyOf:
            - type: string
            - type: 'null'
          title: Stack Id
          description: >-
            ID of the stack this asset belongs to (`asset_stack_` prefix), or
            `null` when the asset is not part of a stack. Group assets by this
            value to collapse a stack into a single tile; the stack's own cover
            and member count are not carried on the asset. Distinct from
            `metadata.auto_stack_id`, which is the camera's in-EXIF
            `MakerNotes:AutoStackID` string — this is the server-assigned
            foreign key to the asset's stack.
      type: object
      required:
        - id
        - mime_type
        - original_file_name
        - local_datetime
        - created_at
        - updated_at
        - kind
        - current_version_id
      title: AssetResponse
      description: |-
        Represents a photo or video asset with metadata and access URLs.

        Top-level fields describe the asset's current rendering (the version
        identified by `current_version_id`) unless they explicitly name the
        original upload, like `original_file_name` and the `file_data` group.
    ErrorResponse:
      title: ErrorResponse
      type: object
      properties:
        detail:
          title: Detail
          type: string
          description: Human-readable explanation of the error.
      required:
        - detail
    FileDataResponse:
      properties:
        device_asset_id:
          type: string
          title: Device Asset Id
          description: Original asset identifier from the device that uploaded this asset.
        device_id:
          type: string
          title: Device Id
          description: Identifier of the device that uploaded this asset.
        file_created_at:
          type: string
          format: date-time
          title: File Created At
          description: When the file was created on the uploading device.
        file_modified_at:
          type: string
          format: date-time
          title: File Modified At
          description: When the file was last modified on the uploading device.
        checksum:
          type: string
          title: Checksum
          description: >-
            Base64-encoded SHA-256 hash of the asset contents for duplicate
            detection and integrity.
        checksum_sha1:
          anyOf:
            - type: string
            - type: 'null'
          title: Checksum Sha1
          description: >-
            Base64-encoded SHA-1 hash of the asset contents. `null` for older
            assets that have no SHA-1.
        file_size_bytes:
          type: integer
          title: File Size Bytes
          description: >-
            Size of the uploaded file in bytes. Each rendering's own size is on
            its row in the asset's version listing.
      type: object
      required:
        - device_asset_id
        - device_id
        - file_created_at
        - file_modified_at
        - checksum
        - file_size_bytes
      title: FileDataResponse
      description: >-
        File/provenance scalars describing the uploaded *file* (not its
        content).


        Returned only when requested via ``include=file_data``; the whole object
        is

        ``null`` otherwise. When present, every field carries its real value —

        ``checksum_sha1`` is the lone exception (``null`` for legacy rows that
        never

        had a SHA-1). This nested object is the home for the file/provenance
        group.
    MetadataResponse:
      properties:
        asset_id:
          type: string
          title: Asset Id
          description: ID of the asset this metadata belongs to
        created_at:
          type: string
          format: date-time
          title: Created At
          description: When this metadata record was created
        updated_at:
          type: string
          format: date-time
          title: Updated At
          description: When this metadata record was last updated
        make:
          anyOf:
            - type: string
            - type: 'null'
          title: Make
          description: Camera manufacturer (e.g., 'Canon', 'Nikon')
        model:
          anyOf:
            - type: string
            - type: 'null'
          title: Model
          description: Camera model (e.g., 'EOS 5D Mark IV')
        orientation:
          anyOf:
            - type: integer
            - type: 'null'
          title: Orientation
          description: >-
            Image orientation value (1-8) indicating rotation/flip: 1=normal,
            2=mirror horizontal, 3=rotate 180°, 4=mirror vertical, 5=mirror
            horizontal+rotate 90° CW, 6=rotate 90° CW, 7=mirror
            horizontal+rotate 90° CCW, 8=rotate 90° CCW
        raw_width:
          anyOf:
            - type: integer
            - type: 'null'
          title: Raw Width
          description: Pre-rotation raw width; null when not available
        raw_height:
          anyOf:
            - type: integer
            - type: 'null'
          title: Raw Height
          description: Pre-rotation raw height; null when not available
        modified_datetime:
          anyOf:
            - type: string
              format: date-time
            - type: 'null'
          title: Modified Datetime
          description: When the file was last modified, with timezone offset if available
        original_datetime:
          anyOf:
            - type: string
              format: date-time
            - type: 'null'
          title: Original Datetime
          description: >-
            When the photo was originally taken, with timezone offset if
            available
        digitized_datetime:
          anyOf:
            - type: string
              format: date-time
            - type: 'null'
          title: Digitized Datetime
          description: When the photo was digitized, with timezone offset if available
        lens_model:
          anyOf:
            - type: string
            - type: 'null'
          title: Lens Model
          description: Lens model used (e.g., 'EF 24-70mm f/2.8L II USM')
        f_number:
          anyOf:
            - type: number
            - type: 'null'
          title: F Number
          description: Aperture f-stop value (e.g., 2.8, 5.6)
        focal_length:
          anyOf:
            - type: number
            - type: 'null'
          title: Focal Length
          description: Focal length in millimeters
        iso:
          anyOf:
            - type: integer
            - type: 'null'
          title: Iso
          description: ISO sensitivity value (e.g., 100, 800, 3200)
        exposure_time:
          anyOf:
            - type: number
            - type: 'null'
          title: Exposure Time
          description: Shutter speed in seconds (e.g., 0.001 for 1/1000s)
        exposure_bias:
          anyOf:
            - type: number
            - type: 'null'
          title: Exposure Bias
          description: Exposure compensation in EV (e.g., -1.0, +0.5)
        latitude:
          anyOf:
            - type: number
            - type: 'null'
          title: Latitude
          description: GPS latitude in decimal degrees
        longitude:
          anyOf:
            - type: number
            - type: 'null'
          title: Longitude
          description: GPS longitude in decimal degrees
        altitude:
          anyOf:
            - type: number
            - type: 'null'
          title: Altitude
          description: GPS altitude in meters
        city:
          anyOf:
            - type: string
            - type: 'null'
          title: City
          description: City name
        state:
          anyOf:
            - type: string
            - type: 'null'
          title: State
          description: State/province name
        country:
          anyOf:
            - type: string
            - type: 'null'
          title: Country
          description: Country name
        country_code:
          anyOf:
            - type: string
            - type: 'null'
          title: Country Code
          description: ISO 3166-1 alpha-2 country code (e.g., 'US', 'JP')
        sublocation:
          anyOf:
            - type: string
            - type: 'null'
          title: Sublocation
          description: Neighborhood or district
        place_name:
          anyOf:
            - type: string
            - type: 'null'
          title: Place Name
          description: Landmark or point-of-interest name
        timezone:
          anyOf:
            - type: string
            - type: 'null'
          title: Timezone
          description: IANA timezone identifier (e.g., 'America/Los_Angeles')
        display_label:
          anyOf:
            - type: string
            - type: 'null'
          title: Display Label
          description: >-
            Human-readable location label. Picks the most specific available
            identifier (place_name > sublocation > city > country) and appends
            broader context (city, then state-or-country). Example: 'Golden Gate
            Bridge, San Francisco, California'. Null when no location fields are
            populated.
        description:
          anyOf:
            - type: string
            - type: 'null'
          title: Description
          description: Image description or caption
        fps:
          anyOf:
            - type: number
            - type: 'null'
          title: Fps
          description: Frame rate for video files
        live_photo_cid:
          anyOf:
            - type: string
            - type: 'null'
          title: Live Photo Cid
          description: Live photo content identifier
        projection_type:
          anyOf:
            - type: string
            - type: 'null'
          title: Projection Type
          description: Projection type (e.g., for 360° photos)
        auto_stack_id:
          anyOf:
            - type: string
            - type: 'null'
          title: Auto Stack Id
          description: Identifier for automatic photo stacking
        rating:
          anyOf:
            - type: integer
            - type: 'null'
          title: Rating
          description: User or camera rating (typically 1-5 stars)
      type: object
      required:
        - asset_id
        - created_at
        - updated_at
      title: MetadataResponse
      description: Metadata for an asset — camera/EXIF fields, GPS, and location names.
    AssetVariant:
      properties:
        url:
          type: string
          title: Url
          description: URL to fetch this image variant
        mimetype:
          type: string
          title: Mimetype
          description: MIME type of the served image
        width:
          anyOf:
            - type: integer
            - type: 'null'
          title: Width
          description: Target width in pixels (null if unknown)
      type: object
      required:
        - url
        - mimetype
      title: AssetVariant
      description: A single image variant with its URL, MIME type, and target width.
    FaceResponse:
      properties:
        id:
          type: string
          title: Id
          description: Unique face identifier with 'face_' prefix
        asset_id:
          type: string
          title: Asset Id
          description: ID of the asset containing this face
        person_id:
          anyOf:
            - type: string
            - type: 'null'
          title: Person Id
          description: ID of the person this face belongs to (if identified)
        bounding_box:
          additionalProperties:
            type: integer
          type: object
          title: Bounding Box
          description: Face location as {x, y, w, h} coordinates in pixels
        confidence:
          anyOf:
            - type: number
            - type: 'null'
          title: Confidence
          description: >-
            Detector confidence on a 0-1 scale; higher is more confident among
            faces detected under the same configuration (values are not
            comparable across detector generations). Null on legacy faces
            without a stored score and on manually added faces.
        source:
          type: string
          enum:
            - automatic
            - manual
          title: Source
          description: >-
            How this face was added: 'automatic' for detector-found faces,
            'manual' for user-drawn face boxes.
        timestamp_ms:
          anyOf:
            - type: integer
            - type: 'null'
          title: Timestamp Ms
          description: For video files, timestamp in milliseconds when face appears
        asset_urls:
          anyOf:
            - additionalProperties:
                $ref: '#/components/schemas/AssetVariant'
              type: object
            - type: 'null'
          title: Asset Urls
          description: 'Asset variants for this face: ''thumbnail'' with face crop'
        created_at:
          type: string
          format: date-time
          title: Created At
          description: When this face was detected and recorded
        updated_at:
          type: string
          format: date-time
          title: Updated At
          description: When this face record was last updated
        cluster_assignment:
          anyOf:
            - $ref: '#/components/schemas/ClusterAssignmentResponse'
            - type: 'null'
          description: >-
            Cluster-assignment diagnostics for this face. Populated only when
            `include=cluster_assignment` is requested on the faces endpoint;
            null otherwise. See `ClusterAssignmentResponse` for the shape.
      type: object
      required:
        - id
        - asset_id
        - bounding_box
        - source
        - created_at
        - updated_at
      title: FaceResponse
      description: Represents a detected face in an asset with facial recognition data.
    PersonResponse:
      properties:
        id:
          type: string
          title: Id
          description: Unique person identifier with 'person_' prefix
        name:
          anyOf:
            - type: string
            - type: 'null'
          title: Name
          description: Optional name assigned to this person
        birth_date:
          anyOf:
            - type: string
              format: date
            - type: 'null'
          title: Birth Date
          description: Optional birth date of this person
        is_hidden:
          type: boolean
          title: Is Hidden
          description: Whether this person should be hidden from the UI
        is_favorite:
          type: boolean
          title: Is Favorite
          description: Whether this person is marked as a favorite
        asset_count:
          anyOf:
            - type: integer
            - type: 'null'
          title: Asset Count
          description: >-
            Number of unique photos this person appears in, or null if not
            computed
        thumbnail_face_id:
          anyOf:
            - type: string
            - type: 'null'
          title: Thumbnail Face Id
          description: ID of the face resource used as this person's thumbnail
        asset_urls:
          anyOf:
            - additionalProperties:
                $ref: '#/components/schemas/AssetVariant'
              type: object
            - type: 'null'
          title: Asset Urls
          description: >-
            Asset variants from this person's thumbnail face. May be null when
            embedded in an AssetResponse; use /api/people endpoints for full
            person data.
        cluster_metrics:
          anyOf:
            - $ref: '#/components/schemas/ClusterMetricsResponse'
            - type: 'null'
          description: >-
            Cohesion metrics for this person's face cluster. Populated only when
            `include=cluster_metrics` is requested on the people endpoint, and
            only for persons with a populated centroid (newly-created empty
            Persons will have null). See `ClusterMetricsResponse` for the shape.
        created_at:
          type: string
          format: date-time
          title: Created At
          description: When this person record was created
        updated_at:
          type: string
          format: date-time
          title: Updated At
          description: When this person record was last updated
      type: object
      required:
        - id
        - is_hidden
        - is_favorite
        - created_at
        - updated_at
      title: PersonResponse
      description: Represents a person identified through face clustering and recognition.
    ClusterAssignmentResponse:
      properties:
        distance_to_person:
          anyOf:
            - type: number
            - type: 'null'
          title: Distance To Person
          description: >-
            Cosine distance from the face's embedding to its currently-assigned
            Person's centroid. Lower = better fit. Null when the face is
            unassigned or when the assigned Person has no centroid.
        candidates:
          items:
            $ref: '#/components/schemas/FaceCandidatePersonResponse'
          type: array
          title: Candidates
          description: >-
            Persons in the same library that pass the same gate shape as
            production face assignment, surfaced with deliberately relaxed
            thresholds so the list is a superset of what the automated path
            would admit. Sorted ascending by distance. Excludes the face's
            currently-assigned Person (its distance is in `distance_to_person`).
            Empty when no eligible Persons pass the gate.
      type: object
      title: ClusterAssignmentResponse
      description: |-
        Per-face cluster-assignment diagnostics: how well the face fits its
        currently-assigned Person, and which other Persons are nearby in
        embedding space. Surfaced via ``include=cluster_assignment`` on the
        faces endpoints — used by the operator-facing face cleanup dashboard
        to triage mis-clustered faces.
    ClusterMetricsResponse:
      properties:
        pairwise_p90:
          type: number
          title: Pairwise P90
          description: >-
            90th-percentile pairwise cosine distance between faces in this
            person's cluster. Lower = more cohesive cluster; loose clusters
            (higher pairwise_p90) are gated out of the face-assignment path to
            prevent further drift.
        pairwise_mean:
          type: number
          title: Pairwise Mean
          description: >-
            Mean pairwise cosine distance between faces in this person's
            cluster.
        face_count:
          type: integer
          title: Face Count
          description: >-
            Number of faces that fed into the centroid and pairwise metrics.
            This is the cluster-membership count, **not** the same as
            `asset_count` — `face_count` counts every face row, while
            `asset_count` counts distinct assets (one asset can contribute
            multiple faces of the same person).
      type: object
      required:
        - pairwise_p90
        - pairwise_mean
        - face_count
      title: ClusterMetricsResponse
      description: |-
        Cohesion metrics for a Person's face cluster — surfaced via
        ``include=cluster_metrics`` on the people endpoints. These describe how
        tight the cluster is in embedding space (lower = more cohesive) and
        drive both the production face-assignment cohesion gate and the
        operator-facing face cleanup dashboard.
    FaceCandidatePersonResponse:
      properties:
        person_id:
          type: string
          title: Person Id
          description: Person ID (with 'person_' prefix) of the candidate.
        name:
          anyOf:
            - type: string
            - type: 'null'
          title: Name
          description: >-
            Display name of the candidate Person, or null for unnamed clusters.
            Candidates surface the same Persons production assignment considers,
            which includes unnamed clusters.
        distance:
          type: number
          title: Distance
          description: >-
            Cosine distance from the face's embedding to this Person's centroid
            (lower = closer).
      type: object
      required:
        - person_id
        - distance
      title: FaceCandidatePersonResponse
      description: |-
        A Person whose centroid is close enough to a given face's embedding
        that it would be considered for assignment — surfaced under
        ``ClusterAssignmentResponse.candidates``.
  securitySchemes:
    apiKeyAuth:
      type: http
      scheme: bearer
      description: >-
        Gumnut API key (`apikey_...`) sent as a Bearer token in the
        `Authorization` header. Create and manage keys in the Gumnut app, or
        with the API key endpoints while signed in to it.
    oauthJwtAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        Short-lived JWT obtained from the OAuth token exchange, sent as a Bearer
        token in the `Authorization` header.

````