openapi: 3.1.0
info:
  title: OwlMar Public API
  version: 1.0.0
  contact:
    email: support@owlmar.com
  license:
    name: Proprietary
  description: |
    The OwlMar Public API (`/v1/*`) gives Enterprise customers programmatic, bearer-key
    authenticated access to their fleet's vessels, equipment, maintenance history, inventory,
    expenses, documents, crew certifications, charter bookings, trip logs, ISM compliance
    records, and fleet-level financial aggregations. Every endpoint below is also present,
    verbatim, in `src/publicApi/routeScopeMap.js` — that file is the source of truth for the
    exposed surface if this spec and the code ever drift; a CI drift-detection suite
    (`validateResponses: true` in `NODE_ENV=test`) fails the build the moment a handler's real
    response stops matching what's documented here.

    Authenticate with `Authorization: Bearer owlmar_live_...` — keys are created from
    Settings → API Keys in the app and are scoped per-key to a set of vessels (`orgScope`)
    and a set of `"<featureKey>:read"` / `"<featureKey>:full"` module grants (`moduleScopes`).
    Every successful response is wrapped in the canonical envelope
    `{ data: T | T[], pagination?: {...}, meta?: {...} }`; every error response is
    `{ error: string, code: string, field?: string, meta?: object }` — switch on `code`, not
    on the human-readable `error` string, which may change wording without notice. List
    endpoints that support cursor pagination accept an opaque `?cursor=` query param (never a
    page number) — see the `Pagination` schema below. `?page=&limit=` remains supported on
    some list endpoints for one deprecation cycle (12 months minimum) and is flagged with
    `Sunset`/`Deprecation` response headers when used; it is intentionally NOT documented as a
    request parameter here — new integrations should use `cursor` exclusively.

    Money and other Decimal-typed values are serialized as strings to preserve
    precision (Decimal columns in Postgres, which JS Number cannot represent
    losslessly at higher precision). Consumers should parse these fields via
    their language's Decimal/BigDecimal type before arithmetic, not JS Number.
servers:
  - url: https://api.owlmar.com/v1
    description: Production
tags:
  - name: Vessels
    description: Vessel records the caller's API key can see (owned or team-member access).
  - name: Equipment
    description: Onboard equipment/systems tracked per vessel.
  - name: Maintenance
    description: Maintenance event history and scheduling.
  - name: Inventory
    description: Spare parts, consumables, and provisioning stock.
  - name: Expenses
    description: Vessel operating expenses.
  - name: Documents
    description: Vessel document library (manuals, certificates, surveys, etc.).
  - name: Crew
    description: STCW crew certifications.
  - name: Charter
    description: Charter bookings.
  - name: Trips
    description: Trip / voyage logs.
  - name: Compliance
    description: ISM compliance records — drill cadences, permits to work, MARPOL regulatory record entries.
  - name: Fleet
    description: Multi-vessel financial aggregations across the caller's accessible fleet.

security:
  - bearerAuth: []

