openapi: 3.1.0

info:
  title: Divebase API
  version: 1.0.0
  description: |
    Public REST API for Divebase, the modern dive logging platform.

    Every endpoint authenticates via a personal API token sent in the
    `Authorization: Bearer <token>` header. Tokens are issued from the
    web app under **Account -> API tokens**.

    Successful responses are wrapped in a `{ "data": ... }` envelope, where
    `data` is the resource object directly (or an array for a collection);
    failures use `{ "error": { "code": ..., "message": ... } }`. A response
    carries either `data` or `error`, never both.
  contact:
    name: Divebase
    url: https://divebase.cloud

servers:
  - url: https://api.divebase.cloud/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Account
    description: Endpoints that operate on the authenticated user's own account.

# Resources that are planned but not yet exposed by the API. This block is a
# vendor extension: it carries no callable endpoints (those live under `paths`)
# and is ignored by validators and renderers. Entries graduate to real `paths`
# as they ship.
x-planned-resources:
  - name: Insurances
    description: >-
      Dive and travel insurance policies, with provider, policy number,
      and coverage dates.
  - name: Trips
    description: >-
      Dive trips that group dives by destination and date.
  - name: Divers
    description: >-
      The account holder's personal profile: name, date of birth, home
      location, and check-in contact details.
  - name: Dive Buddies
    description: >-
      Manage which buddies were present on a given dive.
  - name: Dive Equipment
    description: >-
      Manage which equipment items were used on a given dive.
  - name: Dive Gases
    description: >-
      Manage the gases breathed on a given dive: the gas mix, the cylinder,
      and the start and end pressures.
  - name: Dive Profiles
    description: >-
      Import dive-computer profiles (the sample time-series) into a dive.

