# Catalogue Ingest API, OpenAPI 3.1 specification.
#
# HAND-MAINTAINED. Keep this file in lockstep with the Zod schema in
# server/services/ingest-works.ts (the source of truth for request validation) and with the
# human doc server/docs/catalogue-ingest-api.md. When the Zod schema changes (fields, enums,
# 0-100 constraints, .strict()), update this file in the same change. There is no code
# generation between them.
openapi: 3.1.0
info:
  title: Catalogue Ingest API
  version: "1.0.0"
  description: >
    Inbound-only write API for sending a catalogue of musical works into TakeRecord. A single
    endpoint (POST /api/v1/catalogue/works) upserts works, validates and normalises them, and
    reports every transformation back in the response. There are no read/list/export endpoints
    on this surface.
servers:
  - url: https://ingest.takerecord.com
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Catalogue
    description: Work ingest.
paths:
  /api/v1/catalogue/works:
    post:
      tags: [Catalogue]
      operationId: ingestWorks
      summary: Upsert one to 500 works into a catalogue.
      description: >
        Creates a new catalogue when catalog_id is omitted, or appends to an existing one.
        With ?dry_run=true (or body mode=validate) the request validates and reports without
        persisting and returns 200. Otherwise it upserts by client_work_ref and returns 201.
        Send an Idempotency-Key on commits so retries are safe.
      parameters:
        - name: dry_run
          in: query
          required: false
          description: >
            When true, validate and report without persisting (equivalent to body mode=validate).
            Returns 200.
          schema:
            type: boolean
        - name: Idempotency-Key
          in: header
          required: false
          description: >
            Unique per request (a UUID is ideal); one key per chunk when paging. Same key with the
            same body replays the original response (with header Idempotent-Replay: true); same key
            while still processing returns 409; same key with a different body returns 422. Retained
            24h. Ignored in dry-run.
          schema:
            type: string
        - name: x-client-id
          in: header
          required: false
          description: Optional. If present, must equal the client bound to the API key, else 403.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IngestRequest"
      responses:
        "200":
          description: >
            Dry-run (validate) result. Nothing persisted; work_ids is empty and each
            results[].work_id is null.
          headers:
            x-request-id:
              description: Request id for support/debugging.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestResponse"
        "201":
          description: Commit result. Works persisted.
          headers:
            x-request-id:
              schema:
                type: string
            Idempotent-Replay:
              description: >
                Present and set to true when this response is a replay of a prior request that used
                the same Idempotency-Key.
              schema:
                type: string
                enum: ["true"]
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestResponse"
        "400":
          description: >
            Validation failed (an unknown/misspelled field, a share outside the 0-100 range, or a
            body client_id that does not match the key).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing, invalid, expired, or revoked API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Not a per-client key, missing scope, or x-client-id header mismatch.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: catalog_id is not one of this client's catalogues.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: An Idempotency-Key is still being processed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: An Idempotency-Key was reused with a different request body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded (60 requests per minute per key).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Unexpected server error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: trk_live_...
      description: >
        Per-client API key created in the app (Settings, then API keys, then Manage API keys).
        Resolves the client server-side.
  schemas:
    # A percentage on the 0-100 scale (50 = 50%, 1 = 1%). Divided by 100 at the boundary.
    Percentage:
      type: [number, "null"]
      minimum: 0
      maximum: 100
    IngestRequest:
      type: object
      additionalProperties: false
      required: [works]
      properties:
        client_id:
          type: string
          minLength: 1
          description: If present, must match the client bound to the API key.
        catalog_name:
          type: string
          description: Names a new catalogue. Default is API Import YYYY-MM-DD.
        catalog_id:
          type: string
          description: Append to an existing catalogue. A new one is created if omitted. 404 if not this client's.
        mode:
          type: string
          enum: [validate, commit]
          description: The value validate is a dry-run; commit (the default) persists.
        works:
          type: array
          minItems: 1
          maxItems: 500
          items:
            $ref: "#/components/schemas/WorkInput"
    WorkInput:
      type: object
      additionalProperties: false
      required: [primary_title, recording]
      properties:
        primary_title:
          type: string
          minLength: 1
        client_work_ref:
          type: [string, "null"]
          minLength: 1
          maxLength: 255
          description: Your stable id for this work, and the upsert key. Re-sending it merges into the existing work.
        recording:
          $ref: "#/components/schemas/Recording"
        writers:
          type: array
          items:
            $ref: "#/components/schemas/Writer"
        publishers:
          type: array
          items:
            $ref: "#/components/schemas/Publisher"
        iswc:
          type: [string, "null"]
          description: Canonicalised on store.
        mlc_song_code:
          type: [string, "null"]
        duration:
          type: [string, "null"]
          description: Canonicalised to HH:MM:SS on store.
    Recording:
      type: object
      additionalProperties: false
      required: [artist_name]
      properties:
        artist_name:
          type: string
          minLength: 1
        isrc:
          type: [string, "null"]
          description: Recommended match anchor. Canonicalised on store.
        album_title:
          type: [string, "null"]
        release_date:
          type: [string, "null"]
        label:
          type: [string, "null"]
        upc:
          type: [string, "null"]
        master_share:
          $ref: "#/components/schemas/Percentage"
          description: Sound-recording ownership (the SoundExchange Percentage Claimed).
    Writer:
      type: object
      additionalProperties: false
      required: [writer_first_name, writer_last_name]
      properties:
        writer_first_name:
          type: string
        writer_last_name:
          type: string
        writer_ipi:
          type: [string, "null"]
          description: IPI name number (9 to 11 digits).
        writer_role_code:
          type: string
          enum: [CA, C, A, AR, AD, SR, SA, TR]
          default: CA
        writer_pro_affiliation:
          type: [string, "null"]
        writer_ownership_pct:
          $ref: "#/components/schemas/Percentage"
          description: The writer's share of the composition.
        writer_controlled:
          type: boolean
          default: false
    Publisher:
      type: object
      additionalProperties: false
      required: [publisher_name]
      properties:
        publisher_name:
          type: string
        publisher_ipi:
          type: [string, "null"]
        publisher_collection_share:
          $ref: "#/components/schemas/Percentage"
          description: Mechanical collection share (the MLC value).
        publisher_pr_share:
          $ref: "#/components/schemas/Percentage"
          description: Performance collection share.
        publisher_pro_affiliation:
          type: [string, "null"]
        publisher_controlled:
          type: boolean
          default: false
        publisher_role:
          type: [string, "null"]
          enum: [E, AM, SE, null]
          description: E is original, AM is admin, SE is sub-publisher.
        publisher_mlc_number:
          type: [string, "null"]
        territory:
          type: [string, "null"]
          default: World
        linked_writer_index:
          type: [integer, "null"]
          minimum: 0
          description: 0-based index into this work's writers[]. Which writer this publisher collects for.
        linked_writer_ipi:
          type: [string, "null"]
          description: Alternative to the index. The IPI of the writer this publisher collects for.
    IngestResponse:
      type: object
      required:
        - catalog_id
        - catalog_name
        - mode
        - works_created
        - works_updated
        - works_skipped
        - work_ids
        - results
        - gaps
        - validation
        - roster_adjusted
        - prose_stripped
        - canonicalised
      properties:
        catalog_id:
          type: string
        catalog_name:
          type: string
        mode:
          type: string
          enum: [commit, validate]
        works_created:
          type: integer
        works_updated:
          type: integer
        works_skipped:
          type: integer
        work_ids:
          type: array
          description: Ids of created works, format REC-YYYY-NNNNN. Empty in validate mode.
          items:
            type: string
        results:
          type: array
          description: Per-work echo so you can reconcile your id to ours.
          items:
            $ref: "#/components/schemas/WorkResult"
        gaps:
          type: array
          description: Presence report (what is missing).
          items:
            $ref: "#/components/schemas/Gap"
        validation:
          $ref: "#/components/schemas/ValidationSummary"
        cleansed_identifiers:
          type: [object, "null"]
          description: Counts of identifier cells that held junk/prose and were blanked to a gap, keyed by type.
          additionalProperties:
            type: integer
        roster_adjusted:
          type: integer
          description: Works whose writer roster was split/de-duplicated.
        prose_stripped:
          type: integer
          description: Works that had request-prose removed from structured fields.
        canonicalised:
          type: integer
          description: Identifier/duration fields normalised to canonical form.
    WorkResult:
      type: object
      required: [client_work_ref, work_id, action]
      properties:
        client_work_ref:
          type: [string, "null"]
        work_id:
          type: [string, "null"]
          description: Our id, format REC-YYYY-NNNNN. Null in validate mode.
        action:
          type: string
          enum: [created, updated, skipped]
    Gap:
      type: object
      required: [work_index, title, missing_fields]
      properties:
        work_index:
          type: integer
        title:
          type: string
        missing_fields:
          type: array
          items:
            type: string
            enum:
              - isrc
              - iswc
              - writers
              - writer_ipi
              - writer_ownership_pct
              - publishers
              - publisher_collection_share
    ValidationSummary:
      type: object
      required: [total_works, works_flagged, attestation_ran, issues]
      description: Correctness report. Never blocks the write.
      properties:
        total_works:
          type: integer
        works_flagged:
          type: integer
        attestation_ran:
          type: boolean
          description: False when the registry check was skipped (format and share checks still run).
        issues:
          type: array
          description: Flagged works only.
          items:
            $ref: "#/components/schemas/WorkIssue"
    WorkIssue:
      type: object
      required: [work_index, work_id, title, fields]
      properties:
        work_index:
          type: integer
        work_id:
          type: string
        title:
          type: string
        fields:
          type: array
          items:
            $ref: "#/components/schemas/FieldIssue"
    FieldIssue:
      type: object
      required: [field_name, reason, suggested_action]
      properties:
        field_name:
          type: string
        reason:
          type: string
        code:
          type: string
          enum:
            - ipi_format
            - ipi_unregistered
            - ipi_wrong_party
            - iswc_format
            - isrc_format
            - writer_share_over
            - collection_share_over
        suggested_action:
          type: string
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error message.
        code:
          type: string
          description: Optional machine-readable error code.