paths:
  /vessels:
    get:
      operationId: listVessels
      summary: List vessels
      description: |
        Returns every vessel the API key's creator-user owns or has team-member access to.
        Deliberately a **bare array of `Vessel` objects wrapped only by the envelope**
        (`{ data: Vessel[] }`) — there is no `pagination` block on this endpoint (see "Known
        gaps" in `docs/api.md`: the multi-vessel downgrade-lock annotation needs the full
        owned-vessel set to compute `isLocked`/`lockReason` correctly, so this endpoint isn't
        cursor-paginated). Owned vessels beyond the caller's plan's vessel limit are annotated
        with `isLocked: true` rather than omitted.
      tags: [Vessels]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Vessel'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
      x-codeSamples:
        - lang: cURL
          label: cURL
          source: |
            curl -sS "https://api.owlmar.com/v1/vessels" \
              -H "Authorization: Bearer $OWLMAR_API_KEY"
        - lang: JavaScript
          label: Node (fetch)
          source: |
            const res = await fetch('https://api.owlmar.com/v1/vessels', {
              headers: { Authorization: `Bearer ${process.env.OWLMAR_API_KEY}` },
            });
            const { data: vessels } = await res.json();
            console.log(vessels.map(v => v.vesselName));
        - lang: Python
          label: Python (requests)
          source: |
            import requests

            resp = requests.get(
                "https://api.owlmar.com/v1/vessels",
                headers={"Authorization": f"Bearer {OWLMAR_API_KEY}"},
            )
            vessels = resp.json()["data"]
        - lang: Python
          label: 'Python — 429 backoff'
          source: |
            # Respect Retry-After on 429s rather than hammering the endpoint. RATE_LIMITED
            # also carries meta.retryAfterMs on the JSON body if a proxy strips the header.
            import time
            import requests

            def get_with_backoff(url, headers, max_attempts=5):
                for attempt in range(max_attempts):
                    resp = requests.get(url, headers=headers)
                    if resp.status_code != 429:
                        resp.raise_for_status()
                        return resp.json()
                    retry_after = resp.headers.get("Retry-After")
                    wait_s = float(retry_after) if retry_after else 2 ** attempt
                    time.sleep(wait_s)
                raise RuntimeError("exceeded max retries on 429 RATE_LIMITED")

            vessels = get_with_backoff(
                "https://api.owlmar.com/v1/vessels",
                {"Authorization": f"Bearer {OWLMAR_API_KEY}"},
            )["data"]

  /vessels/{id}:
    get:
      operationId: getVessel
      summary: Get a vessel
      description: |
        Returns a single vessel with its captain, permissions, and (unlike the list endpoint)
        the most recent equipment, maintenance events, documents, and trip logs nested inline
        (each capped at the most recent 10 rows — use the dedicated list endpoints for full
        history). 404 if the vessel doesn't exist or the caller has no access.
      tags: [Vessels]
      parameters:
        - $ref: '#/components/parameters/VesselIdPath'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: '#/components/schemas/VesselDetail'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
      x-codeSamples:
        - lang: cURL
          label: cURL
          source: |
            curl -sS "https://api.owlmar.com/v1/vessels/$VESSEL_ID" \
              -H "Authorization: Bearer $OWLMAR_API_KEY"
        - lang: JavaScript
          label: Node (fetch)
          source: |
            const res = await fetch(`https://api.owlmar.com/v1/vessels/${vesselId}`, {
              headers: { Authorization: `Bearer ${process.env.OWLMAR_API_KEY}` },
            });
            if (res.status === 404) throw new Error('vessel not found or out of key scope');
            const { data: vessel } = await res.json();
        - lang: Python
          label: Python (requests)
          source: |
            resp = requests.get(
                f"https://api.owlmar.com/v1/vessels/{vessel_id}",
                headers={"Authorization": f"Bearer {OWLMAR_API_KEY}"},
            )
            vessel = resp.json()["data"]

  /vessels/{id}/team:
    get:
      operationId: listVesselTeam
      summary: List a vessel's team
      description: |
        Returns the vessel's crew roster (owner + team members), plus `teamTier` (plan
        context — member count, pending invites, seat limit, allowed roles) and
        `planContext` (downgrade-lock accounting). Team members beyond the caller's plan's
        seat limit are annotated `isLocked: true`; roles outside the plan's role allowlist are
        annotated `isRoleLocked: true`. Not cursor-paginated — crew rosters are bounded lists,
        not growing logs (see `docs/api.md` "Known gaps").
      tags: [Crew]
      parameters:
        - $ref: '#/components/parameters/VesselIdPath'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      members:
                        type: array
                        items: { $ref: '#/components/schemas/TeamMember' }
                      teamTier:
                        type: object
                        properties:
                          tier: { type: ['string', 'null'] }
                          memberCount: { type: integer }
                          pendingInvites: { type: integer }
                          limit: { type: ['integer', 'null'] }
                          allowedRoles:
                            type: ['array', 'null']
                            items: { type: string }
                      planContext:
                        type: object
                        properties:
                          planLimit: { type: ['integer', 'null'] }
                          totalMembers: { type: integer }
                          overlimit: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /equipment:
    post:
      operationId: createEquipment
      summary: Create equipment
      description: |
        Registers a new piece of onboard equipment. Requires `equipmentType` and either
        `systemCategory` or `vesselSystemId` (the server resolves whichever one you omit from
        the other when possible). Attempts an automatic catalog-model link on create and
        returns any suggested manufacturer maintenance kits for the matched model.
      tags: [Equipment]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EquipmentCreate'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Equipment'
                      - type: object
                        properties:
                          suggestedKits:
                            type: array
                            description: Manufacturer maintenance kits suggested for the linked catalog model, if any.
                            items:
                              type: object
                              additionalProperties: true
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
      x-codeSamples:
        - lang: cURL
          label: cURL
          source: |
            curl -sS -X POST "https://api.owlmar.com/v1/equipment" \
              -H "Authorization: Bearer $OWLMAR_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                "vesselId": "'"$VESSEL_ID"'",
                "systemCategory": "propulsion",
                "equipmentType": "Main Engine — Port",
                "make": "MAN",
                "model": "D2862 LE466"
              }'
        - lang: JavaScript
          label: 'Node (fetch) — error handling'
          source: |
            const res = await fetch('https://api.owlmar.com/v1/equipment', {
              method: 'POST',
              headers: {
                Authorization: `Bearer ${process.env.OWLMAR_API_KEY}`,
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                vesselId,
                systemCategory: 'propulsion',
                equipmentType: 'Main Engine — Port',
                make: 'MAN',
                model: 'D2862 LE466',
              }),
            });
            const body = await res.json();
            if (!res.ok) {
              // Switch on body.code, never on body.error (wording may change without notice).
              switch (body.code) {
                case 'VALIDATION_ERROR':
                case 'VALIDATION_FAILED':
                  throw new Error(`bad request: ${JSON.stringify(body.meta ?? body.error)}`);
                case 'INSUFFICIENT_SCOPE':
                  throw new Error('this API key is not scoped for equipment_tracking:full');
                case 'VESSEL_OUT_OF_SCOPE':
                  throw new Error('this API key is not scoped to that vessel');
                case 'RATE_LIMITED':
                  throw new Error(`rate limited, retry after ${body.meta?.retryAfterMs}ms`);
                default:
                  throw new Error(`unexpected error: ${body.code} — ${body.error}`);
              }
            }
            const { data: equipment } = body;
        - lang: Python
          label: Python (requests)
          source: |
            resp = requests.post(
                "https://api.owlmar.com/v1/equipment",
                headers={"Authorization": f"Bearer {OWLMAR_API_KEY}"},
                json={
                    "vesselId": vessel_id,
                    "systemCategory": "propulsion",
                    "equipmentType": "Main Engine — Port",
                    "make": "MAN",
                    "model": "D2862 LE466",
                },
            )
            resp.raise_for_status()
            equipment = resp.json()["data"]

  /equipment/vessel/{vesselId}:
    get:
      operationId: listEquipment
      summary: List a vessel's equipment
      description: |
        Returns non-archived equipment for a vessel, each annotated with `hoursStatus`
        (hours-based service-due computation). Supports opaque cursor pagination via
        `?cursor=` — omit `cursor` entirely for the first page, then pass back the `cursor`
        value from `pagination.cursor` for subsequent pages until `pagination.hasMore` is
        `false`. Default sort without `?cursor=` is by `systemCategory`; the cursor-paginated
        path always sorts `(createdAt DESC, id DESC)`.
      tags: [Equipment]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Equipment' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
      x-codeSamples:
        - lang: cURL
          label: cURL
          source: |
            curl -sS "https://api.owlmar.com/v1/equipment/vessel/$VESSEL_ID?cursor=&limit=25" \
              -H "Authorization: Bearer $OWLMAR_API_KEY"
        - lang: JavaScript
          label: 'Node (fetch) — pagination walk'
          source: |
            async function listAllEquipment(vesselId) {
              const items = [];
              // Empty string on the FIRST call is intentional — it opts into cursor mode
              // without seeking a specific starting point. Omitting `cursor` entirely falls
              // back to legacy ?page= behavior instead.
              let cursor = '';
              while (true) {
                const url = new URL(`https://api.owlmar.com/v1/equipment/vessel/${vesselId}`);
                url.searchParams.set('cursor', cursor);
                url.searchParams.set('limit', '100');
                const res = await fetch(url, {
                  headers: { Authorization: `Bearer ${process.env.OWLMAR_API_KEY}` },
                });
                const { data, pagination } = await res.json();
                items.push(...data);
                if (!pagination.hasMore) break;
                cursor = pagination.cursor;
              }
              return items;
            }
        - lang: Python
          label: 'Python — pagination walk'
          source: |
            def list_all_equipment(vessel_id):
                items = []
                cursor = ""  # opt into cursor mode on the first call
                while True:
                    resp = requests.get(
                        f"https://api.owlmar.com/v1/equipment/vessel/{vessel_id}",
                        headers={"Authorization": f"Bearer {OWLMAR_API_KEY}"},
                        params={"cursor": cursor, "limit": 100},
                    )
                    resp.raise_for_status()
                    body = resp.json()
                    items.extend(body["data"])
                    if not body["pagination"]["hasMore"]:
                        break
                    cursor = body["pagination"]["cursor"]
                return items

  /equipment/{id}:
    get:
      operationId: getEquipment
      summary: Get equipment
      description: Returns a single equipment record with its 10 most recent maintenance events, documents, and catalog model info.
      tags: [Equipment]
      parameters:
        - $ref: '#/components/parameters/EquipmentIdPath'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/EquipmentDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      operationId: updateEquipment
      summary: Update equipment
      description: |
        Partial update. Any writable `Equipment` field may be included; only the fields
        present in the body are changed. Setting `currentHours` server-stamps
        `currentHoursAt` and re-evaluates any hours-based maintenance schedules, returning
        `dueSchedules` for anything that crossed into due/due-soon as a result.
      tags: [Equipment]
      parameters:
        - $ref: '#/components/parameters/EquipmentIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EquipmentUpdate'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Equipment'
                      - type: object
                        properties:
                          dueSchedules:
                            type: array
                            items:
                              type: object
                              additionalProperties: true
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
      x-codeSamples:
        - lang: cURL
          label: cURL
          source: |
            curl -sS -X PATCH "https://api.owlmar.com/v1/equipment/$EQUIPMENT_ID" \
              -H "Authorization: Bearer $OWLMAR_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"currentHours": 4821}'
        - lang: JavaScript
          label: Node (fetch)
          source: |
            const res = await fetch(`https://api.owlmar.com/v1/equipment/${equipmentId}`, {
              method: 'PATCH',
              headers: {
                Authorization: `Bearer ${process.env.OWLMAR_API_KEY}`,
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({ currentHours: 4821 }),
            });
            const { data: equipment } = await res.json();
        - lang: Python
          label: Python (requests)
          source: |
            resp = requests.patch(
                f"https://api.owlmar.com/v1/equipment/{equipment_id}",
                headers={"Authorization": f"Bearer {OWLMAR_API_KEY}"},
                json={"currentHours": 4821},
            )
            equipment = resp.json()["data"]

  /maintenance:
    post:
      operationId: createMaintenanceEvent
      summary: Create a maintenance event
      description: |
        Requires `vesselId`, `scheduledDate`, `description`, and `eventType`. `scope`
        defaults to `equipment` and determines which FK is required: `equipment` requires
        `equipmentId`, `system` requires `vesselSystemId`, `vessel` requires neither. On
        ISM-onboarded vessels, `responsibleUserId` is also required. Supports the
        `Idempotency-Key` request header (see `IdempotencyKeyHeader` parameter) — a repeat
        POST with the same key and the same request body within 24h returns the original
        response unchanged with `Idempotent-Replayed: true`; the same key with a *different*
        body returns `409 CONFLICT`.
      tags: [Maintenance]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MaintenanceEventCreate'
      responses:
        '201':
          description: Created
          headers:
            Idempotent-Replayed:
              description: Present and `"true"` only when this response was served from the 24h idempotency cache rather than freshly executed.
              schema: { type: string, enum: ['true'] }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/MaintenanceEvent' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
      x-codeSamples:
        - lang: cURL
          label: 'cURL — idempotent create'
          source: |
            curl -sS -X POST "https://api.owlmar.com/v1/maintenance" \
              -H "Authorization: Bearer $OWLMAR_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: $(uuidgen)" \
              -d '{
                "vesselId": "'"$VESSEL_ID"'",
                "equipmentId": "'"$EQUIPMENT_ID"'",
                "eventType": "routine",
                "scheduledDate": "2026-09-01T09:00:00.000Z",
                "description": "500-hour service — port main engine"
              }'
        - lang: JavaScript
          label: 'Node (fetch) — idempotent create'
          source: |
            import { randomUUID } from 'node:crypto';

            // Generate the Idempotency-Key ONCE per logical create attempt, not per HTTP
            // call — reuse the SAME key across retries of the SAME create so a network
            // timeout-and-retry doesn't double-create the maintenance event.
            const idempotencyKey = randomUUID();

            async function createWithRetry(body, attempts = 3) {
              for (let i = 0; i < attempts; i++) {
                try {
                  const res = await fetch('https://api.owlmar.com/v1/maintenance', {
                    method: 'POST',
                    headers: {
                      Authorization: `Bearer ${process.env.OWLMAR_API_KEY}`,
                      'Content-Type': 'application/json',
                      'Idempotency-Key': idempotencyKey,
                    },
                    body: JSON.stringify(body),
                  });
                  return await res.json();
                } catch (networkErr) {
                  if (i === attempts - 1) throw networkErr;
                }
              }
            }

            const { data: event } = await createWithRetry({
              vesselId,
              equipmentId,
              eventType: 'routine',
              scheduledDate: '2026-09-01T09:00:00.000Z',
              description: '500-hour service — port main engine',
            });
        - lang: Python
          label: 'Python — idempotent create'
          source: |
            import uuid

            idempotency_key = str(uuid.uuid4())  # reuse across retries of the same attempt

            resp = requests.post(
                "https://api.owlmar.com/v1/maintenance",
                headers={
                    "Authorization": f"Bearer {OWLMAR_API_KEY}",
                    "Idempotency-Key": idempotency_key,
                },
                json={
                    "vesselId": vessel_id,
                    "equipmentId": equipment_id,
                    "eventType": "routine",
                    "scheduledDate": "2026-09-01T09:00:00.000Z",
                    "description": "500-hour service — port main engine",
                },
            )
            event = resp.json()["data"]

  /maintenance/vessel/{vesselId}:
    get:
      operationId: listMaintenanceEvents
      summary: List a vessel's maintenance events
      description: |
        Returns maintenance events for a vessel with derived `status`, linked equipment,
        service provider, permit, schedule, and workflow-state info. Supports `?cursor=`
        pagination on the default (status-priority) sort path only — the `status=hours_due`
        filter branch (JS-side post-filter, no materialized sort column) does not support
        cursor and silently falls back to `?page=` semantics if combined with `?cursor=`.
      tags: [Maintenance]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/MaintenanceEvent' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /maintenance/{id}:
    get:
      operationId: getMaintenanceEvent
      summary: Get a maintenance event
      description: Returns the full maintenance event with vessel, linked permit summary, recurring-schedule state, and current workflow state/transitions.
      tags: [Maintenance]
      parameters:
        - $ref: '#/components/parameters/MaintenanceIdPath'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/MaintenanceEventDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      operationId: updateMaintenanceEvent
      summary: Update a maintenance event
      description: Partial update. Supports the `Idempotency-Key` header on the same terms as create.
      tags: [Maintenance]
      parameters:
        - $ref: '#/components/parameters/MaintenanceIdPath'
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MaintenanceEventUpdate'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/MaintenanceEvent' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /maintenance/{id}/complete:
    post:
      operationId: completeMaintenanceEvent
      summary: Mark a maintenance event complete
      description: |
        Stamps `completedDate: now()`, optionally deducts `partsUsed` from inventory (if not
        already deducted), and accepts the same body fields as a PATCH for any final details
        captured at completion time (labor hours, cost, notes, technician signature, etc.).
      tags: [Maintenance]
      parameters:
        - $ref: '#/components/parameters/MaintenanceIdPath'
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              properties:
                partsUsed:
                  type: array
                  items:
                    type: object
                    required: [inventoryItemId, quantity]
                    properties:
                      inventoryItemId: { type: string, format: uuid }
                      quantity: { type: number }
                laborHours: { type: ['number', 'null'] }
                laborCost: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
                notes: { type: ['string', 'null'] }
                technicianSignature: { type: ['string', 'null'] }
                technicianName: { type: ['string', 'null'] }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/MaintenanceEvent' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /inventory:
    post:
      operationId: createInventoryItem
      summary: Create an inventory item
      description: Requires `vesselId` and `itemType`. Accepts either `vesselSystemId` or `category` (the server derives whichever is omitted); neither is required for provisions/consumables not tied to a system.
      tags: [Inventory]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InventoryItemCreate'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/InventoryItem' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /inventory/vessel/{vesselId}:
    get:
      operationId: listInventoryItems
      summary: List a vessel's inventory
      description: |
        Supports `?cursor=` pagination on the standard path only — the `status=lowstock`
        filter (raw SQL comparing `quantity` to `reorderThreshold`) is a bounded result set
        and does not support cursor.
      tags: [Inventory]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/InventoryItem' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /inventory/{id}:
    get:
      operationId: getInventoryItem
      summary: Get an inventory item
      description: Returns a single inventory item with vessel and vessel-system summaries.
      tags: [Inventory]
      parameters:
        - $ref: '#/components/parameters/InventoryIdPath'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/InventoryItem' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      operationId: updateInventoryItem
      summary: Update an inventory item
      description: Partial update — any writable `InventoryItem` field. `category` and `vesselSystemId` are dual-accept (supplying one derives the other when possible).
      tags: [Inventory]
      parameters:
        - $ref: '#/components/parameters/InventoryIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InventoryItemUpdate'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/InventoryItem' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /expenses:
    post:
      operationId: createExpense
      summary: Create an expense
      description: "Requires `vesselId`, `date` (not in the future), and `amount`. `fundingSource: apa` requires `charterBookingId`."
      tags: [Expenses]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExpenseCreate'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Expense' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /expenses/vessel/{vesselId}:
    get:
      operationId: listExpenses
      summary: List a vessel's expenses
      description: |
        Supports `?cursor=` pagination on the non-search path only — `?search=` runs a
        bounded hybrid vector+ILIKE query and does not support cursor. The aggregate
        `totalAmount` (USD-normalized) is returned via `meta.totalAmount`, not as a top-level
        field.
      tags: [Expenses]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Expense' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /expenses/{id}:
    get:
      operationId: getExpense
      summary: Get an expense
      description: Returns a single expense. Pass `?include=lineItems,taxes` to inline OCR-extracted line items and taxes.
      tags: [Expenses]
      parameters:
        - $ref: '#/components/parameters/ExpenseIdPath'
        - name: include
          in: query
          description: Comma-separated related collections to inline — supports `lineItems`, `taxes`.
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/ExpenseDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      operationId: updateExpense
      summary: Update an expense
      description: Partial update — any writable `Expense` field.
      tags: [Expenses]
      parameters:
        - $ref: '#/components/parameters/ExpenseIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExpenseUpdate'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Expense' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /documents/vessel/{vesselId}:
    get:
      operationId: listDocuments
      summary: List a vessel's documents
      description: |
        Excludes archived documents by default. Supports `?cursor=` pagination on the
        non-search path only — `?search=` runs a bounded hybrid vector+ILIKE query (matching
        title/category/OCR text) and does not support cursor. Document *creation* is not on
        the public surface — the only create route requires a multipart file upload, not a
        metadata-only body (see `docs/api.md` "Known gaps").
      tags: [Documents]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Document' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /documents/{id}:
    get:
      operationId: getDocument
      summary: Get a document
      description: Returns a single document with vessel, linked equipment (if any), and uploader.
      tags: [Documents]
      parameters:
        - $ref: '#/components/parameters/DocumentIdPath'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/DocumentDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /stcw/{vesselId}/certifications:
    get:
      operationId: listCrewCertifications
      summary: List a vessel's STCW certifications
      description: Supports `?cursor=` pagination. "Get one certification by ID" is not on the public surface — no such route exists internally (see `docs/api.md` "Known gaps").
      tags: [Crew]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/STCWCertification' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /charter/{vesselId}/bookings:
    get:
      operationId: listBookings
      summary: List a vessel's charter bookings
      description: "Supports `?cursor=` pagination. Bookings are annotated `isLocked: true` when the owning vessel's plan has downgraded below the `bookings_guests` feature tier."
      tags: [Charter]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Booking' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      operationId: createBooking
      summary: Create a charter booking
      description: Requires `bookingReference`, `startDate`, `endDate`. Financial fields (`totalPrice`, `apaAmount`, broker/agent commissions, etc.) are silently stripped unless the vessel's plan is Pro tier or higher for `bookings_guests`.
      tags: [Charter]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookingCreate'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Booking' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /charter/{vesselId}/bookings/{bookingId}:
    get:
      operationId: getBooking
      summary: Get a charter booking
      description: |
        Returns `{ data: { booking: BookingDetail, planContext: {...} } }` — the booking is
        nested under a `booking` key (not returned bare), matching what the underlying
        handler emits before envelope normalization wraps the whole thing in `data`.
      tags: [Charter]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/BookingIdPath'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      booking: { $ref: '#/components/schemas/BookingDetail' }
                      planContext:
                        type: object
                        properties:
                          isEnabled: { type: boolean }
                          currentTier: { type: ['string', 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /trips:
    post:
      operationId: createTripLog
      summary: Create a trip log
      description: "Requires `vesselId` and `departureTime`. `status: in_progress` with `engineInputs`/`fuelInputs` triggers vessel-vital write-back at trip start."
      tags: [Trips]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TripLogCreate'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/TripLog' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /trips/vessel/{vesselId}:
    get:
      operationId: listTripLogs
      summary: List a vessel's trip logs
      description: Supports `?cursor=` pagination on every sort except `sortBy=duration` (a raw-SQL computed-column sort with no defined cursor ordering — falls back to `?page=` semantics).
      tags: [Trips]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TripLog' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /trips/{id}:
    get:
      operationId: getTripLog
      summary: Get a trip log
      description: Lazily backfills geocoded lat/lon on departure/arrival locations that only have a place name, returning the enriched coordinates immediately.
      tags: [Trips]
      parameters:
        - $ref: '#/components/parameters/TripIdPath'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/TripLogDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /compliance/vessel/{vesselId}/drills/cadences:
    get:
      operationId: listDrillCadences
      summary: List a vessel's ISM drill cadences
      description: |
        Returns EVERY active `DrillCadence` row for the vessel (typically fewer than 15 — one
        per ISM-required drill type), plus pre-computed `overdue` and `dueSoon` sub-lists. Not
        a flat list at the top level and not cursor-paginated — this is a bounded ISM registry,
        not a growing log (see `docs/api.md` "Known gaps").
      tags: [Compliance]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      cadences:
                        type: array
                        items: { $ref: '#/components/schemas/DrillCadence' }
                      overdue:
                        type: array
                        items:
                          allOf:
                            - $ref: '#/components/schemas/DrillCadence'
                            - type: object
                              properties:
                                daysOverdue: { type: integer }
                      dueSoon:
                        type: array
                        items: { $ref: '#/components/schemas/DrillCadence' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /compliance/vessel/{vesselId}/ptw:
    get:
      operationId: listPermits
      summary: List a vessel's permits to work
      description: Supports `?cursor=` pagination. Returns a trimmed summary projection (not every `PermitToWork` column) — see `Permit` schema.
      tags: [Compliance]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Permit' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /compliance/vessel/{vesselId}/regulatory-records:
    get:
      operationId: listRegulatoryRecords
      summary: List a vessel's MARPOL regulatory record entries
      description: |
        ORB Annex I (oily-water), GRB Annex V (garbage), and BWM (ballast water) log
        entries. Supports `?cursor=` pagination — cursor mode always sorts
        `(createdAt DESC, id DESC)`, NOT `operationDate` (the default page-mode sort field),
        because `operationDate` isn't guaranteed unique/monotonic.
      tags: [Compliance]
      parameters:
        - $ref: '#/components/parameters/VesselIdParam'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/RegulatoryRecord' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /fleet/revenue-summary:
    get:
      operationId: getFleetRevenueSummary
      summary: Fleet revenue/expense/fee summary for a calendar year
      description: |
        Aggregates charter revenue, expenses, and management fees (USD-normalized) across
        every vessel the caller's API key can access — no `vesselId` path/query param;
        scope is the key's full accessible fleet. `orgScope` narrowing is a structural no-op
        on this endpoint since there's no single vesselId to check against.
      tags: [Fleet]
      parameters:
        - name: year
          in: query
          description: Calendar year (UTC). Defaults to the current year.
          schema: { type: integer, minimum: 2000, maximum: 2100 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      year: { type: integer }
                      vessels:
                        type: array
                        items:
                          type: object
                          properties:
                            vesselId: { type: string, format: uuid }
                            vesselName: { type: string }
                            revenue: { type: number, description: USD-normalized. }
                            expenses: { type: number, description: USD-normalized. }
                            fees: { type: number, description: USD-normalized. }
                            net: { type: number }
                      totals:
                        type: object
                        properties:
                          revenue: { type: number }
                          expenses: { type: number }
                          fees: { type: number }
                          net: { type: number }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
      x-codeSamples:
        - lang: cURL
          label: cURL
          source: |
            curl -sS "https://api.owlmar.com/v1/fleet/revenue-summary?year=2026" \
              -H "Authorization: Bearer $OWLMAR_API_KEY"
        - lang: JavaScript
          label: Node (fetch)
          source: |
            const res = await fetch('https://api.owlmar.com/v1/fleet/revenue-summary?year=2026', {
              headers: { Authorization: `Bearer ${process.env.OWLMAR_API_KEY}` },
            });
            const { data } = await res.json();
            console.log(data.totals.net);
        - lang: Python
          label: Python (requests)
          source: |
            resp = requests.get(
                "https://api.owlmar.com/v1/fleet/revenue-summary",
                headers={"Authorization": f"Bearer {OWLMAR_API_KEY}"},
                params={"year": 2026},
            )
            summary = resp.json()["data"]

  /fleet/vessel-comparison:
    get:
      operationId: getFleetVesselComparison
      summary: Per-vessel revenue/expense/budget comparison for a calendar year
      description: |
        Like `revenue-summary` but adds budget-vs-actual and can be narrowed to a single
        vessel via `?vesselId=` (still no path param — filtered server-side against the
        caller's accessible fleet). `hasApproximations: true` means at least one row lacked a
        captured exchange rate and fell back to a current-rate approximation.
      tags: [Fleet]
      parameters:
        - name: year
          in: query
          schema: { type: integer, minimum: 2000, maximum: 2100 }
        - name: vesselId
          in: query
          description: Restrict to a single vessel (must be within the caller's accessible fleet).
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      year: { type: integer }
                      vessels:
                        type: array
                        items:
                          type: object
                          properties:
                            vesselId: { type: string, format: uuid }
                            vesselName: { type: string }
                            revenue: { type: number }
                            expenses: { type: number }
                            net: { type: number }
                            budgeted: { type: number }
                            budgetVariance: { type: number }
                      hasApproximations: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: owlmar_live_...
      description: |
        API key issued from Settings → API Keys in the OwlMar app. Format
        `owlmar_live_<43-char base64url secret>`. Send as `Authorization: Bearer <key>`. Keys
        are scoped per-key to a vessel allowlist (`orgScope`) and a module grant list
        (`moduleScopes`, `"<featureKey>:read"` / `"<featureKey>:full"`) — see `docs/api.md`
        "Public API (`/v1/*`)" for the full scope model.

  parameters:
    VesselIdPath:                    # keep name+shape unchanged — used by /vessels/{id} paths
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    VesselIdParam:                   # for path templates that use {vesselId}
      name: vesselId
      in: path
      required: true
      schema: { type: string, format: uuid }
    EquipmentIdPath:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    MaintenanceIdPath:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    InventoryIdPath:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    ExpenseIdPath:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    DocumentIdPath:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    BookingIdPath:
      name: bookingId
      in: path
      required: true
      schema: { type: string, format: uuid }
    TripIdPath:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    Cursor:
      name: cursor
      in: query
      description: |
        Opaque pagination cursor from a previous response's `pagination.cursor`. Omit this
        param entirely for the first page of cursor mode; pass `cursor=` (empty) is also
        accepted as "start from the beginning in cursor mode." Omitting `cursor` altogether
        (not even as an empty string) falls back to legacy `?page=` semantics on endpoints
        that still support it.
      required: false
      allowEmptyValue: true
      schema: { type: string }
    Limit:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    IdempotencyKeyHeader:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-generated unique token (e.g. a UUID) scoping this write to be safely retried.
        Replaying the same key with the same request body within 24h returns the original
        cached response unchanged (with `Idempotent-Replayed: true`); replaying with a
        different body returns `409 CONFLICT`. Opt-in — omit for normal (non-idempotent)
        behavior.
      schema: { type: string }

  responses:
    BadRequest:
      description: Request does not match the documented schema, or failed a handler-level business-rule check.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: Missing, malformed, revoked, or expired API key. No distinction is made in the response between these cases (non-enumeration).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Forbidden:
      description: The API key's `moduleScopes` or `orgScope` doesn't cover this request, or the impersonated user's real role/ownership doesn't permit it.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: Resource doesn't exist, or the caller has no access to it (404 is used instead of 403 to avoid confirming existence).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    IdempotencyConflict:
      description: The supplied `Idempotency-Key` was already used with a different request body.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: Per-key hourly rate limit exceeded. `Retry-After` header and `meta.retryAfterMs` both indicate the wait.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the current rate-limit window resets.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  schemas:
    ErrorEnvelope:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
          description: Human-readable message. May change wording without notice — switch on `code`, not this string.
        code:
          type: string
          description: Machine-readable error code. See `docs/api.md` "Error responses" for the full registry.
        field:
          type: string
          description: Present on some validation errors — the request field that failed.
        meta:
          type: object
          additionalProperties: true
          description: Present on some errors (e.g. `RATE_LIMITED`'s `retryAfterMs`, `VALIDATION_ERROR`'s `errors` array).

    Pagination:
      type: object
      description: |
        Canonical cursor-pagination block. On endpoints not yet retrofitted for true cursor
        support, `cursor` is a best-effort display-only value (not decodable, no `?cursor=`
        branch accepts it back) — see each endpoint's description for whether it has full
        cursor support.
      required: [limit, hasMore, total]
      properties:
        cursor:
          type: ['string', 'null']
          description: Opaque token for the next page, or `null` when `hasMore` is `false`.
        limit: { type: integer }
        hasMore: { type: boolean }
        total: { type: integer, description: Total row count matching the filter (not just this page). }

    Meta:
      type: object
      additionalProperties: true
      description: Endpoint-specific extra fields promoted out of the top level by envelope normalization (e.g. `totalAmount`, `planContext`, `hasApproximations`).

    # ── Vessels ──────────────────────────────────────────────────────────────
    UserSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        firstName: { type: ['string', 'null'] }
        lastName: { type: ['string', 'null'] }
        email: { type: ['string', 'null'] }
        phone: { type: ['string', 'null'] }
        profilePhoto: { type: ['string', 'null'] }

    Vessel:
      type: object
      properties:
        id: { type: string, format: uuid }
        ownerId: { type: string, format: uuid }
        vesselName: { type: string }
        make: { type: ['string', 'null'] }
        model: { type: ['string', 'null'] }
        year: { type: ['integer', 'null'] }
        hullNumber: { type: ['string', 'null'] }
        vesselType: { type: ['string', 'null'], description: 'VesselType enum, e.g. motor_yacht, sailing_yacht, catamaran.' }
        status: { type: string, description: 'VesselStatus enum, e.g. in_service, laid_up, in_refit.' }
        engineType: { type: ['string', 'null'] }
        engineCount: { type: ['integer', 'null'] }
        homePort: { type: ['string', 'null'] }
        homePortLat: { type: ['number', 'null'] }
        homePortLon: { type: ['number', 'null'] }
        homePortResolved: { type: ['string', 'null'] }
        homePortNeedsReview: { type: boolean }
        length: { type: ['number', 'null'] }
        beam: { type: ['number', 'null'] }
        draft: { type: ['number', 'null'] }
        airDraft: { type: ['number', 'null'] }
        loaTotal: { type: ['number', 'null'] }
        displacement: { type: ['integer', 'null'] }
        grossTonnage: { type: ['integer', 'null'] }
        fuelCapacity: { type: ['integer', 'null'] }
        waterCapacity: { type: ['integer', 'null'] }
        maxAccommodation: { type: ['integer', 'null'] }
        maxRange: { type: ['integer', 'null'] }
        cruisingSpeed: { type: ['number', 'null'] }
        imoNumber: { type: ['string', 'null'] }
        mmsi: { type: ['string', 'null'] }
        callSign: { type: ['string', 'null'] }
        flagState: { type: ['string', 'null'] }
        complianceProfile: { type: ['string', 'null'] }
        multiCurrencyEnabled: { type: boolean }
        defaultCurrency: { type: string }
        photo: { type: ['string', 'null'] }
        dataConsistencyWarnings:
          type: ['array', 'null']
          items: { type: object, additionalProperties: true }
        owner: { $ref: '#/components/schemas/UserSummary' }
        captain:
          oneOf:
            - $ref: '#/components/schemas/UserSummary'
            - type: 'null'
        permissions:
          type: object
          additionalProperties: true
          description: Per-feature read/full permission map computed for the impersonated user.
        isLocked:
          type: boolean
          description: Present only when this owned vessel is over the caller's plan's vessel-count limit.
        lockReason: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    VesselDetail:
      description: 'GET /vessels/{id} — Vessel plus the 10 most recent nested equipment/maintenance/documents/trips and full inventory.'
      allOf:
        - $ref: '#/components/schemas/Vessel'
        - type: object
          properties:
            consistencyWarnings:
              type: array
              items: { type: object, additionalProperties: true }
            equipment:
              type: array
              items: { $ref: '#/components/schemas/Equipment' }
            maintenanceEvents:
              type: array
              items: { $ref: '#/components/schemas/MaintenanceEvent' }
            documents:
              type: array
              items: { $ref: '#/components/schemas/Document' }
            tripLogs:
              type: array
              items: { $ref: '#/components/schemas/TripLog' }
            inventoryItems:
              type: array
              items: { $ref: '#/components/schemas/InventoryItem' }

    TeamMember:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string }
        firstName: { type: ['string', 'null'] }
        lastName: { type: ['string', 'null'] }
        profilePhoto: { type: ['string', 'null'] }
        vesselRole: { type: string, description: 'VesselRole enum value, e.g. owner, co_owner, captain, crew, guest.' }
        permissions:
          type: array
          items: { type: string }
        addedAt: { type: ['string', 'null'], format: date-time }
        isOwner: { type: boolean }
        isLocked: { type: boolean }
        lockReason: { type: string }
        isRoleLocked: { type: boolean }
        roleLockReason: { type: string }

    # ── Equipment ────────────────────────────────────────────────────────────
    VesselSystemSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        systemKey: { type: string }
        name: { type: string }

    EquipmentModelSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        modelName: { type: ['string', 'null'] }
        manualUrl: { type: ['string', 'null'] }
        manualContentHash: { type: ['string', 'null'] }
        manualDiscoveryStatus: { type: ['string', 'null'] }
        manualDiscoverySource: { type: ['string', 'null'] }
        maintenanceSchedule:
          type: ['object', 'null']
          additionalProperties: true
        manufacturer:
          type: object
          properties:
            name: { type: string }
        maintenanceKits:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              name: { type: string }
              intervalHours: { type: ['integer', 'null'] }

    Equipment:
      type: object
      properties:
        id: { type: string, format: uuid }
        vesselId: { type: string, format: uuid }
        systemCategory: { type: string }
        vesselSystemId: { type: ['string', 'null'], format: uuid }
        vesselSystem:
          oneOf:
            - $ref: '#/components/schemas/VesselSystemSummary'
            - type: 'null'
        equipmentType: { type: string }
        make: { type: ['string', 'null'] }
        model: { type: ['string', 'null'] }
        serialNumber: { type: ['string', 'null'] }
        installDate: { type: ['string', 'null'], format: date-time }
        hoursAtInstall: { type: ['integer', 'null'] }
        currentHours: { type: ['integer', 'null'] }
        currentHoursAt: { type: ['string', 'null'], format: date-time }
        capacity:
          type: ['object', 'null']
          additionalProperties: true
        locationOnVessel: { type: ['string', 'null'] }
        warrantyExpires: { type: ['string', 'null'], format: date-time }
        photos:
          type: array
          items: { type: string }
        notes: { type: ['string', 'null'] }
        equipmentModelId: { type: ['string', 'null'], format: uuid }
        equipmentModel:
          oneOf:
            - $ref: '#/components/schemas/EquipmentModelSummary'
            - type: 'null'
        hoursStatus:
          type: array
          description: Derived hours-based service-due status per applicable maintenance kit/schedule.
          items:
            type: object
            additionalProperties: true
        archivedAt: { type: ['string', 'null'], format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    EquipmentDetail:
      allOf:
        - $ref: '#/components/schemas/Equipment'
        - type: object
          properties:
            maintenanceEvents:
              type: array
              items: { $ref: '#/components/schemas/MaintenanceEvent' }
            documents:
              type: array
              items: { $ref: '#/components/schemas/Document' }

    EquipmentCreate:
      type: object
      required: [vesselId, equipmentType]
      description: One of `systemCategory` or `vesselSystemId` is also required (the server derives the other where possible).
      properties:
        vesselId: { type: string, format: uuid }
        equipmentType: { type: string }
        systemCategory: { type: string }
        vesselSystemId: { type: string, format: uuid }
        make: { type: string }
        model: { type: string }
        serialNumber: { type: string }
        installDate: { type: string, format: date-time }
        hoursAtInstall: { type: integer }
        currentHours: { type: integer }
        capacity: { type: object, additionalProperties: true }
        locationOnVessel: { type: string }
        warrantyExpires: { type: string, format: date-time }
        notes: { type: string }

    EquipmentUpdate:
      type: object
      description: Partial update — any writable `Equipment` field. `additionalProperties:true` because the handler applies the request body directly to Prisma's update payload rather than an explicit allowlist.
      additionalProperties: true
      properties:
        systemCategory: { type: string }
        vesselSystemId: { type: ['string', 'null'], format: uuid }
        equipmentType: { type: string }
        make: { type: ['string', 'null'] }
        model: { type: ['string', 'null'] }
        serialNumber: { type: ['string', 'null'] }
        installDate: { type: ['string', 'null'], format: date-time }
        hoursAtInstall: { type: ['integer', 'null'] }
        currentHours: { type: ['integer', 'null'] }
        capacity: { type: ['object', 'null'], additionalProperties: true }
        locationOnVessel: { type: ['string', 'null'] }
        warrantyExpires: { type: ['string', 'null'], format: date-time }
        notes: { type: ['string', 'null'] }

    # ── Maintenance ──────────────────────────────────────────────────────────
    EquipmentRefSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        equipmentType: { type: string }
        make: { type: ['string', 'null'] }
        model: { type: ['string', 'null'] }

    ServiceProviderSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        businessName: { type: string }
        phone: { type: ['string', 'null'] }

    MaintenanceEvent:
      type: object
      properties:
        id: { type: string, format: uuid }
        vesselId: { type: string, format: uuid }
        equipmentId: { type: ['string', 'null'], format: uuid }
        vesselSystemId: { type: ['string', 'null'], format: uuid }
        scope: { type: string, description: 'MaintenanceScope enum: equipment, system, or vessel.' }
        eventType: { type: string, description: 'MaintenanceType enum: routine, repair, inspection, upgrade, refit, warranty.' }
        status:
          type: string
          description: Derived workflow-bucket status (not a stored column) — computed from `workflowExecution.currentState`.
        priority: { type: string, description: 'MaintenancePriority enum: low, medium, high, critical.' }
        scheduledDate: { type: ['string', 'null'], format: date-time }
        completedDate: { type: ['string', 'null'], format: date-time }
        description: { type: ['string', 'null'] }
        performedBy: { type: ['string', 'null'] }
        serviceProviderId: { type: ['string', 'null'], format: uuid }
        serviceProvider:
          oneOf:
            - $ref: '#/components/schemas/ServiceProviderSummary'
            - type: 'null'
        partsUsed:
          type: array
          items:
            type: object
            properties:
              inventoryItemId: { type: string, format: uuid }
              quantity: { type: number }
        laborHours: { type: ['number', 'null'] }
        laborCost: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        partsCost: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        totalCost: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        currency: { type: string }
        hoursAtService: { type: ['integer', 'null'] }
        beforePhotos: { type: array, items: { type: string } }
        afterPhotos: { type: array, items: { type: string } }
        invoiceAttachments: { type: array, items: { type: string } }
        notes: { type: ['string', 'null'] }
        technicianSignature: { type: ['string', 'null'] }
        technicianName: { type: ['string', 'null'] }
        technicianSignedAt: { type: ['string', 'null'], format: date-time }
        requestedById: { type: ['string', 'null'], format: uuid }
        responsibleUserId: { type: ['string', 'null'], format: uuid }
        linkedPermitId: { type: ['string', 'null'], format: uuid }
        equipment:
          oneOf:
            - $ref: '#/components/schemas/EquipmentRefSummary'
            - type: 'null'
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    MaintenanceEventDetail:
      allOf:
        - $ref: '#/components/schemas/MaintenanceEvent'
        - type: object
          properties:
            vessel: { $ref: '#/components/schemas/Vessel' }
            linkedPermit:
              type: ['object', 'null']
              properties:
                id: { type: string, format: uuid }
                title: { type: string }
                permitType: { type: string }
                status: { type: string }
            schedule:
              type: ['object', 'null']
              additionalProperties: true
              description: Recurring-schedule state (isRecurring, intervalHours/Months, nextDueAt/Hours, grace settings) when this event is linked to a MaintenanceSchedule.
            workflowExecution:
              type: ['object', 'null']
              additionalProperties: true
              description: Current workflow state + available transitions for this event's status.

    MaintenanceEventCreate:
      type: object
      required: [vesselId, eventType, scheduledDate, description]
      properties:
        vesselId: { type: string, format: uuid }
        scope: { type: string, enum: [equipment, system, vessel], default: equipment }
        equipmentId: { type: string, format: uuid, description: Required when scope=equipment. }
        vesselSystemId: { type: string, format: uuid, description: Required when scope=system. }
        eventType: { type: string, enum: [routine, repair, inspection, upgrade, refit, warranty] }
        scheduledDate: { type: string, format: date-time }
        description: { type: string }
        priority: { type: string, enum: [low, medium, high, critical] }
        serviceProviderId: { type: string, format: uuid }
        performedBy: { type: string }
        currency: { type: string }
        responsibleUserId: { type: string, format: uuid, description: Required on ISM-onboarded vessels. }
        laborHours: { type: number }
        laborCost: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        notes: { type: string }
        partsUsed:
          type: array
          items:
            type: object
            required: [inventoryItemId, quantity]
            properties:
              inventoryItemId: { type: string, format: uuid }
              quantity: { type: number }
        workflowId: { type: string, format: uuid }

    MaintenanceEventUpdate:
      type: object
      additionalProperties: true
      description: Partial update — any writable `MaintenanceEvent` field.
      properties:
        eventType: { type: string, enum: [routine, repair, inspection, upgrade, refit, warranty] }
        priority: { type: string, enum: [low, medium, high, critical] }
        scheduledDate: { type: ['string', 'null'], format: date-time }
        completedDate: { type: ['string', 'null'], format: date-time }
        description: { type: ['string', 'null'] }
        notes: { type: ['string', 'null'] }
        laborHours: { type: ['number', 'null'] }
        laborCost: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        partsUsed:
          type: array
          items:
            type: object
            properties:
              inventoryItemId: { type: string, format: uuid }
              quantity: { type: number }

    # ── Inventory ────────────────────────────────────────────────────────────
    InventoryItem:
      type: object
      properties:
        id: { type: string, format: uuid }
        vesselId: { type: string, format: uuid }
        vesselSystemId: { type: ['string', 'null'], format: uuid }
        vesselSystem:
          oneOf:
            - $ref: '#/components/schemas/VesselSystemSummary'
            - type: 'null'
        itemType: { type: string, description: 'InventoryType enum, e.g. spare_part, safety_equipment, consumable, provision.' }
        name: { type: string }
        category: { type: ['string', 'null'] }
        subcategory: { type: ['string', 'null'] }
        quantity: { type: integer }
        unit: { type: ['string', 'null'] }
        location: { type: ['string', 'null'] }
        deckLevel: { type: ['string', 'null'] }
        section: { type: ['string', 'null'] }
        partNumber: { type: ['string', 'null'] }
        fitsEquipment: { type: array, items: { type: string } }
        brand: { type: ['string', 'null'] }
        model: { type: ['string', 'null'] }
        costPerUnit: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        currency: { type: string }
        supplier: { type: ['string', 'null'] }
        reorderThreshold: { type: ['integer', 'null'] }
        reorderQuantity: { type: ['integer', 'null'] }
        autoReorderEnabled: { type: boolean }
        preferredSupplierId: { type: ['string', 'null'], format: uuid }
        expirationDate: { type: ['string', 'null'], format: date-time }
        barcode: { type: ['string', 'null'] }
        photo: { type: ['string', 'null'] }
        notes: { type: ['string', 'null'] }
        sourceDescription: { type: ['string', 'null'] }
        catalogPartId: { type: ['string', 'null'], format: uuid }
        dataHealthFlags:
          type: array
          description: Derived data-quality warnings for this row (e.g. missing reorder threshold).
          items: { type: object, additionalProperties: true }
        hasOpenAutoDraft:
          type: boolean
          description: Whether a suppressed purchase-order auto-draft exists for this item's low-stock trigger.
        vessel:
          type: object
          properties:
            id: { type: string, format: uuid }
            vesselName: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    InventoryItemCreate:
      type: object
      required: [vesselId, itemType, name]
      properties:
        vesselId: { type: string, format: uuid }
        itemType: { type: string, enum: [spare_part, safety_equipment, consumable, provision, interior, exterior, cleaning, tools, electronics, medical, lines_rigging, navigation, tender_equipment, water_toys, other] }
        name: { type: string }
        vesselSystemId: { type: string, format: uuid }
        category: { type: string }
        quantity: { type: integer }
        unit: { type: string }
        location: { type: string }
        partNumber: { type: string }
        costPerUnit: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        currency: { type: string }
        supplier: { type: string }
        reorderThreshold: { type: integer }
        expirationDate: { type: string, format: date-time }
        notes: { type: string }

    InventoryItemUpdate:
      type: object
      additionalProperties: true
      description: Partial update — any writable `InventoryItem` field.
      properties:
        name: { type: string }
        vesselSystemId: { type: ['string', 'null'], format: uuid }
        category: { type: ['string', 'null'] }
        quantity: { type: integer }
        unit: { type: ['string', 'null'] }
        location: { type: ['string', 'null'] }
        costPerUnit: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        currency: { type: string }
        reorderThreshold: { type: ['integer', 'null'] }
        notes: { type: ['string', 'null'] }

    # ── Expenses ─────────────────────────────────────────────────────────────
    CharterBookingRefSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        bookingReference: { type: string }

    Expense:
      type: object
      properties:
        id: { type: string, format: uuid }
        vesselId: { type: ['string', 'null'], format: uuid }
        category: { type: string, description: 'ExpenseCategory enum, e.g. fuel, maintenance, insurance, dockage, provisions, crew.' }
        subcategory: { type: ['string', 'null'] }
        description: { type: string }
        amount: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        currency: { type: string }
        date: { type: string, format: date }
        vendor: { type: ['string', 'null'] }
        receiptUrl: { type: ['string', 'null'] }
        maintenanceId: { type: ['string', 'null'], format: uuid }
        equipmentId: { type: ['string', 'null'], format: uuid }
        notes: { type: ['string', 'null'] }
        tags: { type: array, items: { type: string } }
        recurring: { type: boolean }
        recurringPeriod: { type: ['string', 'null'] }
        charterBookingId: { type: ['string', 'null'], format: uuid }
        charterBooking:
          oneOf:
            - $ref: '#/components/schemas/CharterBookingRefSummary'
            - type: 'null'
        fundingSource: { type: string, enum: [owner, charter, apa] }
        costCenter: { type: ['string', 'null'] }
        quantity: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        unit: { type: ['string', 'null'] }
        quantityUsed: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        estimatedCost: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        provisionStatus: { type: ['string', 'null'] }
        tip: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        hasLineItems: { type: boolean }
        lineItemCount: { type: integer }
        reimbursementStatus: { type: ['string', 'null'] }
        isArchived: { type: boolean }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ExpenseDetail:
      allOf:
        - $ref: '#/components/schemas/Expense'
        - type: object
          properties:
            vessel:
              type: object
              properties:
                id: { type: string, format: uuid }
                vesselName: { type: string }
                ownerId: { type: string, format: uuid }
            lineItems:
              type: array
              description: Only present when `?include=lineItems`.
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  description: { type: string }
                  quantity: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
                  unitPrice: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
                  totalPrice: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
            taxes:
              type: array
              description: Only present when `?include=taxes`.
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  rate: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
                  amount: { type: string , description: 'Serialized as string to preserve Decimal precision.' }

    ExpenseCreate:
      type: object
      required: [vesselId, date, amount]
      properties:
        vesselId: { type: string, format: uuid }
        category: { type: string, enum: [fuel, maintenance, insurance, dockage, registration, crew, provisions, equipment, upgrades, cleaning, electronics, safety, navigation, communication, entertainment, crew_gratuity, crew_food, commission, delivery, marketing, food, beverages, excursions, permits, laundry, watersports, other] }
        description: { type: string }
        amount: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        currency: { type: string }
        date: { type: string, format: date, description: Must not be in the future. }
        vendor: { type: string }
        charterBookingId: { type: string, format: uuid }
        fundingSource: { type: string, enum: [owner, charter, apa], description: '`apa` requires `charterBookingId`.' }
        notes: { type: string }
        tags: { type: array, items: { type: string } }

    ExpenseUpdate:
      type: object
      additionalProperties: true
      description: Partial update — any writable `Expense` field.
      properties:
        category: { type: string }
        description: { type: string }
        amount: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        currency: { type: string }
        date: { type: string, format: date }
        vendor: { type: ['string', 'null'] }
        notes: { type: ['string', 'null'] }

    # ── Documents ────────────────────────────────────────────────────────────
    Document:
      type: object
      properties:
        id: { type: string, format: uuid }
        vesselId: { type: ['string', 'null'], format: uuid }
        scope: { type: string, enum: [vessel, global] }
        documentType: { type: string, description: 'DocumentType enum, e.g. manual, invoice, warranty, registration, insurance, survey, certificate.' }
        category: { type: string }
        title: { type: string }
        fileUrl: { type: ['string', 'null'] }
        fileSize: { type: ['integer', 'null'] }
        mimeType: { type: ['string', 'null'] }
        equipmentId: { type: ['string', 'null'], format: uuid }
        expirationDate: { type: ['string', 'null'], format: date-time }
        uploadedBy: { type: string, format: uuid }
        tags: { type: array, items: { type: string } }
        status: { type: string, default: active }
        currentVersion: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    DocumentDetail:
      allOf:
        - $ref: '#/components/schemas/Document'
        - type: object
          properties:
            vessel: { $ref: '#/components/schemas/Vessel' }
            equipment:
              oneOf:
                - $ref: '#/components/schemas/Equipment'
                - type: 'null'
            uploader: { $ref: '#/components/schemas/UserSummary' }

    # ── Crew / STCW ──────────────────────────────────────────────────────────
    STCWDetail:
      type: object
      properties:
        id: { type: string, format: uuid }
        stcwCode: { type: ['string', 'null'] }
        stcwRegulation: { type: ['string', 'null'] }
        flagState: { type: ['string', 'null'] }
        endorsementNumber: { type: ['string', 'null'] }
        endorsementExpiry: { type: ['string', 'null'], format: date-time }
        revalidationDate: { type: ['string', 'null'], format: date-time }
        limitations: { type: ['string', 'null'] }

    STCWCertification:
      type: object
      properties:
        id: { type: string, format: uuid }
        vesselId: { type: string, format: uuid }
        userId: { type: string, format: uuid }
        certType: { type: string }
        certName: { type: string }
        issuingAuthority: { type: ['string', 'null'] }
        certNumber: { type: ['string', 'null'] }
        issuedDate: { type: ['string', 'null'], format: date-time }
        expiryDate: { type: ['string', 'null'], format: date-time }
        status: { type: string, default: active }
        notes: { type: ['string', 'null'] }
        user: { $ref: '#/components/schemas/UserSummary' }
        document:
          type: ['object', 'null']
          properties:
            id: { type: string, format: uuid }
            title: { type: string }
            fileUrl: { type: ['string', 'null'] }
            fileSize: { type: ['integer', 'null'] }
            mimeType: { type: ['string', 'null'] }
        stcwDetail:
          oneOf:
            - $ref: '#/components/schemas/STCWDetail'
            - type: 'null'
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    # ── Charter ──────────────────────────────────────────────────────────────
    GuestSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        profile:
          type: object
          properties:
            id: { type: string, format: uuid }
            firstName: { type: ['string', 'null'] }
            lastName: { type: ['string', 'null'] }
            email: { type: ['string', 'null'] }
            isVip: { type: boolean }
            profilePhoto: { type: ['string', 'null'] }

    Booking:
      type: object
      properties:
        id: { type: string, format: uuid }
        vesselId: { type: ['string', 'null'], format: uuid }
        bookingReference: { type: string }
        charterCompany: { type: ['string', 'null'] }
        startDate: { type: string, format: date-time }
        endDate: { type: string, format: date-time }
        embarkPort: { type: ['string', 'null'] }
        disembarkPort: { type: ['string', 'null'] }
        itinerary:
          type: ['object', 'array', 'null']
          additionalProperties: true
        totalPrice: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        currency: { type: string }
        depositPaid: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        status: { type: string, enum: [inquiry, confirmed, in_progress, completed, cancelled] }
        specialRequests: { type: ['string', 'null'] }
        provisioningNotes: { type: ['string', 'null'] }
        notes: { type: ['string', 'null'] }
        apaAmount: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        apaPercentage: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        deliveryFee: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        redeliveryFee: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        taxRate: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        taxAmount: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        brokerName: { type: ['string', 'null'] }
        brokerCommission: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        agentCommission: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        isArchived: { type: boolean }
        isLocked: { type: boolean, description: 'Present only when the plan has downgraded below bookings_guests full-charter access.' }
        lockReason: { type: string }
        guests:
          type: array
          items: { $ref: '#/components/schemas/GuestSummary' }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    BookingDetail:
      allOf:
        - $ref: '#/components/schemas/Booking'
        - type: object
          properties:
            provisioningList:
              type: array
              items: { type: object, additionalProperties: true }
            payments:
              type: array
              items: { type: object, additionalProperties: true }
            guestPortals:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  accessToken: { type: string }
                  isActive: { type: boolean }
                  expiresAt: { type: ['string', 'null'], format: date-time }

    BookingCreate:
      type: object
      required: [bookingReference, startDate, endDate]
      properties:
        bookingReference: { type: string }
        charterCompany: { type: string }
        startDate: { type: string, format: date-time }
        endDate: { type: string, format: date-time }
        embarkPort: { type: string }
        disembarkPort: { type: string }
        itinerary: { type: object, additionalProperties: true }
        totalPrice: { type: string, description: 'Silently stripped unless the vessel plan is bookings_guests Pro+. Serialized as string to preserve Decimal precision.' }
        currency: { type: string }
        depositPaid: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        status: { type: string, enum: [inquiry, confirmed, in_progress, completed, cancelled], default: confirmed }
        specialRequests: { type: string }
        provisioningNotes: { type: string }
        notes: { type: string }
        apaAmount: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        apaPercentage: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        deliveryFee: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        redeliveryFee: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        taxRate: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        taxAmount: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        brokerName: { type: string }
        brokerCommission: { type: string , description: 'Serialized as string to preserve Decimal precision.' }
        agentCommission: { type: string , description: 'Serialized as string to preserve Decimal precision.' }

    # ── Trips ────────────────────────────────────────────────────────────────
    TripLog:
      type: object
      properties:
        id: { type: string, format: uuid }
        vesselId: { type: string, format: uuid }
        tripName: { type: ['string', 'null'] }
        tripReference: { type: ['string', 'null'] }
        departureTime: { type: string, format: date-time }
        departureLocation:
          type: ['object', 'null']
          additionalProperties: true
        arrivalTime: { type: ['string', 'null'], format: date-time }
        arrivalLocation:
          type: ['object', 'null']
          additionalProperties: true
        captain: { type: ['string', 'null'] }
        crew: { type: array, items: { type: string } }
        purpose: { type: ['string', 'null'] }
        distanceNm: { type: ['number', 'null'] }
        fuelConsumed: { type: ['number', 'null'] }
        maxSpeed: { type: ['number', 'null'] }
        avgSpeed: { type: ['number', 'null'] }
        weatherSummary: { type: ['string', 'null'] }
        passengerCount: { type: ['integer', 'null'] }
        notes: { type: ['string', 'null'] }
        status: { type: string, enum: [scheduled, in_progress, completed, paused, cancelled] }
        engineHoursStart:
          type: ['object', 'null']
          additionalProperties: true
        engineHoursEnd:
          type: ['object', 'null']
          additionalProperties: true
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    TripLogDetail:
      allOf:
        - $ref: '#/components/schemas/TripLog'
        - type: object
          properties:
            vessel:
              type: object
              properties:
                id: { type: string, format: uuid }
                vesselName: { type: string }
                make: { type: ['string', 'null'] }
                model: { type: ['string', 'null'] }

    TripLogCreate:
      type: object
      required: [vesselId, departureTime]
      properties:
        vesselId: { type: string, format: uuid }
        departureTime: { type: string, format: date-time }
        departureLocation: { type: object, additionalProperties: true }
        arrivalTime: { type: string, format: date-time }
        arrivalLocation: { type: object, additionalProperties: true }
        captain: { type: string }
        crew: { type: array, items: { type: string } }
        purpose: { type: string }
        distanceNm: { type: number }
        fuelConsumed: { type: number }
        maxSpeed: { type: number }
        avgSpeed: { type: number }
        weatherSummary: { type: string }
        passengerCount: { type: integer }
        notes: { type: string }
        status: { type: string, enum: [scheduled, in_progress, completed, paused, cancelled] }
        engineInputs:
          type: array
          description: New-shape vital inputs — only meaningful with status=in_progress.
          items: { type: object, additionalProperties: true }
        fuelInputs:
          type: array
          items: { type: object, additionalProperties: true }

    # ── Compliance ───────────────────────────────────────────────────────────
    DrillCadence:
      type: object
      properties:
        id: { type: string, format: uuid }
        drillType: { type: string, description: 'DrillType enum, e.g. fire, abandon_ship, man_overboard, lifeboat_launch, steering_gear_test.' }
        frequency: { type: string, description: 'RecurringFrequency enum, e.g. monthly, quarterly, annually.' }
        isActive: { type: boolean }
        lastCompletedAt: { type: ['string', 'null'], format: date-time }
        nextDueAt: { type: ['string', 'null'], format: date-time }
        alertDaysBeforeDue: { type: integer }
        checklistTemplateId: { type: ['string', 'null'], format: uuid }
        cadenceOverrideNote: { type: ['string', 'null'] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Permit:
      type: object
      description: Trimmed summary projection of PermitToWork — not every column (see `docs/api.md`).
      properties:
        id: { type: string, format: uuid }
        permitType: { type: string, description: 'PtwType enum: hot_work, enclosed_space, working_aloft, working_overside, diving.' }
        title: { type: string }
        status: { type: string, description: 'PtwStatus enum: pending_approval, issued, active, suspended, closed, cancelled.' }
        issuedAt: { type: string, format: date-time }
        validFrom: { type: string, format: date-time }
        validUntil: { type: string, format: date-time }
        checklistInstanceId: { type: ['string', 'null'], format: uuid }
        holderName: { type: ['string', 'null'] }
        issuerName: { type: ['string', 'null'] }
        isExpired: { type: boolean }

    RegulatoryRecord:
      type: object
      properties:
        id: { type: string, format: uuid }
        vesselId: { type: string, format: uuid }
        entryType: { type: string, enum: [orb_annex_i, grb_annex_v, bwm_record] }
        operationDate: { type: string, format: date-time }
        operationTime: { type: ['string', 'null'], description: '"HH:MM" UTC.' }
        positionLat: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        positionLon: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        positionText: { type: ['string', 'null'] }
        officerUserId: { type: ['string', 'null'], format: uuid }
        officer:
          oneOf:
            - $ref: '#/components/schemas/UserSummary'
            - type: 'null'
        signatureUrl: { type: ['string', 'null'] }
        operationCode: { type: ['string', 'null'], description: 'ORB Annex I code A-H.' }
        operationDescription: { type: ['string', 'null'] }
        quantityLitres: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        disposalMethod: { type: ['string', 'null'] }
        retentionTankId: { type: ['string', 'null'] }
        grbCategory: { type: ['string', 'null'], description: 'GRB Annex V category A-H.' }
        grbDisposalLocation: { type: ['string', 'null'] }
        grbMassKg: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        bwmOperationType: { type: ['string', 'null'] }
        bwmSourceWater: { type: ['string', 'null'] }
        bwmTreatmentSystem: { type: ['string', 'null'] }
        bwmVolumeM3: { type: ['string', 'null'] , description: 'Serialized as string to preserve Decimal precision.' }
        receiptUrl: { type: ['string', 'null'] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