paths:
  /whoami:
    get:
      operationId: whoami
      summary: Return the authenticated user
      description: |
        Returns the identity of the user the bearer token belongs to.

        Useful for clients that want to confirm which account a token
        is bound to before issuing further requests.
      tags: [Account]
      responses:
        "200":
          description: The authenticated user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WhoamiResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /buddies:
    get:
      operationId: listBuddies
      summary: List the authenticated user's buddies
      description: |
        Returns one page of the buddies owned by the user the bearer token
        belongs to, ordered by id descending. Ids are UUIDv7 and encode
        the millisecond they were minted in, so this is most recently added
        first; ids minted within the same millisecond fall in a stable but
        arbitrary order.

        There is no search or sort selection in this version, so the order is
        not the web listing's alphabetical address-book order. Results are
        paginated; use the `page` and `per_page` query parameters to walk the
        whole set.
      tags: [Account]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: The user's buddies.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BuddyListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createBuddy
      summary: Create a buddy
      description: |
        Creates a buddy owned by the user the bearer token belongs to.

        The request body is a flat JSON object of the buddy's writable fields
        (no resource wrapper). No single field is required, but at least one of
        `first_name` and `last_name` must be present. On success the new buddy
        is returned under `data` with `201 Created` and a `Location` header. A
        malformed JSON body yields `400`; a well-formed body that fails
        validation yields `422` with a per-field `details` array, in which the
        two-field name rule reports against the `base` sentinel.
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BuddyCreateRequest"
      responses:
        "201":
          description: The created buddy.
          headers:
            Location:
              description: URL of the newly created buddy.
              schema:
                type: string
                format: uri
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BuddyResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /buddies/{id}:
    get:
      operationId: getBuddy
      summary: Fetch one of the authenticated user's buddies
      description: |
        Returns a single buddy owned by the user the bearer token belongs to,
        addressed by their UUIDv7 id.

        An id that does not exist and an id owned by another user are
        indistinguishable: both yield 404. The API never reveals whether
        another user's buddy exists.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: The buddy's UUIDv7 identifier.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "200":
          description: The requested buddy.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BuddyResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateBuddy
      summary: Update one of the authenticated user's buddies
      description: |
        Updates the buddy identified by `id`, owned by the user the bearer
        token belongs to. The request body is a flat JSON object of the
        writable fields to change; omitted fields are left unchanged (partial
        update), and no field is required.

        On success the updated buddy is returned under `data` with `200 OK`. An
        unknown id, or one owned by a different user, returns `404`. A malformed
        JSON body yields `400`; a well-formed body that fails validation yields
        `422` with a per-field `details` array, leaving the stored record
        unchanged. Clearing the last remaining name fails that way rather than
        storing a nameless buddy.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the buddy to update.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BuddyWritable"
      responses:
        "200":
          description: The updated buddy.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BuddyResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteBuddy
      summary: Delete one of the authenticated user's buddies
      description: |
        Deletes the buddy identified by `id`. The buddy must be owned by the
        user the bearer token belongs to.

        A successful delete returns `204 No Content` with an empty body. The
        buddy is removed from every dive they were on; those dives survive, so
        this never conflicts with `409`. An unknown id, or one owned by a
        different user, returns `404` with the `not_found` error: the two cases
        are deliberately indistinguishable.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the buddy to delete.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "204":
          description: The buddy was deleted. No response body.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /certifications:
    get:
      operationId: listCertifications
      summary: List the authenticated user's certifications
      description: |
        Returns one page of the certifications owned by the user the bearer
        token belongs to, ordered by id descending. Ids are UUIDv7 and encode
        the millisecond they were minted in, so this is most recently added
        first; ids minted within the same millisecond fall in a stable but
        arbitrary order.

        Expired certifications are included, and there is no search or sort
        selection in this version. Results are paginated; use the `page` and
        `per_page` query parameters to walk the whole set.
      tags: [Account]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: The user's certifications.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CertificationListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createCertification
      summary: Create a certification
      description: |
        Creates a certification owned by the user the bearer token belongs to.

        The request body is a flat JSON object of the certification's writable
        fields (no resource wrapper); `name` is required. On success the new
        certification is returned under `data` with `201 Created` and a
        `Location` header. A malformed JSON body yields `400`; a well-formed
        body that fails validation yields `422` with a per-field `details` array.
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CertificationCreateRequest"
      responses:
        "201":
          description: The created certification.
          headers:
            Location:
              description: URL of the newly created certification.
              schema:
                type: string
                format: uri
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CertificationResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /certifications/{id}:
    get:
      operationId: getCertification
      summary: Fetch one of the authenticated user's certifications
      description: |
        Returns a single certification owned by the user the bearer token
        belongs to, addressed by its UUIDv7 id.

        An id that does not exist and an id owned by another user are
        indistinguishable: both yield 404. The API never reveals whether
        another user's certification exists.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: The certification's UUIDv7 identifier.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "200":
          description: The requested certification.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CertificationResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateCertification
      summary: Update one of the authenticated user's certifications
      description: |
        Updates the certification identified by `id`, owned by the user the
        bearer token belongs to. The request body is a flat JSON object of the
        writable fields to change; omitted fields are left unchanged (partial
        update), and no field is required.

        On success the updated certification is returned under `data` with
        `200 OK`. An unknown id, or one owned by a different user, returns
        `404`. A malformed JSON body yields `400`; a well-formed body that
        fails validation yields `422` with a per-field `details` array, leaving
        the stored record unchanged.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the certification to update.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CertificationWritable"
      responses:
        "200":
          description: The updated certification.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CertificationResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteCertification
      summary: Delete one of the authenticated user's certifications
      description: |
        Deletes the certification identified by `id`. The certification must be
        owned by the user the bearer token belongs to.

        A successful delete returns `204 No Content` with an empty body. An
        unknown id, or one owned by a different user, returns `404` with the
        `not_found` error: the two cases are deliberately indistinguishable.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the certification to delete.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "204":
          description: The certification was deleted. No response body.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /cylinders:
    get:
      operationId: listCylinders
      summary: List the authenticated user's cylinders
      description: |
        Returns one page of the cylinders owned by the user the bearer token
        belongs to, ordered by id descending. Ids are UUIDv7 and encode
        the millisecond they were minted in, so this is most recently added
        first; ids minted within the same millisecond fall in a stable but
        arbitrary order.

        There is no category filter, favourites filter, search, or sort
        selection in this version. Results are paginated; use the `page` and
        `per_page` query parameters to walk the whole set.
      tags: [Account]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: The user's cylinders.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CylinderListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createCylinder
      summary: Create a cylinder
      description: |
        Creates a cylinder owned by the user the bearer token belongs to.

        The request body is a flat JSON object of the cylinder's writable fields
        (no resource wrapper); `name`, `material`, `volume`, and
        `working_pressure` are required. Setting `default` to true clears the
        caller's previous default. On success the new cylinder is returned under
        `data` with `201 Created` and a `Location` header. A malformed JSON body
        yields `400`; a well-formed body that fails validation yields `422` with
        a per-field `details` array.
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CylinderCreateRequest"
      responses:
        "201":
          description: The created cylinder.
          headers:
            Location:
              description: URL of the newly created cylinder.
              schema:
                type: string
                format: uri
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CylinderResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /cylinders/{id}:
    get:
      operationId: getCylinder
      summary: Fetch one of the authenticated user's cylinders
      description: |
        Returns a single cylinder owned by the user the bearer token belongs to,
        addressed by its UUIDv7 id.

        An id that does not exist and an id owned by another user are
        indistinguishable: both yield 404. The API never reveals whether
        another user's cylinder exists.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: The cylinder's UUIDv7 identifier.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "200":
          description: The requested cylinder.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CylinderResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateCylinder
      summary: Update one of the authenticated user's cylinders
      description: |
        Updates the cylinder identified by `id`, owned by the user the bearer
        token belongs to. The request body is a flat JSON object of the writable
        fields to change; omitted fields are left unchanged (partial update),
        and no field is required. Setting `default` to true clears the caller's
        previous default.

        On success the updated cylinder is returned under `data` with `200 OK`.
        An unknown id, or one owned by a different user, returns `404`. A
        malformed JSON body yields `400`; a well-formed body that fails
        validation yields `422` with a per-field `details` array, leaving the
        stored record unchanged.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the cylinder to update.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CylinderWritable"
      responses:
        "200":
          description: The updated cylinder.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CylinderResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteCylinder
      summary: Delete one of the authenticated user's cylinders
      description: |
        Deletes the cylinder identified by `id`. The cylinder must be owned by
        the user the bearer token belongs to.

        A successful delete returns `204 No Content` with an empty body. An
        unknown id, or one owned by a different user, returns `404` with the
        `not_found` error: the two cases are deliberately indistinguishable. A
        cylinder that is logged on a dive cannot be deleted and returns `409`
        with the `in_use` error.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the cylinder to delete.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "204":
          description: The cylinder was deleted. No response body.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /dive_sites:
    get:
      operationId: listDiveSites
      summary: List the authenticated user's dive sites
      description: |
        Returns one page of the dive sites owned by the user the bearer token
        belongs to, ordered by id descending. Ids are UUIDv7 and encode
        the millisecond they were minted in, so this is most recently added
        first; ids minted within the same millisecond fall in a stable but
        arbitrary order.

        There is no search or sort selection in this version. Results are
        paginated; use the `page` and `per_page` query parameters to walk the
        whole set.
      tags: [Account]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: The user's dive sites.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveSiteListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createDiveSite
      summary: Create a dive site
      description: |
        Creates a dive site owned by the user the bearer token belongs to.

        The request body is a flat JSON object of the dive site's writable
        fields (no resource wrapper); `name` is required. On success the new
        dive site is returned under `data` with `201 Created` and a `Location`
        header. A malformed JSON body yields `400`; a well-formed body that
        fails validation yields `422` with a per-field `details` array.
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DiveSiteCreateRequest"
      responses:
        "201":
          description: The created dive site.
          headers:
            Location:
              description: URL of the newly created dive site.
              schema:
                type: string
                format: uri
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveSiteResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /dive_sites/{id}:
    get:
      operationId: getDiveSite
      summary: Fetch one of the authenticated user's dive sites
      description: |
        Returns a single dive site owned by the user the bearer token
        belongs to, addressed by its UUIDv7 id.

        An id that does not exist and an id owned by another user are
        indistinguishable: both yield 404. The API never reveals whether
        another user's dive site exists.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: The dive site's UUIDv7 identifier.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "200":
          description: The requested dive site.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveSiteResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateDiveSite
      summary: Update one of the authenticated user's dive sites
      description: |
        Updates the dive site identified by `id`, owned by the user the bearer
        token belongs to. The request body is a flat JSON object of the writable
        fields to change; omitted fields are left unchanged (partial update),
        and no field is required.

        On success the updated dive site is returned under `data` with `200 OK`.
        An unknown id, or one owned by a different user, returns `404`. A
        malformed JSON body yields `400`; a well-formed body that fails
        validation yields `422` with a per-field `details` array, leaving the
        stored record unchanged.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the dive site to update.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DiveSiteWritable"
      responses:
        "200":
          description: The updated dive site.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveSiteResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteDiveSite
      summary: Delete one of the authenticated user's dive sites
      description: |
        Deletes the dive site identified by `id`. The site must be owned by the
        user the bearer token belongs to.

        Deletion is a soft delete: the site leaves ordinary reads but is retained
        as a tombstone, so the deletion can propagate to the caller's other
        devices. Any dive that referenced the site keeps existing and keeps
        its `dive_site_id`; the link is left intact rather than nulled. A
        successful delete returns `204 No Content` with an empty body. An unknown
        id, or one owned by a different user, returns `404` with the `not_found`
        error: the two cases are deliberately indistinguishable.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the dive site to delete.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "204":
          description: The dive site was deleted. No response body.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /gas_mixes:
    get:
      operationId: listGasMixes
      summary: List the authenticated user's gas mixes
      description: |
        Returns one page of the gas mixes owned by the user the bearer token
        belongs to, ordered by id descending. Ids are UUIDv7 and encode
        the millisecond they were minted in, so this is most recently added
        first; ids minted within the same millisecond fall in a stable but
        arbitrary order.

        There is no category filter, favourites filter, search, or sort
        selection in this version. Results are paginated; use the `page` and
        `per_page` query parameters to walk the whole set.
      tags: [Account]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: The user's gas mixes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GasMixListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createGasMix
      summary: Create a gas mix
      description: |
        Creates a gas mix owned by the user the bearer token belongs to.

        The request body is a flat JSON object of the gas mix's writable fields
        (no resource wrapper); `name` and `oxygen` are required, while `helium`,
        `ppo2`, `use`, and `favourite` default when omitted. On success the new
        gas mix is returned under `data` with `201 Created` and a `Location`
        header. A malformed JSON body yields `400`; a well-formed body that
        fails validation yields `422` with a per-field `details` array.
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GasMixCreateRequest"
      responses:
        "201":
          description: The created gas mix.
          headers:
            Location:
              description: URL of the newly created gas mix.
              schema:
                type: string
                format: uri
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GasMixResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /gas_mixes/{id}:
    get:
      operationId: getGasMix
      summary: Fetch one of the authenticated user's gas mixes
      description: |
        Returns a single gas mix owned by the user the bearer token belongs to,
        addressed by its UUIDv7 id.

        An id that does not exist and an id owned by another user are
        indistinguishable: both yield 404. The API never reveals whether
        another user's gas mix exists.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: The gas mix's UUIDv7 identifier.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "200":
          description: The requested gas mix.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GasMixResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateGasMix
      summary: Update one of the authenticated user's gas mixes
      description: |
        Updates the gas mix identified by `id`, owned by the user the bearer
        token belongs to. The request body is a flat JSON object of the writable
        fields to change; omitted fields are left unchanged (partial update),
        and no field is required.

        On success the updated gas mix is returned under `data` with `200 OK`.
        An unknown id, or one owned by a different user, returns `404`. A
        malformed JSON body yields `400`; a well-formed body that fails
        validation yields `422` with a per-field `details` array, leaving the
        stored record unchanged.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the gas mix to update.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GasMixWritable"
      responses:
        "200":
          description: The updated gas mix.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GasMixResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteGasMix
      summary: Delete one of the authenticated user's gas mixes
      description: |
        Deletes the gas mix identified by `id`. The gas mix must be owned by
        the user the bearer token belongs to.

        A successful delete returns `204 No Content` with an empty body. An
        unknown id, or one owned by a different user, returns `404` with the
        `not_found` error: the two cases are deliberately indistinguishable. A
        gas mix that is logged on a dive cannot be deleted and returns `409`
        with the `in_use` error.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the gas mix to delete.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "204":
          description: The gas mix was deleted. No response body.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /dives:
    get:
      operationId: listDives
      summary: List the authenticated user's dives
      description: |
        Returns one page of the dives logged by the user the bearer token
        belongs to, ordered by start time descending (most recent first).

        There is no search or sort selection in this version. The logbook is
        unbounded and fully reachable page by page; use the `page` and
        `per_page` query parameters to walk it.
      tags: [Account]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: The user's dives.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createDive
      summary: Log a dive
      description: |
        Creates a dive owned by the user the bearer token belongs to.

        The request body is a flat JSON object of the dive's writable fields (no
        resource wrapper); `started_at`, `duration`, and `max_depth` are
        required. On success the new dive is returned under `data` with `201
        Created` and a `Location` header; the create response is the light dive
        shape and carries no `samples`. A malformed JSON body yields `400`; a
        well-formed body that fails validation yields `422` with a per-field
        `details` array.
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DiveCreateRequest"
      responses:
        "201":
          description: The created dive.
          headers:
            Location:
              description: URL of the newly created dive.
              schema:
                type: string
                format: uri
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /dives/import:
    post:
      operationId: importDive
      summary: Import a fully-formed dive
      description: |
        Ingests a complete dive owned by the user the bearer token belongs to: its
        writable fields together with its profile sample series, gas usage, and
        (optionally) the original device bytes, in a single call. The programmatic
        equivalent of handing a .dive / UDDF / FIT file to the browser import
        surface, for an integration that has already read a dive from a dive
        computer.

        The dive is deduplicated by a source identity: `source` is `api` and
        `source_reference` is the supplied `external_id`, or the SHA-256 of the
        decoded `raw` blob, or absent when neither is given. A push whose identity
        already matches one of the caller's current dives returns that dive with
        `200 OK`, unchanged (a re-push overwrites nothing, so a diver's later edits
        survive); a new dive returns `201 Created` with a `Location` header. Both
        responses carry the dive plus its profile `samples`. A profile with fewer
        than two depth-bearing samples is dropped rather than rejected. A malformed
        JSON body yields `400`; a dive or gas that fails validation yields `422`.
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DiveImportRequest"
            example:
              external_id: "shearwater:AB12CD34:2026-06-14T08:12:00Z"
              number: 342
              started_at: "2026-06-14T08:12:00Z"
              timezone: Europe/Malta
              duration: 3180
              max_depth: 42.7
              average_depth: 24.3
              mode: opencircuit
              bottom_duration: 1200
              surface_interval: 4980
              repetitive_number: 2
              decompression: true
              deco_model: ZHL-16C
              deco_model_level: "40/85"
              deco_duration: 540
              cns: 42.5
              otu: 61
              temperature_air: 27.0
              temperature_min: 16.0
              temperature_max: 21.5
              visibility_distance: 25.0
              visibility_clarity: 4
              current: light
              weather: sunny
              entry_type: boat
              exit_type: boat
              weight: 6.0
              rating: 5
              dive_site_id: "0199aa10-7000-7abc-8def-0000000000aa"
              notes: "Um El Faroud wreck, bottom section."
              profile:
                device: { manufacturer: Shearwater, model: Perdix, serial: AB12CD34, firmware: "2.96" }
                samples:
                  - { time: 0, depth: 0.0, temp: 21.5, gas: 0, pressure: [{ tank: 0, bar: 230 }] }
                  - { time: 240, depth: 42.7, temp: 16.0, gas: 0, pressure: [{ tank: 0, bar: 150 }], ppo2: [{ sensor: null, bar: 1.32 }], ndl: 0 }
                  - { time: 1200, depth: 41.9, temp: 16.1, gas: 0, pressure: [{ tank: 0, bar: 96 }], cns: 38.0, deco: { type: decostop, depth: 6, time: 540 } }
                  - { time: 1440, depth: 21.0, temp: 17.8, gas: 1, pressure: [{ tank: 1, bar: 190 }], events: [{ type: gasswitch, gas: "Nx 50" }] }
                  - { time: 3180, depth: 0.0, temp: 21.0, gas: 1, pressure: [{ tank: 1, bar: 68 }] }
              gases:
                - { oxygen: 21, helium: 35, role: bottom, start_pressure: 230, end_pressure: 96, start_offset: 0, end_offset: 1440 }
                - { oxygen: 50, helium: 0, role: deco, start_pressure: 190, end_pressure: 68, start_offset: 1440, end_offset: 3180 }
              raw:
                content_type: application/octet-stream
                data: "U2hlYXJ3YXRlciBQZXJkaXggcmF3IGRpdmUgYmxvYg=="
                parser_type: shearwater_petrel
      responses:
        "200":
          description: A duplicate re-push; the already-imported dive, unchanged, with its samples.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveShowResponse"
        "201":
          description: The imported dive, with its profile samples.
          headers:
            Location:
              description: URL of the newly imported dive.
              schema:
                type: string
                format: uri
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveShowResponse"
              example:
                data:
                  id: "0199aa10-7000-7abc-8def-0000000003a2"
                  number: 342
                  started_at: "2026-06-14T08:12:00.000Z"
                  timezone: Europe/Malta
                  duration: 3180
                  max_depth: "42.7"
                  average_depth: "24.3"
                  mode: opencircuit
                  decompression: true
                  deco_model: ZHL-16C
                  deco_model_level: "40/85"
                  cns: "42.5"
                  otu: 61
                  temperature_min: "16.0"
                  dive_site_id: "0199aa10-7000-7abc-8def-0000000000aa"
                  rating: 5
                  imported_at: "2026-06-14T09:03:11.482Z"
                  created_at: "2026-06-14T09:03:11.482Z"
                  updated_at: "2026-06-14T09:03:11.482Z"
                  samples:
                    - { time: 0, depth: 0.0, temp: 21.5, gas: 0, pressure: [{ tank: 0, bar: 230 }] }
                    - { time: 1200, depth: 41.9, temp: 16.1, gas: 0, pressure: [{ tank: 0, bar: 96 }], cns: 38.0, deco: { type: decostop, depth: 6, time: 540 } }
                    - { time: 3180, depth: 0.0, temp: 21.0, gas: 1, pressure: [{ tank: 1, bar: 68 }] }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /dives/{id}:
    get:
      operationId: getDive
      summary: Fetch one of the authenticated user's dives
      description: |
        Returns a single dive owned by the user the bearer token belongs to,
        addressed by its UUIDv7 id, together with its profile `samples` series
        (an empty array when the dive has no profile).

        An id that does not exist and an id owned by another user are
        indistinguishable: both yield 404. The API never reveals whether
        another user's dive exists.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: The dive's UUIDv7 identifier.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "200":
          description: The requested dive, with its profile samples.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateDive
      summary: Update one of the authenticated user's dives
      description: |
        Updates the dive identified by `id`, owned by the user the bearer token
        belongs to. The request body is a flat JSON object of the writable
        fields to change; omitted fields are left unchanged (partial update),
        and no field is required.

        On success the updated dive is returned under `data` with `200 OK`; the
        update response is the light dive shape and carries no `samples`. An
        unknown id, or one owned by a different user, returns `404`. A malformed
        JSON body yields `400`; a well-formed body that fails validation yields
        `422` with a per-field `details` array, leaving the stored record
        unchanged.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the dive to update.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DiveWritable"
      responses:
        "200":
          description: The updated dive.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteDive
      summary: Delete one of the authenticated user's dives
      description: |
        Deletes the dive identified by `id`. The dive must be owned by the user
        the bearer token belongs to.

        Deletion is a soft delete: the dive leaves ordinary reads but is retained
        as a tombstone, so the deletion can propagate to the caller's other
        devices. Its profile, gases, and preserved dive data are destroyed
        with it; the import that recorded its provenance is kept. A successful
        delete returns `204 No Content` with an empty body. An unknown id, or one
        owned by a different user, returns `404` with the `not_found` error: the
        two cases are deliberately indistinguishable.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the dive to delete.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "204":
          description: The dive was deleted. No response body.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /dives/{id}/profile:
    post:
      operationId: attachDiveProfile
      summary: Attach a profile to an existing dive from an uploaded file
      description: |
        Enriches the dive identified by `id`, owned by the user the bearer token
        belongs to, from an uploaded dive-computer file (`.uddf`, `.dive`, or
        `.fit`). The server parses the file, exactly as the browser Attach
        surface does; the caller sends no normalized payload. This is the API's
        one file-upload endpoint, so the request body is `multipart/form-data`
        rather than JSON.

        The file must resolve to exactly one dive carrying a chartable profile.
        The parsed profile replaces any existing one wholesale, gases are
        imported only when the dive has none, blank scalars are backfilled, the
        average depth is recomputed, and the uploaded file is preserved as the
        dive's re-parseable source data. The dive's identity and core facts
        (including `source`, `source_reference`, and `imported_at`) are never
        touched. On success the enriched dive is returned with `200 OK`,
        including its profile `samples`.

        An unknown id, or one owned by a different user, returns `404`. A missing
        file yields `400`; an oversize upload yields `413`; an unsupported,
        multi-dive, or unchartable file yields `422`.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: The dive's UUIDv7 identifier.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: A single .uddf, .dive, or .fit dive-computer file.
      responses:
        "200":
          description: The enriched dive, with its attached profile samples.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiveShowResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /equipment:
    get:
      operationId: listEquipment
      summary: List the authenticated user's equipment
      description: |
        Returns one page of the equipment owned by the user the bearer token
        belongs to, ordered by id descending. Ids are UUIDv7 and encode
        the millisecond they were minted in, so this is most recently added
        first; ids minted within the same millisecond fall in a stable but
        arbitrary order.

        Retired items are included, and there is no search or sort selection in
        this version. Results are paginated; use the `page` and `per_page`
        query parameters to walk the whole set.
      tags: [Account]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: The user's equipment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EquipmentListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createEquipment
      summary: Create an equipment item
      description: |
        Creates an equipment item owned by the user the bearer token belongs to.

        The request body is a flat JSON object of the item's writable fields (no
        resource wrapper); only `name` is required. On success the new item is
        returned under `data` with `201 Created` and a `Location` header. A
        malformed JSON body yields `400`; a well-formed body that fails
        validation yields `422` with a per-field `details` array.
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EquipmentCreateRequest"
      responses:
        "201":
          description: The created equipment item.
          headers:
            Location:
              description: URL of the newly created equipment item.
              schema:
                type: string
                format: uri
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EquipmentResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /equipment/{id}:
    get:
      operationId: getEquipment
      summary: Fetch one of the authenticated user's equipment items
      description: |
        Returns a single equipment item owned by the user the bearer token
        belongs to, addressed by its UUIDv7 id.

        An id that does not exist and an id owned by another user are
        indistinguishable: both yield 404. The API never reveals whether
        another user's equipment item exists.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: The equipment item's UUIDv7 identifier.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "200":
          description: The requested equipment item.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EquipmentResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateEquipment
      summary: Update one of the authenticated user's equipment items
      description: |
        Updates the equipment item identified by `id`, owned by the user the
        bearer token belongs to. The request body is a flat JSON object of the
        writable fields to change; omitted fields are left unchanged (partial
        update), and no field is required.

        On success the updated item is returned under `data` with `200 OK`. An
        unknown id, or one owned by a different user, returns `404`. A malformed
        JSON body yields `400`; a well-formed body that fails validation yields
        `422` with a per-field `details` array, leaving the stored record
        unchanged.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the equipment item to update.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EquipmentWritable"
      responses:
        "200":
          description: The updated equipment item.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EquipmentResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteEquipment
      summary: Delete one of the authenticated user's equipment items
      description: |
        Deletes the equipment item identified by `id`. The item must be owned by
        the user the bearer token belongs to.

        A successful delete returns `204 No Content` with an empty body. An
        unknown id, or one owned by a different user, returns `404` with the
        `not_found` error: the two cases are deliberately indistinguishable.
      tags: [Account]
      parameters:
        - name: id
          in: path
          required: true
          description: UUIDv7 of the equipment item to delete.
          schema:
            type: string
            format: uuid
          example: "0199aa10-7000-7abc-8def-000000000001"
      responses:
        "204":
          description: The equipment item was deleted. No response body.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: dvb_*
      description: |
        A personal API token issued to the calling user. Tokens start with
        the `dvb_` prefix followed by 43 url-safe base64 characters
        (47 characters total).

  parameters:
    PageParam:
      name: page
      in: query
      required: false
      description: |
        The 1-based page number to return. A value below 1 is treated as the
        first page; a page beyond the last returns an empty `data` array with
        an accurate pagination node.
      schema:
        type: integer
        minimum: 1
        default: 1
      example: 1

    PerPageParam:
      name: per_page
      in: query
      required: false
      description: |
        Rows per page. Must be one of the offered sizes; any other value (out
        of range, non-positive, or malformed) falls back to the default of 25.
      schema:
        type: integer
        enum: [10, 25, 50, 100]
        default: 25
      example: 25

  schemas:
    User:
      type: object
      required: [id, email, created_at]
      properties:
        id:
          type: string
          format: uuid
          description: UUIDv7 identifier in canonical hyphenated form.
          example: "01900000-1234-7000-8000-000000000000"
        email:
          type: string
          format: email
          description: The user's normalized email address.
          example: user@example.com
        created_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the user signed up.
          example: "2026-05-21T15:24:07.000Z"

    WhoamiResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/User"

    Buddy:
      type: object
      required:
        - id
        - first_name
        - last_name
        - email
        - phone
        - language
        - certification_agency
        - certification_name
        - notes
        - dives_count
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: UUIDv7 identifier in canonical hyphenated form.
          example: "0199aa10-7000-7abc-8def-000000000001"
        first_name:
          type: [string, "null"]
          description: |
            The given name, or null. At least one of first_name and last_name
            is always non-null.
          example: Marco
        last_name:
          type: [string, "null"]
          description: |
            The family name, or null. At least one of first_name and last_name
            is always non-null.
          example: Ferrari
        email:
          type: [string, "null"]
          format: email
          description: Email address, stored trimmed and lowercased, or null.
          example: marco.ferrari@example.com
        phone:
          type: [string, "null"]
          description: Free-text phone number, not format-validated, or null.
          example: "+39 333 1234567"
        language:
          type: [string, "null"]
          enum:
          - en
          - de
          - fr
          - es
          - it
          - pt
          - nl
          - sv
          - "no"
          - da
          - fi
          - pl
          - ru
          - el
          - tr
          - ar
          - he
          - ja
          - zh
          - ko
          - th
          - id
          - tl
          - null
          description: An ISO 639-1 code for a language the buddy speaks, or null.
          example: it
        certification_agency:
          type: [string, "null"]
          description: The agency behind the buddy's headline certification, or null.
          example: PADI
        certification_name:
          type: [string, "null"]
          description: The buddy's headline certification, or null.
          example: Master Scuba Diver
        notes:
          type: [string, "null"]
          description: Free-form notes, or null.
          example: Met during a liveaboard in the Red Sea.
        dives_count:
          type: integer
          minimum: 0
          description: |
            The number of dives the buddy was on, the "dives together" figure.
            System-maintained and read-only; it changes as the buddy's dive
            links change.
          example: 2
        created_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the buddy was recorded.
          example: "2026-05-18T16:50:59.000Z"
        updated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the buddy last changed.
          example: "2026-05-18T16:50:59.000Z"

    BuddyListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Buddy"
        pagination:
          $ref: "#/components/schemas/Pagination"

    BuddyResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/Buddy"

    BuddyWritable:
      type: object
      description: |
        The writable fields of a buddy, sent as a flat JSON object with no
        resource wrapper. Read-only fields (id, dives_count, timestamps,
        ownership) are ignored if present. A blank free-text string is stored as
        null, and email is additionally trimmed and lowercased. Clear the
        enumerated language with null rather than a blank string, which the
        enum does not admit.
      properties:
        first_name:
          type: [string, "null"]
          description: |
            The given name, or null. At least one of first_name and last_name
            must be present.
          example: Marco
        last_name:
          type: [string, "null"]
          description: |
            The family name, or null. At least one of first_name and last_name
            must be present.
          example: Ferrari
        email:
          type: [string, "null"]
          format: email
          description: Email address, or null. Must be syntactically valid when present.
          example: marco.ferrari@example.com
        phone:
          type: [string, "null"]
          description: Free-text phone number, not format-validated, or null.
          example: "+39 333 1234567"
        language:
          type: [string, "null"]
          enum:
          - en
          - de
          - fr
          - es
          - it
          - pt
          - nl
          - sv
          - "no"
          - da
          - fi
          - pl
          - ru
          - el
          - tr
          - ar
          - he
          - ja
          - zh
          - ko
          - th
          - id
          - tl
          - null
          description: An ISO 639-1 code for a language the buddy speaks, or null.
          example: it
        certification_agency:
          type: [string, "null"]
          description: The agency behind the buddy's headline certification, or null.
          example: PADI
        certification_name:
          type: [string, "null"]
          description: The buddy's headline certification, or null.
          example: Master Scuba Diver
        notes:
          type: [string, "null"]
          description: Free-form notes, or null.
          example: Met during a liveaboard in the Red Sea.

    BuddyCreateRequest:
      description: |
        A buddy has no single required field. The constraint spans two of them:
        at least one of first_name and last_name must be non-blank, which is why
        this schema carries an anyOf rather than the flat required list the
        sibling create requests use. The server stores a blank or whitespace-only
        string as null, so each branch constrains the value and not just the
        presence of the key.
      allOf:
        - $ref: "#/components/schemas/BuddyWritable"
        - anyOf:
            - required: [first_name]
              properties:
                first_name:
                  type: string
                  pattern: "\\S"
            - required: [last_name]
              properties:
                last_name:
                  type: string
                  pattern: "\\S"

    Certification:
      type: object
      required:
        - id
        - name
        - agency
        - certificate_number
        - issued_on
        - expires_on
        - instructor_name
        - instructor_number
        - notes
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: UUIDv7 identifier in canonical hyphenated form.
          example: "0199aa10-7000-7abc-8def-000000000001"
        name:
          type: string
          description: The certification's display name as printed on the card.
          example: Tech 1
        agency:
          type: [string, "null"]
          description: The issuing organization, or null.
          example: GUE
        certificate_number:
          type: [string, "null"]
          description: The number printed on the card, or null.
          example: "0906EX5236"
        issued_on:
          type: [string, "null"]
          format: date
          description: Day-precision date the certification was earned, or null.
          example: "2024-03-01"
        expires_on:
          type: [string, "null"]
          format: date
          description: Day-precision expiry date, or null for a lifetime certification.
          example: null
        instructor_name:
          type: [string, "null"]
          description: The instructor of record, or null.
          example: Mario Arena
        instructor_number:
          type: [string, "null"]
          description: The instructor's identifier, or null.
          example: null
        notes:
          type: [string, "null"]
          description: Free-form notes, or null.
          example: null
        created_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the certification was recorded.
          example: "2026-05-21T15:24:07.000Z"
        updated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the certification last changed.
          example: "2026-05-21T15:24:07.000Z"

    CertificationListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Certification"
        pagination:
          $ref: "#/components/schemas/Pagination"

    CertificationResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/Certification"

    CertificationWritable:
      type: object
      description: |
        The writable fields of a certification, sent as a flat JSON object with
        no resource wrapper. Read-only fields (id, timestamps, ownership) are
        ignored if present. A blank optional string is stored as null. The
        front and back card images are not writable over the API.
      properties:
        name:
          type: string
          description: The certification's display name. Required on create.
          example: Wreck Diver
        agency:
          type: [string, "null"]
          description: The issuing organization, or null.
          example: PADI
        certificate_number:
          type: [string, "null"]
          description: The number printed on the card, or null.
          example: "WRK-001"
        issued_on:
          type: [string, "null"]
          format: date
          description: Day-precision date the certification was earned (YYYY-MM-DD), or null.
          example: "2022-05-01"
        expires_on:
          type: [string, "null"]
          format: date
          description: Day-precision expiry date (YYYY-MM-DD), or null for a lifetime certification.
          example: "2027-05-01"
        instructor_name:
          type: [string, "null"]
          description: The instructor of record, or null.
          example: Jane Roe
        instructor_number:
          type: [string, "null"]
          description: The instructor's identifier, or null.
          example: "INSTR-9"
        notes:
          type: [string, "null"]
          description: Free-form notes, or null.
          example: Earned in Malta.

    CertificationCreateRequest:
      allOf:
        - $ref: "#/components/schemas/CertificationWritable"
      required: [name]

    Cylinder:
      type: object
      required:
        - id
        - name
        - material
        - volume
        - working_pressure
        - twin
        - favourite
        - default
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: UUIDv7 identifier in canonical hyphenated form.
          example: "0199aa10-7000-7abc-8def-000000000001"
        name:
          type: string
          description: The cylinder's name.
          example: Steel 12
        material:
          type: string
          enum: [steel, aluminium, carbon]
          description: What the cylinder is made of.
          example: steel
        volume:
          type: string
          description: |
            Per-cylinder water volume in litres, serialized as a string to
            preserve precision.
          example: "12.0"
        working_pressure:
          type: integer
          description: Rated fill pressure in bar.
          example: 232
        twin:
          type: boolean
          description: True for a manifolded pair of cylinders, false for a single.
          example: false
        favourite:
          type: boolean
          description: True when the diver has favourited the cylinder.
          example: true
        default:
          type: boolean
          description: |
            True for the cylinder pre-selected when logging a dive; at most one
            per user.
          example: true
        created_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the cylinder was recorded.
          example: "2026-05-18T16:50:59.000Z"
        updated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the cylinder last changed.
          example: "2026-05-18T16:50:59.000Z"

    CylinderListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Cylinder"
        pagination:
          $ref: "#/components/schemas/Pagination"

    CylinderResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/Cylinder"

    CylinderWritable:
      type: object
      description: |
        The writable fields of a cylinder, sent as a flat JSON object with no
        resource wrapper. Read-only fields (id, timestamps, ownership) are
        ignored if present.
      properties:
        name:
          type: string
          description: The cylinder's name. Required on create.
          example: AL80 stage
        material:
          type: string
          enum: [steel, aluminium, carbon]
          description: What the cylinder is made of. Required on create.
          example: aluminium
        volume:
          type: [number, string]
          description: |
            Per-cylinder water volume in litres. Accepts a JSON number or a
            decimal string; always serialized back as a string. Required on
            create; must be greater than 0.
          example: 11.1
        working_pressure:
          type: integer
          minimum: 1
          description: |
            Rated fill pressure in bar. Required on create; must be greater
            than 0.
          example: 207
        twin:
          type: boolean
          description: True for a manifolded pair of cylinders. Defaults to false.
          example: false
        favourite:
          type: boolean
          description: True to favourite the cylinder. Defaults to false.
          example: false
        default:
          type: boolean
          description: |
            True to make this the pre-selected cylinder when logging a dive;
            setting it clears the previous default. Defaults to false.
          example: false

    CylinderCreateRequest:
      allOf:
        - $ref: "#/components/schemas/CylinderWritable"
      required: [name, material, volume, working_pressure]

    GasMix:
      type: object
      required:
        - id
        - name
        - oxygen
        - helium
        - ppo2
        - use
        - favourite
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: UUIDv7 identifier in canonical hyphenated form.
          example: "0199aa10-7000-7abc-8def-000000000001"
        name:
          type: string
          description: The gas mix's name.
          example: Nitrox 32
        oxygen:
          type: integer
          description: Oxygen percentage, a whole number from 1 to 100.
          example: 32
        helium:
          type: integer
          description: Helium percentage, a whole number from 0 to 99.
          example: 0
        ppo2:
          type: string
          description: |
            Operating partial pressure of oxygen, serialized as a string to
            preserve precision.
          example: "1.4"
        use:
          type: string
          enum: [bottom, deco]
          description: How the mix is breathed.
          example: bottom
        favourite:
          type: boolean
          description: True when the diver has favourited the mix.
          example: true
        created_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the gas mix was recorded.
          example: "2026-05-18T16:50:59.000Z"
        updated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the gas mix last changed.
          example: "2026-05-18T16:50:59.000Z"

    GasMixListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/GasMix"
        pagination:
          $ref: "#/components/schemas/Pagination"

    GasMixResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/GasMix"

    GasMixWritable:
      type: object
      description: |
        The writable fields of a gas mix, sent as a flat JSON object with no
        resource wrapper. Read-only fields (id, timestamps, ownership) are
        ignored if present.
      properties:
        name:
          type: string
          description: The gas mix's name. Required on create.
          example: Back gas 18/45
        oxygen:
          type: integer
          minimum: 1
          maximum: 100
          description: Oxygen percentage. Required on create.
          example: 18
        helium:
          type: integer
          minimum: 0
          maximum: 99
          description: Helium percentage. Defaults to 0.
          example: 45
        ppo2:
          type: [number, string]
          exclusiveMinimum: 0
          maximum: 3
          description: |
            Operating partial pressure of oxygen. Accepts a JSON number or a
            decimal string; always serialized back as a string. Defaults to
            1.4; must be greater than 0 and at most 3.
          example: 1.4
        use:
          type: string
          enum: [bottom, deco]
          description: How the mix is breathed. Defaults to bottom.
          example: bottom
        favourite:
          type: boolean
          description: True to favourite the mix. Defaults to false.
          example: false

    GasMixCreateRequest:
      allOf:
        - $ref: "#/components/schemas/GasMixWritable"
      required: [name, oxygen]

    DiveSite:
      type: object
      required:
        - id
        - name
        - country
        - region
        - location
        - latitude
        - longitude
        - altitude
        - difficulty
        - rating
        - water_type
        - water_body
        - interests
        - notes
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: UUIDv7 identifier in canonical hyphenated form.
          example: "0199aa10-7000-7abc-8def-000000000001"
        name:
          type: string
          description: The dive site's name.
          example: Brodness (ex-Banffshire)
        country:
          type: [string, "null"]
          enum:
          - AD
          - AE
          - AF
          - AG
          - AI
          - AL
          - AM
          - AO
          - AQ
          - AR
          - AS
          - AT
          - AU
          - AW
          - AX
          - AZ
          - BA
          - BB
          - BD
          - BE
          - BF
          - BG
          - BH
          - BI
          - BJ
          - BL
          - BM
          - BN
          - BO
          - BQ
          - BR
          - BS
          - BT
          - BV
          - BW
          - BY
          - BZ
          - CA
          - CC
          - CD
          - CF
          - CG
          - CH
          - CI
          - CK
          - CL
          - CM
          - CN
          - CO
          - CR
          - CU
          - CV
          - CW
          - CX
          - CY
          - CZ
          - DE
          - DJ
          - DK
          - DM
          - DO
          - DZ
          - EC
          - EE
          - EG
          - EH
          - ER
          - ES
          - ET
          - FI
          - FJ
          - FK
          - FM
          - FO
          - FR
          - GA
          - GB
          - GD
          - GE
          - GF
          - GG
          - GH
          - GI
          - GL
          - GM
          - GN
          - GP
          - GQ
          - GR
          - GS
          - GT
          - GU
          - GW
          - GY
          - HK
          - HM
          - HN
          - HR
          - HT
          - HU
          - ID
          - IE
          - IL
          - IM
          - IN
          - IO
          - IQ
          - IR
          - IS
          - IT
          - JE
          - JM
          - JO
          - JP
          - KE
          - KG
          - KH
          - KI
          - KM
          - KN
          - KP
          - KR
          - KW
          - KY
          - KZ
          - LA
          - LB
          - LC
          - LI
          - LK
          - LR
          - LS
          - LT
          - LU
          - LV
          - LY
          - MA
          - MC
          - MD
          - ME
          - MF
          - MG
          - MH
          - MK
          - ML
          - MM
          - MN
          - MO
          - MP
          - MQ
          - MR
          - MS
          - MT
          - MU
          - MV
          - MW
          - MX
          - MY
          - MZ
          - NA
          - NC
          - NE
          - NF
          - NG
          - NI
          - NL
          - "NO"
          - NP
          - NR
          - NU
          - NZ
          - OM
          - PA
          - PE
          - PF
          - PG
          - PH
          - PK
          - PL
          - PM
          - PN
          - PR
          - PS
          - PT
          - PW
          - PY
          - QA
          - RE
          - RO
          - RS
          - RU
          - RW
          - SA
          - SB
          - SC
          - SD
          - SE
          - SG
          - SH
          - SI
          - SJ
          - SK
          - SL
          - SM
          - SN
          - SO
          - SR
          - SS
          - ST
          - SV
          - SX
          - SY
          - SZ
          - TC
          - TD
          - TF
          - TG
          - TH
          - TJ
          - TK
          - TL
          - TM
          - TN
          - TO
          - TR
          - TT
          - TV
          - TW
          - TZ
          - UA
          - UG
          - UM
          - US
          - UY
          - UZ
          - VA
          - VC
          - VE
          - VG
          - VI
          - VN
          - VU
          - WF
          - WS
          - YE
          - YT
          - ZA
          - ZM
          - ZW
          - null
          description: ISO 3166-1 alpha-2 country code, or null.
          example: IT
        region:
          type: [string, "null"]
          description: The region between country and locality, or null.
          example: Lazio
        location:
          type: [string, "null"]
          description: A free-text locality descriptor, or null.
          example: Anzio
        latitude:
          type: [string, "null"]
          description: |
            Latitude in decimal degrees within [-90, 90], or null. Serialized
            as a string to preserve precision.
          example: "41.5284079"
        longitude:
          type: [string, "null"]
          description: |
            Longitude in decimal degrees within [-180, 180], or null. Serialized
            as a string to preserve precision.
          example: "12.4432353"
        altitude:
          type: [string, "null"]
          description: |
            Altitude in meters above sea level, or null. Serialized as a string
            to preserve precision.
          example: "0.0"
        heading:
          type: [integer, "null"]
          minimum: 0
          maximum: 359
          description: |
            Compass orientation of the site feature in degrees within [0, 360),
            with 0 = north, or null.
          example: 183
        difficulty:
          type: [integer, "null"]
          minimum: 1
          maximum: 5
          description: Difficulty level from 1 (easiest) to 5 (most specialized), or null.
          example: 5
        rating:
          type: [integer, "null"]
          minimum: 1
          maximum: 5
          description: User-supplied star rating from 1 to 5, or null.
          example: 5
        water_type:
          type: [string, "null"]
          enum: [fresh, salt, null]
          description: The water type, one of `fresh` or `salt`, or null.
          example: salt
        water_body:
          type: [string, "null"]
          enum: [ocean, lake, quarry, river, spring, pool, other, null]
          description: >-
            The body of water, one of `ocean`, `lake`, `quarry`, `river`,
            `spring`, `pool`, `other`, or null.
          example: quarry
        interests:
          type: array
          items:
            type: string
            enum: [wreck, cave, pinnacle, wall, reef]
          description: |
            Zero or more site interests, in catalogue order. Empty array when
            none.
          example: [wreck, reef]
        notes:
          type: [string, "null"]
          description: Free-form notes, or null.
          example: null
        created_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the dive site was recorded.
          example: "2026-05-18T16:50:59.000Z"
        updated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the dive site last changed.
          example: "2026-05-18T16:50:59.000Z"

    DiveSiteListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/DiveSite"
        pagination:
          $ref: "#/components/schemas/Pagination"

    DiveSiteResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/DiveSite"

    DiveSiteWritable:
      type: object
      description: |
        The writable fields of a dive site, sent as a flat JSON object with no
        resource wrapper. Read-only fields (id, timestamps, ownership) are
        ignored if present. A blank optional string is stored as null.
      properties:
        name:
          type: string
          description: The dive site's name. Required on create.
          example: Lo Scoglietto
        country:
          type: [string, "null"]
          enum:
          - AD
          - AE
          - AF
          - AG
          - AI
          - AL
          - AM
          - AO
          - AQ
          - AR
          - AS
          - AT
          - AU
          - AW
          - AX
          - AZ
          - BA
          - BB
          - BD
          - BE
          - BF
          - BG
          - BH
          - BI
          - BJ
          - BL
          - BM
          - BN
          - BO
          - BQ
          - BR
          - BS
          - BT
          - BV
          - BW
          - BY
          - BZ
          - CA
          - CC
          - CD
          - CF
          - CG
          - CH
          - CI
          - CK
          - CL
          - CM
          - CN
          - CO
          - CR
          - CU
          - CV
          - CW
          - CX
          - CY
          - CZ
          - DE
          - DJ
          - DK
          - DM
          - DO
          - DZ
          - EC
          - EE
          - EG
          - EH
          - ER
          - ES
          - ET
          - FI
          - FJ
          - FK
          - FM
          - FO
          - FR
          - GA
          - GB
          - GD
          - GE
          - GF
          - GG
          - GH
          - GI
          - GL
          - GM
          - GN
          - GP
          - GQ
          - GR
          - GS
          - GT
          - GU
          - GW
          - GY
          - HK
          - HM
          - HN
          - HR
          - HT
          - HU
          - ID
          - IE
          - IL
          - IM
          - IN
          - IO
          - IQ
          - IR
          - IS
          - IT
          - JE
          - JM
          - JO
          - JP
          - KE
          - KG
          - KH
          - KI
          - KM
          - KN
          - KP
          - KR
          - KW
          - KY
          - KZ
          - LA
          - LB
          - LC
          - LI
          - LK
          - LR
          - LS
          - LT
          - LU
          - LV
          - LY
          - MA
          - MC
          - MD
          - ME
          - MF
          - MG
          - MH
          - MK
          - ML
          - MM
          - MN
          - MO
          - MP
          - MQ
          - MR
          - MS
          - MT
          - MU
          - MV
          - MW
          - MX
          - MY
          - MZ
          - NA
          - NC
          - NE
          - NF
          - NG
          - NI
          - NL
          - "NO"
          - NP
          - NR
          - NU
          - NZ
          - OM
          - PA
          - PE
          - PF
          - PG
          - PH
          - PK
          - PL
          - PM
          - PN
          - PR
          - PS
          - PT
          - PW
          - PY
          - QA
          - RE
          - RO
          - RS
          - RU
          - RW
          - SA
          - SB
          - SC
          - SD
          - SE
          - SG
          - SH
          - SI
          - SJ
          - SK
          - SL
          - SM
          - SN
          - SO
          - SR
          - SS
          - ST
          - SV
          - SX
          - SY
          - SZ
          - TC
          - TD
          - TF
          - TG
          - TH
          - TJ
          - TK
          - TL
          - TM
          - TN
          - TO
          - TR
          - TT
          - TV
          - TW
          - TZ
          - UA
          - UG
          - UM
          - US
          - UY
          - UZ
          - VA
          - VC
          - VE
          - VG
          - VI
          - VN
          - VU
          - WF
          - WS
          - YE
          - YT
          - ZA
          - ZM
          - ZW
          - null
          description: ISO 3166-1 alpha-2 country code, or null.
          example: IT
        region:
          type: [string, "null"]
          description: The region between country and locality, or null.
          example: Tuscan Archipelago
        location:
          type: [string, "null"]
          description: A free-text locality descriptor, or null.
          example: Isola d'Elba
        latitude:
          type: [number, string, "null"]
          description: |
            Latitude in decimal degrees within [-90, 90]. Accepts a JSON number
            or a decimal string; always serialized back as a string.
          example: 42.7628
        longitude:
          type: [number, string, "null"]
          description: |
            Longitude in decimal degrees within [-180, 180]. Accepts a JSON
            number or a decimal string; always serialized back as a string.
          example: 10.1503
        altitude:
          type: [number, string, "null"]
          description: |
            Altitude in meters above sea level. Accepts a JSON number or a
            decimal string; always serialized back as a string.
          example: 0
        heading:
          type: [integer, "null"]
          minimum: 0
          maximum: 359
          description: |
            Compass orientation of the site feature in degrees within [0, 360),
            with 0 = north. Whole degrees; serialized back as an integer.
          example: 183
        difficulty:
          type: [integer, "null"]
          minimum: 1
          maximum: 5
          description: Difficulty level from 1 (easiest) to 5 (most specialized), or null.
          example: 3
        rating:
          type: [integer, "null"]
          minimum: 1
          maximum: 5
          description: User-supplied star rating from 1 to 5, or null.
          example: 4
        water_type:
          type: [string, "null"]
          enum: [fresh, salt, null]
          description: |
            The water type, one of `fresh` or `salt`. A blank value clears it
            to null; an unknown value fails validation.
          example: salt
        water_body:
          type: [string, "null"]
          enum: [ocean, lake, quarry, river, spring, pool, other, null]
          description: |
            The body of water, one of `ocean`, `lake`, `quarry`, `river`,
            `spring`, `pool`, or `other`. A blank value clears it to null; an
            unknown value fails validation.
          example: quarry
        interests:
          type: array
          items:
            type: string
            enum: [wreck, cave, pinnacle, wall, reef]
          description: |
            Set-replace list of site interests. The supplied array replaces the
            stored set in full; an empty array clears it. Stored de-duplicated
            and ordered to the catalogue. On Update, omitting the field leaves
            it unchanged.
          example: [wreck, reef]
        notes:
          type: [string, "null"]
          description: Free-form notes, or null.
          example: Sheltered cove, easy shore entry.

    DiveSiteCreateRequest:
      allOf:
        - $ref: "#/components/schemas/DiveSiteWritable"
      required: [name]

    Dive:
      type: object
      required:
        - id
        - number
        - started_at
        - timezone
        - duration
        - max_depth
        - average_depth
        - mode
        - bottom_duration
        - surface_interval
        - repetitive_number
        - decompression
        - deco_model
        - deco_model_level
        - deco_duration
        - cns
        - otu
        - temperature_air
        - temperature_min
        - temperature_max
        - visibility_distance
        - visibility_clarity
        - current
        - weather
        - entry_type
        - exit_type
        - weight
        - dive_site_id
        - rating
        - notes
        - imported_at
        - source
        - source_reference
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: UUIDv7 identifier in canonical hyphenated form.
          example: "0199aa10-7000-7abc-8def-000000000001"
        number:
          type: [integer, "null"]
          description: The diver-supplied dive number, or null. Not required to be unique.
          example: 142
        started_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp (UTC) of when the dive began.
          example: "2026-05-01T10:00:00.000Z"
        timezone:
          type: [string, "null"]
          description: |
            IANA timezone name the dive was experienced in (e.g. Europe/Rome),
            or null.
          example: Europe/Rome
        duration:
          type: integer
          description: The dive's length in whole seconds.
          example: 3720
        max_depth:
          type: string
          description: |
            Maximum depth in meters, serialized as a string to preserve
            precision.
          example: "28.5"
        average_depth:
          type: [string, "null"]
          description: |
            Mean depth in meters, serialized as a string to preserve precision,
            or null.
          example: "18.4"
        mode:
          type: [string, "null"]
          enum: [opencircuit, closedcircuit, semiclosedcircuit, gauge, freedive, null]
          description: The diver's breathing setup, or null when unspecified.
          example: closedcircuit
        bottom_duration:
          type: [integer, "null"]
          description: Time on the bottom in whole seconds, or null.
          example: 2280
        surface_interval:
          type: [integer, "null"]
          description: |
            Surface time since the previous dive, in whole seconds, or null.
          example: 5700
        repetitive_number:
          type: [integer, "null"]
          description: The dive's 1-based position within a repetitive series, or null.
          example: 2
        decompression:
          type: [boolean, "null"]
          description: |
            true for a decompression dive, false for no-deco, or null when
            unknown.
          example: true
        deco_model:
          type: [string, "null"]
          description: The decompression algorithm, or null.
          example: "ZHL-16C"
        deco_model_level:
          type: [string, "null"]
          description: |
            Gradient factors or conservatism level (e.g. 40/85 or +3), or null.
          example: "40/85"
        deco_duration:
          type: [integer, "null"]
          description: Time in decompression, in whole seconds, or null.
          example: 720
        cns:
          type: [string, "null"]
          description: |
            CNS oxygen-toxicity percent, serialized as a string to preserve
            precision, or null.
          example: "28.5"
        otu:
          type: [integer, "null"]
          description: Oxygen tolerance units, or null.
          example: 41
        temperature_air:
          type: [string, "null"]
          description: |
            Surface air temperature in °C, serialized as a string to preserve
            precision, or null.
          example: "24.0"
        temperature_min:
          type: [string, "null"]
          description: |
            Coldest water temperature over the dive in °C, serialized as a
            string, or null. Defaults from the profile minimum when present.
          example: "16.0"
        temperature_max:
          type: [string, "null"]
          description: |
            Warmest water temperature over the dive in °C, serialized as a
            string, or null. Defaults from the profile maximum when present.
          example: "21.0"
        visibility_distance:
          type: [string, "null"]
          description: |
            Horizontal visibility in meters, serialized as a string, or null.
          example: "15.0"
        visibility_clarity:
          type: [integer, "null"]
          enum: [1, 2, 3, 4, 5, null]
          description: |
            Clarity rating from 1 (Zero) to 5 (Excellent), or null.
          example: 4
        current:
          type: [string, "null"]
          enum: [none, light, moderate, strong, null]
          description: The water current strength, or null.
          example: light
        weather:
          type: [string, "null"]
          enum: [sunny, cloudy, rainy, windy, foggy, null]
          description: The surface weather, or null.
          example: sunny
        entry_type:
          type: [string, "null"]
          enum: [boat, shore, null]
          description: How the diver entered the water, or null.
          example: boat
        exit_type:
          type: [string, "null"]
          enum: [boat, shore, null]
          description: How the diver exited the water, or null.
          example: boat
        weight:
          type: [string, "null"]
          description: |
            Ballast carried in kilograms, serialized as a string, or null.
          example: "6.0"
        dive_site_id:
          type: [string, "null"]
          format: uuid
          description: |
            UUIDv7 of the dive site this dive references, or null. Resolve via
            GET /v1/dive_sites.
          example: "0199aa10-7000-7abc-8def-0000000000aa"
        rating:
          type: [integer, "null"]
          enum: [1, 2, 3, 4, 5, null]
          description: A 1..5 star rating, or null when unrated.
          example: 4
        notes:
          type: [string, "null"]
          description: Free-form notes, or null.
          example: null
        imported_at:
          type: [string, "null"]
          format: date-time
          description: |
            ISO 8601 timestamp of when the dive was imported into the logbook,
            or null for manually-logged dives.
          example: null
        source:
          type: [string, "null"]
          description: |
            Provenance tag of the import the dive came from (e.g. uddf, fit,
            dive_envelope, macdive, api), or null for a manually-logged dive.
            Read-only.
          example: macdive
        source_reference:
          type: [string, "null"]
          description: |
            Stable per-source locator identifying the dive within its source
            (e.g. a UDDF dive id, a MacDive ZUUID, an API external_id), or null.
            Read-only; with `source` it lets a tool resolve one of its records to
            this dive's id, then attach a profile.
          example: "F1E2D3C4-B5A6-7890-1234-567890ABCDEF"
        created_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the dive was recorded.
          example: "2026-05-18T16:50:59.000Z"
        updated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the dive last changed.
          example: "2026-05-18T16:50:59.000Z"

    DiveListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Dive"
        pagination:
          $ref: "#/components/schemas/Pagination"

    DiveResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/Dive"

    DiveShowResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/DiveShow"

    DiveShow:
      description: |
        The single-dive (Read) representation: the base dive plus its profile's
        sample series. The list and write responses use the base Dive shape; only
        GET /dives/{id} adds `samples`.
      allOf:
        - $ref: "#/components/schemas/Dive"
        - type: object
          required: [samples]
          properties:
            samples:
              type: array
              description: |
                The dive profile's full normalized sample series, ordered by
                elapsed time, or an empty array when the dive has no profile.
              items:
                $ref: "#/components/schemas/DiveSample"

    DiveWritable:
      type: object
      description: |
        The writable fields of a dive, sent as a flat JSON object with no resource
        wrapper. Read-only fields (id, imported_at, timestamps, ownership) are
        ignored if present. A blank optional string is stored as null.
      properties:
        number:
          type: [integer, "null"]
          description: The diver-supplied dive number, or null. Not required to be unique.
          example: 142
        started_at:
          type: string
          format: date-time
          description: |
            Absolute ISO 8601 instant (carry an offset or Z) when the dive began.
            Required on create.
          example: "2026-05-01T10:00:00.000Z"
        timezone:
          type: [string, "null"]
          description: |
            IANA timezone name the dive was experienced in (e.g. Europe/Rome), or
            null. Independent metadata: it does not reinterpret started_at.
          example: Europe/Rome
        duration:
          type: integer
          description: The dive's length in whole seconds. Required on create.
          example: 3720
        max_depth:
          type: [number, string]
          description: |
            Maximum depth in meters. Accepts a JSON number or a decimal string;
            always serialized back as a string. Required on create.
          example: 28.5
        average_depth:
          type: [number, string, "null"]
          description: |
            Mean depth in meters. Accepts a JSON number or a decimal string;
            always serialized back as a string. A profile supplies the default
            value but does not lock the field; an explicit value is stored as
            written.
          example: 18.4
        mode:
          type: [string, "null"]
          enum: [opencircuit, closedcircuit, semiclosedcircuit, gauge, freedive, null]
          description: The diver's breathing setup, or null. Any other value fails validation.
          example: closedcircuit
        bottom_duration:
          type: [integer, "null"]
          description: Time on the bottom in whole seconds, or null.
          example: 2280
        surface_interval:
          type: [integer, "null"]
          description: Surface time since the previous dive, in whole seconds, or null.
          example: 5700
        repetitive_number:
          type: [integer, "null"]
          description: The dive's 1-based position within a repetitive series, or null.
          example: 2
        decompression:
          type: [boolean, "null"]
          description: true for a decompression dive, false for no-deco, or null.
          example: true
        deco_model:
          type: [string, "null"]
          description: The decompression algorithm, or null.
          example: "ZHL-16C"
        deco_model_level:
          type: [string, "null"]
          description: Gradient factors or conservatism level (e.g. 40/85 or +3), or null.
          example: "40/85"
        deco_duration:
          type: [integer, "null"]
          description: Time in decompression, in whole seconds, or null.
          example: 720
        cns:
          type: [number, string, "null"]
          description: |
            CNS oxygen-toxicity percent. Accepts a JSON number or a decimal
            string; always serialized back as a string.
          example: 28.5
        otu:
          type: [integer, "null"]
          description: Oxygen tolerance units, or null.
          example: 41
        temperature_air:
          type: [number, string, "null"]
          description: |
            Surface air temperature in °C. Accepts a JSON number or a decimal
            string; always serialized back as a string.
          example: 24.0
        temperature_min:
          type: [number, string, "null"]
          description: |
            Coldest water temperature over the dive in °C. Accepts a JSON number
            or a decimal string; always serialized back as a string. Defaults
            from the profile minimum when present.
          example: 16.0
        temperature_max:
          type: [number, string, "null"]
          description: |
            Warmest water temperature over the dive in °C. Accepts a JSON number
            or a decimal string; always serialized back as a string. Defaults
            from the profile maximum when present.
          example: 21.0
        visibility_distance:
          type: [number, string, "null"]
          description: |
            Horizontal visibility in meters. Accepts a JSON number or a decimal
            string; always serialized back as a string. Must be ≥ 0.
          example: 15.0
        visibility_clarity:
          type: [integer, "null"]
          enum: [1, 2, 3, 4, 5, null]
          description: Clarity rating from 1 (Zero) to 5 (Excellent), or null.
          example: 4
        current:
          type: [string, "null"]
          enum: [none, light, moderate, strong, null]
          description: The water current strength, or null. Any other value fails validation.
          example: light
        weather:
          type: [string, "null"]
          enum: [sunny, cloudy, rainy, windy, foggy, null]
          description: The surface weather, or null. Any other value fails validation.
          example: sunny
        entry_type:
          type: [string, "null"]
          enum: [boat, shore, null]
          description: How the diver entered the water, or null. Any other value fails validation.
          example: boat
        exit_type:
          type: [string, "null"]
          enum: [boat, shore, null]
          description: How the diver exited the water, or null. Any other value fails validation.
          example: boat
        weight:
          type: [number, string, "null"]
          description: |
            Ballast carried in kilograms. Accepts a JSON number or a decimal
            string; always serialized back as a string. Must be ≥ 0.
          example: 6.0
        dive_site_id:
          type: [string, "null"]
          format: uuid
          description: |
            UUIDv7 of a dive site the caller owns, or null. A site owned by
            another user fails validation on `dive_site`.
          example: "0199aa10-7000-7abc-8def-0000000000aa"
        rating:
          type: [integer, "null"]
          enum: [1, 2, 3, 4, 5, null]
          description: A 1..5 star rating, or null.
          example: 4
        notes:
          type: [string, "null"]
          description: Free-form notes, or null.
          example: Drift along the wall, strong current.

    DiveCreateRequest:
      allOf:
        - $ref: "#/components/schemas/DiveWritable"
      required: [started_at, duration, max_depth]

    DiveImportRequest:
      description: |
        The body of POST /dives/import: the writable dive fields (started_at,
        duration, and max_depth required), plus the optional source identity,
        profile, gases, and raw device bytes the push carries. The profile, gases,
        and raw source are writable here even though they are not serialized back on
        the dive resource.
      allOf:
        - $ref: "#/components/schemas/DiveWritable"
        - type: object
          properties:
            external_id:
              type: [string, "null"]
              description: |
                The caller's stable identifier for this dive, stored as its
                source_reference for idempotent re-push. Falls back to the raw
                blob's SHA-256, then to no identity (every push creates a new dive).
              example: "diveexporter:9f8c1e"
            profile:
              $ref: "#/components/schemas/DiveImportProfile"
            gases:
              type: array
              description: The dive's breathing gases, in the order a sample's `gas` channel indexes.
              items:
                $ref: "#/components/schemas/DiveImportGas"
            raw:
              $ref: "#/components/schemas/DiveImportRaw"
      required: [started_at, duration, max_depth]

    DiveImportProfile:
      type: object
      description: The dive's recorded time series and the computer that recorded it.
      properties:
        samples:
          type: array
          description: |
            The ordered sample series in the profile sample schema (the same shape
            GET /dives/{id} returns). Fewer than two depth-bearing samples leave the
            dive profile-less rather than failing the push.
          items:
            $ref: "#/components/schemas/DiveSample"
        device:
          $ref: "#/components/schemas/DiveImportDevice"

    DiveImportDevice:
      type: object
      description: The recording dive computer's identity; every field optional.
      properties:
        manufacturer:
          type: string
          example: Shearwater
        model:
          type: string
          example: Perdix
        serial:
          type: string
          example: "12345678"
        firmware:
          type: string
          example: "2.96"

    DiveImportGas:
      type: object
      description: |
        One breathing gas used on the dive. The oxygen/helium pair is deduped into
        the caller's gas mixes by composition; consumption anchors to the caller's
        default cylinder unless a cylinder_id is given. A sample's `gas` channel is
        the 0-based index of this gas within the request's `gases` array.
      required: [oxygen]
      properties:
        oxygen:
          type: integer
          description: Oxygen percentage (1..100).
          example: 32
        helium:
          type: integer
          description: Helium percentage (0..99); 0 for air or nitrox.
          example: 0
        role:
          type: [string, "null"]
          enum: [bottom, deco, diluent, oxygen, bailout, null]
          description: The gas's role on the dive, or null.
          example: bottom
        start_pressure:
          type: [integer, "null"]
          description: Cylinder pressure when the gas was switched to, whole bar.
          example: 210
        end_pressure:
          type: [integer, "null"]
          description: Cylinder pressure when the gas was switched away, whole bar.
          example: 90
        start_offset:
          type: [integer, "null"]
          description: Elapsed seconds when the gas became active.
          example: 0
        end_offset:
          type: [integer, "null"]
          description: Elapsed seconds when the gas stopped being active.
          example: 2280
        cylinder_id:
          type: [string, "null"]
          format: uuid
          description: A cylinder the caller owns to anchor consumption to; the default cylinder when omitted.

    DiveImportRaw:
      type: object
      description: >-
        The original device bytes, preserved as the dive's source data. A preserved
        blob must name the parser that re-parses it, so parser_type is required
        alongside data.
      required: [data, parser_type]
      properties:
        data:
          type: string
          format: byte
          description: The raw device blob, base64-encoded.
        content_type:
          type: string
          description: The blob's media type; application/octet-stream when omitted.
          example: application/octet-stream
        parser_type:
          type: string
          description: >-
            The parser that re-parses these bytes (the vocabulary a .dive envelope's
            parserType uses). Required whenever data is present.
          example: shearwater_petrel

    DiveSample:
      type: object
      description: |
        One reading in a dive profile (see specs/dive_profiles.md "Sample
        schema"). Carries an elapsed time plus any channels the source recorded
        at that instant; an unrecorded channel is omitted, not null.
      required: [time]
      properties:
        time:
          type: integer
          description: Elapsed seconds since the dive started. Non-negative, non-decreasing across the series.
          example: 60
        depth:
          type: number
          description: Depth below the surface, meters.
          example: 18.4
        temp:
          type: number
          description: Water temperature, Celsius.
          example: 18.1
        pressure:
          type: array
          description: Per-tank pressure readings.
          items:
            type: object
            properties:
              tank:
                type: integer
                description: 0-based index of the dive's tank.
              bar:
                type: integer
                description: The reading in whole bar.
        ppo2:
          type: array
          description: Measured or calculated oxygen partial pressures.
          items:
            type: object
            properties:
              sensor:
                type: [integer, "null"]
                description: 0-based sensor index, or null when unidentified.
              bar:
                type: number
                description: Oxygen partial pressure, bar.
        setpoint:
          type: number
          description: Closed-circuit oxygen setpoint, bar.
        cns:
          type: number
          description: Central-nervous-system oxygen-toxicity, percent.
        otu:
          type: number
          description: Oxygen tolerance units accrued to this point.
        ndl:
          type: integer
          description: No-decompression limit remaining, seconds.
        tts:
          type: integer
          description: Time-to-surface, seconds.
        rbt:
          type: integer
          description: Remaining bottom time, seconds.
        deco:
          type: object
          description: Current decompression obligation.
          properties:
            type:
              type: string
              enum: [ndl, decostop, safetystop, deepstop]
            depth:
              type: number
              description: The ceiling in meters.
            time:
              type: integer
              description: The stop time in seconds.
        gas:
          type: integer
          description: 0-based index of the dive's gas in use, set where the gas takes effect.
        events:
          type: array
          description: Discrete markers at this sample (gas switches and dive-computer events).
          items:
            type: object
            required: [type]
            properties:
              type:
                type: string
                description: >-
                  A lowercase libdivecomputer event token (e.g. ascent, deepstop, bookmark,
                  heading, po2), or gasswitch for a gas change.
              name:
                type: string
                description: The event's human-readable name, when it has one (e.g. Ascent Rate Warning).
              gas:
                type: string
                description: The gas switched to, when type is gasswitch.
              value:
                type: integer
                description: The event's numeric payload, when it has one (the libdivecomputer event value).
        heartrate:
          type: integer
          description: Heart rate, beats per minute.
        bearing:
          type: integer
          description: Compass heading, degrees (0-359).
        mode:
          type: string
          enum: [opencircuit, closedcircuit, semiclosedcircuit, gauge, freedive]
          description: >-
            The breathing loop in use. Set where the mode takes effect (as gas is), so
            carry the last value forward; some sources stamp every sample.
          example: closedcircuit

    Equipment:
      type: object
      required:
        - id
        - name
        - category
        - manufacturer
        - model
        - serial_number
        - weight
        - url
        - notes
        - purchased_on
        - price_amount
        - price_currency
        - purchased_from
        - next_service_on
        - archived_at
        - dives_count
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: UUIDv7 identifier in canonical hyphenated form.
          example: "0199aa10-7000-7abc-8def-000000000001"
        name:
          type: string
          description: The equipment item's name.
          example: Mk25 left post
        category:
          type: [string, "null"]
          description: |
            Canonical category code (e.g. regulator, bcd, computer), or null.
            See the Equipment categories list for the full set of codes.
          example: regulator
        manufacturer:
          type: [string, "null"]
          description: The brand that made the item, or null.
          example: Scubapro
        model:
          type: [string, "null"]
          description: The model designation, or null.
          example: Mk25
        serial_number:
          type: [string, "null"]
          description: The item's serial identifier, or null.
          example: SCB-12345
        weight:
          type: [string, "null"]
          description: |
            The item's own mass in kilograms, or null. Serialized as a string to
            preserve precision. This is the weight of the item itself, not the
            ballast a diver carried on a dive.
          example: "1.3"
        url:
          type: [string, "null"]
          description: A reference URL (product page, manual, vendor), or null.
          example: https://www.scubapro.com/regulators/mk25-evo
        notes:
          type: [string, "null"]
          description: Free-form notes, or null.
          example: null
        purchased_on:
          type: [string, "null"]
          format: date
          description: Day-precision date the item was acquired, or null.
          example: "2018-04-10"
        price_amount:
          type: [string, "null"]
          description: |
            The purchase amount, or null. Serialized as a string to preserve
            precision.
          example: "620.0"
        price_currency:
          type: [string, "null"]
          description: An ISO 4217 three-letter currency code, or null.
          example: EUR
        purchased_from:
          type: [string, "null"]
          description: The shop or source the item came from, or null.
          example: Diving Center Anzio
        next_service_on:
          type: [string, "null"]
          format: date
          description: Day-precision date the item is next due for service or inspection, or null.
          example: "2026-04-10"
        archived_at:
          type: [string, "null"]
          format: date-time
          description: |
            ISO 8601 timestamp when the item was retired / out of service, or
            null when in service. The UI labels this state "Inactive".
          example: null
        dives_count:
          type: integer
          minimum: 0
          description: |
            The number of dives the item was used on. System-maintained and
            read-only; it changes as the item's dive links change.
          example: 12
        created_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the equipment item was recorded.
          example: "2026-05-18T16:50:59.000Z"
        updated_at:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the equipment item last changed.
          example: "2026-05-18T16:50:59.000Z"

    EquipmentListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Equipment"
        pagination:
          $ref: "#/components/schemas/Pagination"

    EquipmentResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/Equipment"

    EquipmentWritable:
      type: object
      description: |
        The writable fields of an equipment item, sent as a flat JSON object
        with no resource wrapper. Read-only fields (id, timestamps, ownership)
        are ignored if present. A blank optional string is stored as null.
      properties:
        name:
          type: string
          description: The equipment item's name. Required on create.
          example: Mk25 left post
        category:
          type: [string, "null"]
          description: |
            Canonical category code (e.g. regulator, bcd, computer), or null.
            An unknown value fails with inclusion.
          example: regulator
        manufacturer:
          type: [string, "null"]
          description: The brand that made the item, or null.
          example: Scubapro
        model:
          type: [string, "null"]
          description: The model designation, or null.
          example: Mk25
        serial_number:
          type: [string, "null"]
          description: The item's serial identifier, or null.
          example: SCB-12345
        weight:
          type: [number, string, "null"]
          description: |
            The item's own mass in kilograms, or null. Accepts a JSON number or
            a decimal string; always serialized back as a string. Must be ≥ 0.
          example: 1.3
        url:
          type: [string, "null"]
          description: |
            A reference URL (product page, manual, vendor), or null. Must be a
            well-formed http or https URL; a malformed value fails with invalid.
          example: https://www.scubapro.com/regulators/mk25-evo
        notes:
          type: [string, "null"]
          description: Free-form notes, or null.
          example: Primary first stage. Annual service Apr 2025.
        purchased_on:
          type: [string, "null"]
          format: date
          description: Day-precision date the item was acquired (YYYY-MM-DD), or null.
          example: "2018-04-10"
        price_amount:
          type: [number, string, "null"]
          description: |
            The purchase amount, or null. Accepts a JSON number or a decimal
            string; always serialized back as a string. Must be greater than or
            equal to 0.
          example: "620.0"
        price_currency:
          type: [string, "null"]
          description: |
            An ISO 4217 three-letter currency code, or null. An unknown value
            fails with inclusion.
          example: EUR
        purchased_from:
          type: [string, "null"]
          description: The shop or source the item came from, or null.
          example: Diving Center Anzio
        next_service_on:
          type: [string, "null"]
          format: date
          description: |
            Day-precision date the item is next due for service or inspection
            (YYYY-MM-DD), or null.
          example: "2026-04-10"
        archived_at:
          type: [string, "null"]
          format: date-time
          description: |
            An ISO 8601 date-time to retire the item (out of service), or null
            to mark it in service. The UI labels this state "Inactive".
          example: null

    EquipmentCreateRequest:
      allOf:
        - $ref: "#/components/schemas/EquipmentWritable"
      required: [name]

    Pagination:
      type: object
      description: |
        Where the returned page sits within the whole collection. Present on
        every collection response, including an empty one.
      required: [current_page, per_page, total_entries, total_pages]
      properties:
        current_page:
          type: integer
          minimum: 1
          description: The 1-based page this response returns.
          example: 2
        per_page:
          type: integer
          description: The rows-per-page size applied to this response.
          example: 25
        total_entries:
          type: integer
          minimum: 0
          description: The total number of records in the whole collection, across all pages.
          example: 137
        total_pages:
          type: integer
          minimum: 1
          description: |
            The number of pages at the current page size. At least 1, even when
            the collection is empty.
          example: 6

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable machine-readable error identifier (snake_case).
              example: unauthorized
            message:
              type: string
              description: Human-readable explanation of the error.
              example: Authentication is required to access this endpoint.

    ValidationError:
      type: object
      required: [error]
      description: |
        The error envelope returned when a well-formed request body fails
        validation. Extends the standard error with a non-empty `details`
        array, one entry per offending field.
      properties:
        error:
          type: object
          required: [code, message, details]
          properties:
            code:
              type: string
              description: Always `validation_error` for this response.
              example: validation_error
            message:
              type: string
              description: Human-readable summary; clients must not parse it.
              example: The submitted data was invalid.
            details:
              type: array
              description: One entry per field that failed validation.
              items:
                type: object
                required: [field, code, message]
                properties:
                  field:
                    type: string
                    description: |
                      The attribute that failed, or the sentinel `base` for a
                      record-level error.
                    example: name
                  code:
                    type: string
                    description: |
                      Stable machine-readable kind of failure (the model's
                      validator kind): e.g. blank, inclusion, not_a_number,
                      greater_than_or_equal_to, less_than_or_equal_to.

                      Always one of these tokens, never a prose sentence. A
                      failure the model has not named reports the generic
                      `invalid`.
                    example: blank
                  message:
                    type: string
                    description: Human-readable explanation for this field; do not parse.
                    example: Name can't be blank

  responses:
    Unauthorized:
      description: The request did not carry a valid bearer token.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: unauthorized
              message: Authentication is required to access this endpoint.

    NotFound:
      description: |
        The addressed resource does not exist, or exists but is owned by a
        different user. The two cases are deliberately indistinguishable.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: not_found
              message: The requested resource could not be found.

    BadRequest:
      description: |
        The request body could not be parsed (malformed JSON), or a required
        parameter was missing.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: bad_request
              message: The request body could not be parsed as JSON.

    UnprocessableContent:
      description: |
        The request body was well-formed but one or more fields failed
        validation. The `details` array names each offending field.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ValidationError"
          example:
            error:
              code: validation_error
              message: The submitted data was invalid.
              details:
                - field: name
                  code: blank
                  message: Name can't be blank
                - field: latitude
                  code: less_than_or_equal_to
                  message: Latitude must be less than or equal to 90

    Conflict:
      description: |
        The request was well-formed and authorized, but the resource's current
        state forbids the operation (for example deleting a cylinder or gas mix
        that is logged on a dive).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: in_use
              message: The cylinder is logged on a dive and cannot be deleted.
    PayloadTooLarge:
      description: |
        An uploaded file exceeded the quick-intake size cap (emitted by the dive
        profile attach endpoint, the one endpoint that accepts a file upload).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: payload_too_large
              message: That file is over 2 MB. Attach a smaller dive-computer export.
