# TracePass v1 API — OpenAPI 3.1 specification
# ---------------------------------------------------------------------
# Hand-written, cross-checked against the platform's Zod schemas in
# src/lib/validation/schemas.ts and the route handlers under
# src/app/api/v1/. Authoritative reference for every public endpoint
# TracePass exposes; the prose docs at https://www.tracepass.eu/docs
# wrap the same vocabulary in a more readable form.
#
# Format conventions:
#   - All v1 endpoints require an API key as a Bearer token in the
#     Authorization header (BearerAuth security scheme below).
#   - All write endpoints accept an Idempotency-Key header with
#     24-hour replay semantics.
#   - GTINs are 14 digits with a valid GS1 check digit; serials are
#     unique within a GTIN; GLNs are 13 digits with mod-10 check
#     digit.
#   - The bulk JSON-LD tenant export uses session-cookie auth
#     instead — it is intentionally NOT API-key reachable.

openapi: 3.1.0

info:
  title: TracePass API
  version: "1.0.0"
  summary: Digital Product Passport REST API for the TracePass platform.
  description: |
    REST API for managing Digital Product Passports on TracePass —
    products, passports, parties (economic operators), bulk batches,
    and the JSON-LD tenant export.

    Most operations require an API key in the standard
    `Authorization: Bearer <key>` header. v1 API access is available
    on every plan including Free. Daily call caps apply only to
    Free (100/day) and Basic (200/day) as free-tier abuse prevention;
    Starter and above are unlimited.

    Worked examples in curl / TypeScript / Python live alongside
    each endpoint at https://www.tracepass.eu/docs.
  contact:
    name: TracePass support
    email: support@tracepass.eu
    url: https://www.tracepass.eu
  license:
    name: TracePass Terms of Service
    url: https://www.tracepass.eu/terms
  termsOfService: https://www.tracepass.eu/terms

servers:
  - url: https://app.tracepass.eu
    description: Production

externalDocs:
  description: Prose documentation with worked examples
  url: https://www.tracepass.eu/docs

tags:
  - name: Passports
    description: |
      Create, read, update, suspend, archive, and bulk-import Digital
      Product Passports. Includes the parties block for economic-
      operator chains.
    externalDocs:
      url: https://www.tracepass.eu/docs/passports
  - name: Products
    description: |
      The catalogue layer — products, images, defaults. One product
      can have many passports (one per serialised unit).
    externalDocs:
      url: https://www.tracepass.eu/docs/products
  - name: Exports
    description: |
      Bulk JSON-LD tenant export. Dashboard-cookie auth, not API
      key — see the operation's security block.
    externalDocs:
      url: https://www.tracepass.eu/docs/exports
  - name: EPCIS
    description: |
      GS1 EPCIS 2.0 supply-chain events — export a passport's event
      history, capture events from partners and ERP systems, and
      query the event store. Export is included on Starter plans and
      up; capture and query are a paid add-on.
    externalDocs:
      url: https://www.tracepass.eu/docs/epcis
  - name: Templates
    description: |
      The regulatory DPP schemas — what a compliant passport must
      contain per category, and the EU regulation behind each
      required field. Read-only reference data; use it to discover
      requirements before creating products and passports.
    externalDocs:
      url: https://www.tracepass.eu/docs
  - name: OAuth
    description: |
      OAuth 2.0 authorization-server endpoints (authorization-code
      flow with PKCE). These power the user-authorized auth method —
      a third-party app or AI assistant a user "Connects". Not v1
      resource endpoints; documented here so client libraries can
      drive the flow. Discovery metadata at
      `/.well-known/oauth-authorization-server` (RFC 8414).
    externalDocs:
      url: https://www.tracepass.eu/docs/authentication

# Default security applied to every operation: EITHER an API key
# (BearerAuth, all-or-nothing) OR an OAuth2 access token. The global
# OAuth2 requirement lists the read scope `passports:read` as the
# baseline; write operations override this block with `passports:write`
# (an OAuth token needs the write scope to mutate, while an API key
# bypasses scope checks entirely). The tenant-export operation overrides
# with its own (cookie-based) requirement.
security:
  - BearerAuth: []
  - OAuth2:
      - passports:read

paths:
  # =====================================================================
  # Passports
  # =====================================================================

  /api/v1/passports:
    post:
      operationId: createPassport
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Create a single passport.
      description: |
        Create a single Digital Product Passport. The passport is
        bound to a product (`productId`) and identified by a GS1
        GTIN + serial unique within that GTIN. New passports start
        in `draft` status — fields are populated via subsequent
        PATCH calls and the passport is published from the
        dashboard once review is complete.

        Counts as one v1 write AND consumes one DPP slot from the
        plan's `maxDpps` quota — when that quota is exhausted and
        the plan supports overage, returns 402 with an
        `overage_required` body. Retry with
        `confirmOverage: true` to accept the per-passport charge.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePassportInput"
            example:
              productId: "6650a1b2c3d4e5f6a7b8c9d0"
              gs1:
                gtin: "04012345000015"
                serialNumber: "BP-48V-100-000001"
      responses:
        "201":
          description: Passport created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/OverageRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

    get:
      operationId: listPassports
      tags: [Passports]
      summary: List passports.
      description: |
        Paginated list of passports the workspace owns. Filter by
        product, status, or a free-text search across GTIN and
        serial.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: productId
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/ObjectId"
        - name: status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/PassportStatus"
        - name: search
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Paginated passport list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/PaginatedPassports"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    get:
      operationId: getPassport
      tags: [Passports]
      summary: Read one passport.
      description: |
        Default response includes the full passport — translatable
        fields carry their `sourceLocale` and a per-locale
        `translations` map. `?lang=<locale>` resolves every field
        through the public-viewer chain and returns one resolved
        value per field; `?format=full` adds template-derived
        labels, units, and access levels alongside each value.

        An alternate addressing form exists at
        `GET /api/v1/passports/by-serial/{serial}` with the same
        body and query-parameter behaviour.
      parameters:
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [full]
          description: |
            Set to `full` to include template field labels, units,
            and access levels alongside each field value.
        - name: lang
          in: query
          required: false
          schema:
            type: string
          description: |
            ISO 639-1 locale to resolve every field value through
            the public-viewer chain (viewer-locale translation →
            source-locale value → English → first-applied →
            universal source). One of the 24 EU locales. When set,
            the `translations` map is dropped from each field.
      responses:
        "200":
          description: Passport.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"
    delete:
      operationId: deletePassport
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Delete a passport permanently.
      description: |
        **Permanent and irreversible.** Removes the passport row
        plus every cascaded dependent (extractions, agent-session
        state, supplier requests linked to this passport alone,
        scan + service events, EPCIS event refs, and any uploaded
        documents whose only reference was this passport — shared
        documents stay with the link removed). R2 storage under
        the passport's folder is swept.

        **Only never-published passports are eligible.** Status
        must be `draft` or `in_review` AND `publishedAt` must be
        `null`. Anything else returns 409 with a `reason` naming
        the policy. The compliance moat protecting printed QRs in
        the wild is preserved — a published passport can only be
        archived, never deleted.

        **Paid-plan feature.** Returns 403 on Free plans with a
        `reason` field pointing at the archive endpoint as the
        no-cost alternative.

        Honours `Idempotency-Key`. No webhook (the `billingEvents`
        audit entry is the trail).
      responses:
        "200":
          description: Permanently deleted; summary includes cascade counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  summary:
                    type: object
                    properties:
                      passportId: { type: string }
                      serialNumber: { type: string }
                      gtin: { type: string }
                      deletedCounts:
                        type: object
                        description: Per-collection cascade counts plus R2 object sweep.
                      dppsActiveDelta:
                        type: integer
                        enum: [-1, 0]
                        description: How the live-active counter moved (always -1 or 0).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Plan gate — hard-delete is paid-only.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Passport not eligible (already published / approved / suspended / etc).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/compliance:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    get:
      operationId: getPassportCompliance
      tags: [Passports]
      summary: Compliance verdict for one passport.
      description: |
        Returns a three-tier compliance verdict
        (`compliant` / `compliant_with_warnings` / `incomplete`) with
        regulation-cited findings, so an integration can gap-check a
        passport and re-check after fixing — the read → fix → verify
        loop.

        Findings come from three tiers:
        - **static** — required template fields present + approved,
          required economic-operator parties present (critical), and
          well-formed field values (warning);
        - **conditional** — per-category rules in force today (battery
          Reg (EU) 2023/1542 Art. 77 + Annex XIII field-applicability,
          chemicals REACH Art. 33 / SCIP, construction Reg (EU) 2024/3110),
          plus the cross-cutting EU-economic-operator rule
          (Reg (EU) 2019/1020 Art. 4). For batteries, applicability
          findings (`ruleId: "BAT-APP"`, warning) flag fields filled but
          not applicable to the battery's type — e.g. carbon footprint on
          a non-rechargeable cell. These reuse the standard
          `ComplianceFinding` shape (no new fields);
        - **coverage** — `conditionalCoverage` reports whether
          conditional rules were evaluated for this category or it is
          `static-only` (no binding conditional in force yet), so the
          absence of conditional findings is never read as compliance.

        Read-only; counts as one v1 passport read against the daily cap.
      responses:
        "200":
          description: Compliance verdict.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceVerdict"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/registry-readiness:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    get:
      operationId: getPassportRegistryReadiness
      tags: [Passports]
      summary: Registry-readiness preflight for one passport.
      description: |
        Returns whether a passport would pass the **EU DPP Registry's
        formal submission gate** — `{ ready, findings[] }`. This is the
        registry's *mechanical* pre-submission check, distinct from and
        complementary to the substantive `/compliance` verdict: a
        passport can be registry-ready yet not substantively compliant,
        and vice-versa.

        Three formal checks, each carried on the standard
        `ComplianceFinding` shape with a `REG-READY-*` `ruleId`:
        - **`REG-READY-MANDATORY`** — every mandatory field that applies
          to this battery type is present and approved (applicability-aware:
          a field that is not applicable to the battery's category is not
          counted as missing; an `unknown`-applicability field surfaces as
          a warning, not a block);
        - **`REG-READY-FORMAT`** — filled fields are well-formed
          (types, enums, patterns, ranges);
        - **`REG-READY-LINK`** — the passport's public link resolves
          (a live probe; on an unpublished passport this is an advisory
          warning rather than a block).

        `ready` is `true` only when there are zero critical findings.
        **Battery passports only** in this version; a non-battery passport
        returns `ready: true` with a single `REG-READY-SCOPE` warning.

        Read-only; counts as one v1 passport read against the daily cap.
      responses:
        "200":
          description: Registry-readiness result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RegistryReadinessResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/fields/{key}:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/FieldKey"
      - $ref: "#/components/parameters/IdempotencyKey"
    patch:
      operationId: updatePassportField
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Update one field on a passport.
      description: |
        Patch a single field on a passport. The value is validated
        against the field key (must exist on the passport's
        template) and persisted with an audit-trail entry tagged
        `via API key <prefix>`. Writes default to `status:
        "approved"` — API-key-driven integrations are trusted by
        convention. Override with `source: "ai_suggested"` or
        `source: "supplier"` to land in the review queue instead.

        An alternate addressing form exists at PATCH
        `/api/v1/passports/by-serial/{serial}/fields/{key}` with
        the same body shape.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateFieldInput"
            example:
              value: 5.24
      responses:
        "200":
          description: Field updated.
          content:
            application/json:
              schema:
                type: object
                required: [field, version]
                properties:
                  field:
                    $ref: "#/components/schemas/PassportField"
                  version:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/parties/{role}:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/PartyRole"
      - $ref: "#/components/parameters/IdempotencyKey"
    patch:
      operationId: upsertParty
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Upsert an economic-operator party.
      description: |
        Create or replace the Party for one role on a passport.
        Sending the same role twice is an upsert: the existing
        block is replaced atomically. At least one of `gln` or
        `legacyOperatorId` must be set in the body — without an
        identifier the Party doesn't actually identify anyone.

        When two roles share the same `gln`, the JSON-LD emission
        collapses them into a single party with multiple roles
        attached.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Party"
            example:
              legalName: "EuroBat Recyclers GmbH"
              gln: "4012345678901"
              country: "DE"
              url: "https://eurobat-recyclers.example"
      responses:
        "200":
          description: Party upserted.
          content:
            application/json:
              schema:
                type: object
                required: [role, party, version]
                properties:
                  role:
                    $ref: "#/components/schemas/PartyRoleEnum"
                  party:
                    $ref: "#/components/schemas/Party"
                  version:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"
    delete:
      operationId: deleteParty
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Remove a party from a passport.
      description: |
        Remove the Party for one role on a passport. Idempotent —
        removing an absent role returns `{ removed: false }`
        without bumping the passport version.
      responses:
        "200":
          description: Party removed (or already absent).
          content:
            application/json:
              schema:
                type: object
                required: [role, removed]
                properties:
                  role:
                    $ref: "#/components/schemas/PartyRoleEnum"
                  removed:
                    type: boolean
                  version:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/suspend:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: suspendPassport
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Suspend a passport (reversible).
      description: |
        Reversible suspend. The public viewer flips to the
        suspended state page (HTTP 423 with structured body); QR
        scans effectively die without the URL going to 404.

        Optional `reason` body surfaces in the
        `passport.suspended` webhook payload and the dashboard
        audit trail (truncated at 500 chars). Empty body is fine.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
            example:
              reason: "Quality investigation pending — batch BB-2026-04-12."
      responses:
        "200":
          description: Suspended.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Wrong status to suspend from.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/archive:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: archivePassport
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Archive a passport (irreversible).
      description: |
        **Irreversible.** Public viewer returns 404, the GS1
        Digital Link URL stops resolving, the QR code dies for
        good. Use ONLY for products that never shipped — archiving
        a passport for a product already in customers' hands
        breaks every QR scan they'll ever make.

        The HTTP method is POST and the path includes a literal
        `archive` segment, both intentional friction. Fires the
        `passport.archived` webhook.
      responses:
        "200":
          description: Archived.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Already archived.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/batch:
    post:
      operationId: batchCreatePassports
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Batch-create passports (up to 100 per call).
      description: |
        Create up to 100 passports in one call. Each item carries
        the same body shape as the single-create endpoint;
        partial-success per item — each gets its own status in the
        response array.

        The whole batch consumes N writes upfront against the
        daily-write budget; if that would overflow, the entire
        batch returns 429 (no partial billing). Same applies to
        DPP overage at the batch level — when the DPP quota would
        be exceeded the batch returns 402 with `overage_required`,
        and `confirmOverage: true` accepts the overage charge for
        the whole batch.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BatchCreatePassportsInput"
      responses:
        "200":
          description: Partial-success batch result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchPassportsResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/OverageRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/qr:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    get:
      operationId: getPassportQr
      tags: [Passports]
      summary: Render the passport's QR code.
      description: |
        Returns the QR code for a passport. Default response is a
        raw SVG image so `<img src=".../qr">` works directly. Use
        `?format=png` for a raster PNG, or `?format=json` for an
        envelope containing both the SVG markup and a base64
        PNG data URI plus the encoded URL + status.

        Counts as one v1 passport-read against the daily budget
        (same counter as the data endpoint).
      parameters:
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [svg, png, json]
            default: svg
        - name: useCompanyBranding
          in: query
          required: false
          schema:
            type: string
            enum: ["true"]
          description: |
            When set to `true`, foreground colour comes from the
            company's `branding.primaryColor`. Ignored if `color`
            is also set.
        - name: color
          in: query
          required: false
          schema:
            type: string
            pattern: "^[0-9A-Fa-f]{6}$"
          description: 6-char hex without `#`. Wins over useCompanyBranding.
        - name: backgroundColor
          in: query
          required: false
          schema:
            type: string
            pattern: "^[0-9A-Fa-f]{6,8}$"
          description: 6 or 8 char hex without `#` (8 = RGBA).
      responses:
        "200":
          description: QR rendering.
          content:
            image/svg+xml:
              schema:
                type: string
            image/png:
              schema:
                type: string
                format: binary
            application/json:
              schema:
                $ref: "#/components/schemas/QrJsonEnvelope"
        "400":
          description: Invalid colour parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"

  # =====================================================================
  # By-serial alternate addressing — same operations as the by-id forms,
  # just with the customer's own serial in the path. Useful when the ERP
  # only carries the serial. Bodies and responses are byte-identical to
  # the by-id siblings.
  # =====================================================================

  /api/v1/passports/by-serial/{serial}:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/GtinQuery"
    get:
      operationId: getPassportBySerial
      tags: [Passports]
      summary: Read one passport by serial.
      description: |
        By-serial addressing for the read endpoint — same response
        shape and same query parameters (`?lang=`, `?format=full`)
        as `GET /api/v1/passports/{id}`.
      parameters:
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [full]
        - name: lang
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Passport.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/AmbiguousSerial"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/qr:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/GtinQuery"
    get:
      operationId: getPassportQrBySerial
      tags: [Passports]
      summary: Render the passport's QR code (by serial).
      description: Same shape as `GET /api/v1/passports/{id}/qr`.
      parameters:
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [svg, png, json]
            default: svg
        - name: useCompanyBranding
          in: query
          required: false
          schema:
            type: string
            enum: ["true"]
        - name: color
          in: query
          required: false
          schema:
            type: string
            pattern: "^[0-9A-Fa-f]{6}$"
        - name: backgroundColor
          in: query
          required: false
          schema:
            type: string
            pattern: "^[0-9A-Fa-f]{6,8}$"
      responses:
        "200":
          description: QR rendering.
          content:
            image/svg+xml:
              schema:
                type: string
            image/png:
              schema:
                type: string
                format: binary
            application/json:
              schema:
                $ref: "#/components/schemas/QrJsonEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/AmbiguousSerial"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/fields/{key}:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/GtinQuery"
      - $ref: "#/components/parameters/FieldKey"
      - $ref: "#/components/parameters/IdempotencyKey"
    patch:
      operationId: updatePassportFieldBySerial
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Update one field on a passport (by serial).
      description: |
        Same body, same response, and same semantics as PATCH
        `/api/v1/passports/{id}/fields/{key}`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateFieldInput"
      responses:
        "200":
          description: Field updated.
          content:
            application/json:
              schema:
                type: object
                required: [field, version]
                properties:
                  field:
                    $ref: "#/components/schemas/PassportField"
                  version:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/AmbiguousSerial"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/suspend:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/GtinQuery"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: suspendPassportBySerial
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Suspend a passport (by serial).
      description: Same shape as POST `/api/v1/passports/{id}/suspend`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
      responses:
        "200":
          description: Suspended.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/AmbiguousSerial"
        "422":
          description: Wrong status to suspend from.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/archive:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/GtinQuery"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: archivePassportBySerial
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Archive a passport (by serial, irreversible).
      description: Same shape as POST `/api/v1/passports/{id}/archive`. **Irreversible.**
      responses:
        "200":
          description: Archived.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/AmbiguousSerial"
        "422":
          description: Already archived.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/epcis:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/GtinQuery"
    get:
      operationId: passportEpcisExportBySerial
      tags: [EPCIS]
      summary: Export a passport's events as an EPCIS 2.0 document (by serial).
      description: |
        Same EPCIS 2.0 export as
        `GET /api/v1/passports/{id}/epcis`, looked up by your own
        serial instead of our internal id and scoped to the API
        key's company. Returns a standards-valid EPCIS 2.0 JSON-LD
        `EPCISDocument`.

        EPCIS export is included on Starter plans and up; a tenant
        without it gets `403 epcis_export_not_available`. Counts as
        one passport read.
      responses:
        "200":
          description: EPCIS 2.0 document.
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/EpcisDocument"
        "400":
          description: Invalid serial.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: EPCIS export not available on this plan.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/AmbiguousSerial"

  # =====================================================================
  # Products
  # =====================================================================

  /api/v1/products:
    post:
      operationId: createProduct
      tags: [Products]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Create a product.
      description: |
        Create a product. The category template is resolved
        automatically from the `category` slug — must match a
        seeded category. `model` strings are unique within the
        workspace.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateProductInput"
            example:
              name: "Li-Ion 48V Battery Pack"
              model: "BP-48V-100"
              category: "batteries"
              defaultFieldValues:
                batteryChemistry: "NMC"
                nominalVoltage: 48
      responses:
        "201":
          description: Product created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Product"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

    get:
      operationId: listProducts
      tags: [Products]
      summary: List products.
      description: Paginated list of products in the workspace.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: category
          in: query
          required: false
          schema:
            type: string
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [active, archived]
        - name: search
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Paginated product list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/PaginatedProducts"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/products/{id}:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      operationId: getProduct
      tags: [Products]
      summary: Read one product.
      description: |
        Read one product by ID. Returns the full document
        including default field values, image URLs, template
        reference, and the running `passportCount`.
      responses:
        "200":
          description: Product.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Product"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"
    patch:
      operationId: updateProduct
      tags: [Products]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Update a product.
      description: |
        Patch one or more product fields. Send only the keys you
        want to change — omitted fields stay untouched.

        `imageUrls` REPLACES the existing array (deliberate — your
        CMS stays canonical). To append a single image without
        rewriting the list, use the multipart upload endpoint.
        `description: null` clears the description (distinct from
        omitting the key).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateProductInput"
      responses:
        "200":
          description: Product updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Product"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"
    delete:
      operationId: deleteProduct
      tags: [Products]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Delete a product permanently.
      description: |
        **Permanent and irreversible.** Removes the product row +
        any uploaded documents whose only reference was this
        product (documents linked to other products/passports
        stay, just with `productId` unset). R2 storage under
        `<companyId>/products/<productId>/` is swept.

        **Only products with ZERO passports of any status are
        eligible** — including archived. The rule preserves audit
        history: an archived passport carries the regulatory story
        ("we published this, then withdrew it") that would be
        orphaned if the parent product disappeared.

        **Paid-plan feature.** Returns 403 on Free plans; use the
        archive endpoint instead.

        Honours `Idempotency-Key`. No webhook.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Permanently deleted; summary includes cascade counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  summary:
                    type: object
                    properties:
                      productId: { type: string }
                      name: { type: string }
                      deletedCounts:
                        type: object
                        properties:
                          documentRefsUnlinked: { type: integer }
                          documentsDeleted: { type: integer }
                          r2ObjectsDeleted: { type: integer }
                          documentR2ObjectsDeleted: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Plan gate — hard-delete is paid-only.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Product not eligible (any passports still reference it).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/products/{id}/archive:
    parameters:
      - $ref: "#/components/parameters/ProductId"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: archiveProduct
      tags: [Products]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Archive a product.
      description: |
        Soft-archive. `Product.status` flips to `archived`; the
        product disappears from default listings (still visible
        with the `?showArchived=true` filter). Existing passports
        keep resolving — archive blocks NEW passport creation
        against this product going forward; it doesn't break any
        QR already in customers' hands.

        Returns 409 when any non-archived passport still references
        the product. Honours `Idempotency-Key`.
      responses:
        "200":
          description: Archived.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Product"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Product still has non-archived passports.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/products/batch:
    post:
      operationId: batchCreateProducts
      tags: [Products]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Batch-create products (up to 100 per call).
      description: |
        Create up to 100 products in one call. Each item carries
        the same body shape as the single-create endpoint.
        Partial-success per item — each gets its own status in
        the response array.

        The whole batch consumes N writes upfront against the
        daily-write budget; if that would overflow, the entire
        batch returns 429 (no partial billing).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [products]
              properties:
                products:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    $ref: "#/components/schemas/CreateProductInput"
      responses:
        "200":
          description: Partial-success batch result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchProductsResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/products/{id}/images:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    post:
      operationId: uploadProductImage
      tags: [Products]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Upload a product image.
      description: |
        Upload a single image file via `multipart/form-data` (field
        name `file`) and append it to the product's `imageUrls`
        array. Returns the resulting public R2 URL plus the full
        updated array.

        PNG / JPG / WebP only, max 5 MB per file, max 20 images
        per product. **No Idempotency-Key support** — multipart
        bodies aren't safely hashable; the client should check
        existence and skip if retrying.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: Image bytes (PNG, JPG, or WebP, ≤ 5 MB).
      responses:
        "201":
          description: Image uploaded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UploadImageResponse"
        "400":
          description: No file provided.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          description: Image larger than 5 MB.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "415":
          description: Unsupported MIME type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: Product already has 20 images (max).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/products/{id}/images/{index}:
    parameters:
      - $ref: "#/components/parameters/ProductId"
      - name: index
        in: path
        required: true
        schema:
          type: integer
          minimum: 0
        description: Zero-based image index in the product's `imageUrls` array.
    delete:
      operationId: deleteProductImage
      tags: [Products]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Remove a product image by index.
      description: |
        Remove a single image from `imageUrls` by its zero-based
        index. The underlying R2 object is not deleted; the URL
        just stops being referenced.
      responses:
        "200":
          description: Image removed.
          content:
            application/json:
              schema:
                type: object
                required: [removed, imageUrls]
                properties:
                  removed:
                    type: string
                    format: uri
                  imageUrls:
                    type: array
                    items:
                      type: string
                      format: uri
        "400":
          description: Invalid image index.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Index out of range, or product not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  # =====================================================================
  # Exports
  # =====================================================================

  /api/exports/tenant:
    get:
      operationId: tenantExport
      tags: [Exports]
      summary: Bulk JSON-LD tenant export.
      description: |
        Returns every product, every passport (regardless of
        status), and every referenced category template the
        workspace owns, as one JSON-LD document.

        **Not API-key reachable.** Auth is dashboard JWT (admin
        role) + paying-customer gate — same trust level as
        deleting the company. The pragmatic flow is: open Settings
        → Data export in the dashboard and click "Export tenant".
        Programmatic access requires a session cookie issued by
        `/api/auth/login`.

        Synchronous response with `Content-Disposition:
        attachment; filename="tracepass-tenant-export-<companyId>-<YYYY-MM-DD>.jsonld"`.
        No pagination — all-or-nothing.
      security:
        - SessionCookie: []
      parameters:
        - name: Accept
          in: header
          required: false
          schema:
            type: string
            enum:
              - application/ld+json
              - application/json
            default: application/ld+json
      responses:
        "200":
          description: Tenant export.
          headers:
            Content-Disposition:
              schema:
                type: string
              description: |
                attachment; filename="tracepass-tenant-export-<companyId>-<YYYY-MM-DD>.jsonld"
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/TenantExport"
            application/json:
              schema:
                $ref: "#/components/schemas/TenantExport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Active paid subscription required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "403":
          description: Admin role required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /api/exports/tenant/epcis:
    get:
      operationId: tenantEpcisExport
      tags: [Exports]
      summary: Bulk EPCIS 2.0 tenant export.
      description: |
        Returns every **published** passport the workspace owns,
        merged into a single EPCIS 2.0 `EPCISDocument` (supply-chain,
        service, ownership, and captured events). The EPCIS sibling of
        the JSON-LD tenant export at `GET /api/exports/tenant`.

        Only published passports are included — draft / suspended /
        archived / expired passports are not part of a traceability
        feed. The tenant exporting their own data sees the full event
        set (authority access level).

        **Not API-key reachable.** Auth is dashboard JWT (admin role) +
        paying-customer gate — same privileged-action trust level as
        the JSON-LD tenant export. Programmatic access requires a
        session cookie from `/api/auth/login`; the per-passport v1
        EPCIS routes cover API-key access.

        EPCIS export is included on Starter plans and up; a tenant
        without it gets `403 epcis_export_not_available`.

        Synchronous response with `Content-Disposition: attachment`.
        No pagination — a tenant large enough to need streaming is the
        trigger to add an NDJSON / background-job path (not built today).
      security:
        - SessionCookie: []
      parameters:
        - name: Accept
          in: header
          required: false
          schema:
            type: string
            enum:
              - application/ld+json
              - application/json
            default: application/ld+json
      responses:
        "200":
          description: EPCIS 2.0 document covering every published passport.
          headers:
            Content-Disposition:
              schema:
                type: string
              description: |
                attachment; filename="tracepass-tenant-epcis-<companyId>-<YYYY-MM-DD>.jsonld"
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/EpcisDocument"
            application/json:
              schema:
                $ref: "#/components/schemas/EpcisDocument"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Active paid subscription required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "403":
          description: |
            Admin role required, or EPCIS export not available on the
            tenant's plan (`epcis_export_not_available`, Starter+).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  # =====================================================================
  # EPCIS 2.0
  # =====================================================================
  /api/v1/passports/{id}/epcis:
    get:
      operationId: passportEpcisExport
      tags: [EPCIS]
      summary: Export a passport's events as an EPCIS 2.0 document.
      description: |
        Returns the passport's supply-chain, service, ownership, and
        captured events as a standards-valid EPCIS 2.0 JSON-LD
        `EPCISDocument`.

        EPCIS export is included on Starter plans and up; a tenant
        without it gets `403 epcis_export_not_available`.

        A by-serial sibling exists at
        `GET /api/v1/passports/by-serial/{serial}/epcis` — identical
        response, looked up by your own serial. Counts as one
        passport read.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The passport's TracePass id.
      responses:
        "200":
          description: EPCIS 2.0 document.
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/EpcisDocument"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: EPCIS export not available on this plan.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/epcis/capture:
    post:
      operationId: epcisCapture
      tags: [EPCIS]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Capture EPCIS 2.0 events.
      description: |
        The EPCIS 2.0 Capture interface. Accepts an `EPCISDocument`,
        an `EPCISQueryDocument`, a bare event, or a bare array of
        events as JSON-LD. Each event is validated, its EPCs resolved
        to passports, and stored.

        Responds `202 Accepted` with a `captureJobId` — poll
        `GET /api/v1/epcis/capture/{id}` for the per-event outcome.

        EPCIS capture is a paid add-on; a tenant without it gets
        `403 epcis_capture_not_available`. Counts as one v1 write.
        Idempotent: send an `Idempotency-Key` header, and each event's
        own EPCIS `eventID` is also deduped.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
          description: |
            Stripe-style idempotency key. A replay with the same key
            and body returns the cached 202 without re-capturing.
      requestBody:
        required: true
        content:
          application/ld+json:
            schema:
              $ref: "#/components/schemas/EpcisDocument"
      responses:
        "202":
          description: Capture job accepted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EpcisCaptureResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Active paid subscription required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "403":
          description: EPCIS capture add-on not enabled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /api/v1/epcis/capture/{id}:
    get:
      operationId: epcisCaptureJob
      tags: [EPCIS]
      summary: Poll an EPCIS capture job.
      description: |
        The read side of the EPCIS 2.0 async capture model. Returns
        the job's status, how many events were captured, and any
        per-event errors. Counts as one read.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The capture job id from the capture response.
      responses:
        "200":
          description: Capture job status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EpcisCaptureJob"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: EPCIS capture add-on not enabled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/epcis/events:
    get:
      operationId: epcisQuery
      tags: [EPCIS]
      summary: Query the EPCIS 2.0 event store.
      description: |
        The EPCIS 2.0 Query interface. Accepts the standard EPCIS
        query parameter grammar (`EQ_bizStep`, `GE_eventTime`,
        `LT_eventTime`, `MATCH_epc`, `EQ_bizLocation`, …), passed
        through verbatim to the query node, and returns an
        `EPCISQueryDocument`.

        EPCIS query is part of the EPCIS add-on; without it the
        response is `403 epcis_query_not_available`. When the query
        node is not provisioned for the deployment the response is
        `503 epcis_query_node_unavailable` — query is enabled on
        request. Counts as one read.
      parameters:
        - name: EQ_bizStep
          in: query
          required: false
          schema:
            type: string
          description: |
            EPCIS query filter — match events with this bizStep.
            One of the standard EPCIS query parameters; the full
            grammar is forwarded to the query node.
        - name: GE_eventTime
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Match events at or after this time.
        - name: MATCH_epc
          in: query
          required: false
          schema:
            type: string
          description: Match events referencing this EPC.
      responses:
        "200":
          description: EPCIS query result.
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/EpcisDocument"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Active paid subscription required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "403":
          description: EPCIS query add-on not enabled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: |
            The EPCIS query node is not provisioned for this
            deployment. Export and capture are unaffected.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /api/v1/templates:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: List the DPP category templates (regulatory schemas).
      description: |
        Lists all DPP category templates — the regulatory field
        schemas (battery, textile, electronics, …). Reference data:
        each entry gives the field count, required-field count, and
        the governing EU regulation, so an integrator or AI assistant
        can discover what a compliant passport in a category needs
        before creating products or passports. Not company-scoped —
        templates are global. Authenticated with an API key.
      responses:
        "200":
          description: The available category templates.
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items:
                      type: object
                      properties:
                        category:
                          type: string
                          example: battery
                        categoryLabel:
                          type: object
                          additionalProperties:
                            type: string
                          description: Localized category label, keyed by locale.
                        fieldCount:
                          type: integer
                          example: 94
                        requiredFieldCount:
                          type: integer
                          example: 94
                        version:
                          type: integer
                          example: 1
                        regulation:
                          type: object
                          properties:
                            name:
                              type: string
                              example: EU Battery Regulation
                            number:
                              type: string
                              example: (EU) 2023/1542
                            effectiveDate:
                              type: string
                              format: date-time
                            mandatoryDate:
                              type: string
                              format: date-time
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /api/v1/templates/{category}:
    get:
      operationId: getTemplate
      tags: [Templates]
      summary: Get the full regulatory schema for one DPP category.
      description: |
        Returns the full field schema for one DPP category — every
        field with its key, label, data type, whether it is required,
        access level (public / restricted / authority), enum options,
        validation bounds, and (where known) the regulation article /
        annex that mandates it. This is the "what does a compliant
        battery / textile / … passport actually need" lookup that
        powers compliance-aware integrations and the MCP server's
        compliance-copilot prompts. Authenticated with an API key.
      parameters:
        - name: category
          in: path
          required: true
          schema:
            type: string
            enum: [battery, textile, electronics, construction, steel, chemicals, packaging, furniture, tyres, jewelry, toys, fmcg]
          description: The DPP category key.
      responses:
        "200":
          description: The category's full field schema.
          content:
            application/json:
              schema:
                type: object
                properties:
                  category:
                    type: string
                    example: battery
                  categoryLabel:
                    type: string
                    nullable: true
                  version:
                    type: integer
                  regulation:
                    type: object
                    properties:
                      name: { type: string }
                      number: { type: string }
                      effectiveDate: { type: string, format: date-time }
                      mandatoryDate: { type: string, format: date-time }
                  fieldCount:
                    type: integer
                  requiredFieldCount:
                    type: integer
                  fields:
                    type: array
                    items:
                      type: object
                      properties:
                        key:
                          type: string
                          example: batteryUniqueIdentifier
                        label:
                          type: string
                          nullable: true
                        description:
                          type: string
                          nullable: true
                        dataType:
                          type: string
                          example: string
                        unit:
                          type: string
                          nullable: true
                        required:
                          type: boolean
                        accessLevel:
                          type: string
                          enum: [public, restricted, authority]
                        category:
                          type: string
                          description: Localized sub-section header this field belongs to.
                        enumOptions:
                          type: array
                          nullable: true
                          items:
                            type: object
                            properties:
                              value: { type: string }
                              label: { type: string, nullable: true }
                        validation:
                          type: object
                          properties:
                            minLength: { type: integer, nullable: true }
                            maxLength: { type: integer, nullable: true }
                            min: { type: number, nullable: true }
                            max: { type: number, nullable: true }
                            pattern: { type: string, nullable: true }
                        regulationRef:
                          type: object
                          nullable: true
                          description: |
                            The regulation article/annex that mandates
                            this field, when known. null for platform-
                            derived fields.
                          properties:
                            article:
                              type: string
                              example: Art. 77
                            annex:
                              type: string
                              example: Annex VI
                            description:
                              type: string
                              nullable: true
        "401":
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: No template for that category.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  # =====================================================================
  # OAuth 2.0 authorization server
  # =====================================================================

  /api/oauth/register:
    post:
      operationId: oauthRegister
      tags: [OAuth]
      summary: Register an OAuth client (Dynamic Client Registration).
      description: |
        RFC 7591 Dynamic Client Registration. Public, unauthenticated —
        a hosted client (e.g. an MCP host) self-registers before any
        user is involved. Public clients (PKCE-only) omit
        `token_endpoint_auth_method` or set it to `none` and receive no
        secret; confidential clients set `client_secret_post` and
        receive a `client_secret` once. Rate-limited per IP.
        (Developers can also register apps interactively at Developer →
        OAuth Apps in the dashboard.)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_name, redirect_uris]
              properties:
                client_name:
                  type: string
                  example: Acme ERP Connector
                redirect_uris:
                  type: array
                  items: { type: string, format: uri }
                  example: ["https://acme.example/oauth/callback"]
                scope:
                  type: string
                  example: passports:read passports:write offline_access
                token_endpoint_auth_method:
                  type: string
                  enum: [none, client_secret_post]
                  default: none
      responses:
        "201":
          description: Client registered. `client_secret` present only for confidential clients.
          content:
            application/json:
              schema:
                type: object
                properties:
                  client_id: { type: string, example: oac_3f9a... }
                  client_secret: { type: string, description: Returned ONCE; confidential clients only. }
                  client_name: { type: string }
                  redirect_uris: { type: array, items: { type: string } }
                  scope: { type: string }
                  token_endpoint_auth_method: { type: string }
        "400":
          description: Invalid client metadata or redirect_uri.

  /api/oauth/authorize:
    get:
      operationId: oauthAuthorize
      tags: [OAuth]
      summary: Authorization endpoint (start the auth-code flow).
      description: |
        RFC 6749 §4.1.1. Validates the request, then redirects the
        logged-in user to the consent screen; on approval the browser
        is redirected back to `redirect_uri` with a single-use `code`.
        PKCE is mandatory (`code_challenge_method=S256`). An unknown
        client or unregistered `redirect_uri` returns 400 (no redirect);
        other parameter errors redirect back with `?error=`.
      security: []
      parameters:
        - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: redirect_uri, in: query, required: true, schema: { type: string, format: uri } }
        - { name: scope, in: query, required: false, schema: { type: string }, description: Space-delimited scopes (narrowed to the client's ceiling). }
        - { name: state, in: query, required: false, schema: { type: string } }
        - { name: code_challenge, in: query, required: true, schema: { type: string }, description: base64url SHA-256 of the code_verifier. }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
      responses:
        "302":
          description: Redirect to the consent screen, or back to the client with code / error.
        "400":
          description: Unknown client or unregistered redirect_uri.

  /api/oauth/token:
    post:
      operationId: oauthToken
      tags: [OAuth]
      summary: Token endpoint (exchange code or refresh token).
      description: |
        RFC 6749 §3.2. Form-encoded. `grant_type=authorization_code`
        exchanges a PKCE-bound code (send `code_verifier`) for a 15-min
        access token (+ refresh token when `offline_access` was
        granted). `grant_type=refresh_token` rotates the refresh token.
        Confidential clients also send `client_secret`. Responses are
        `Cache-Control: no-store`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                grant_type: { type: string, enum: [authorization_code, refresh_token] }
                code: { type: string }
                redirect_uri: { type: string, format: uri }
                code_verifier: { type: string }
                refresh_token: { type: string }
                client_id: { type: string }
                client_secret: { type: string, description: Confidential clients only. }
      responses:
        "200":
          description: Access token issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  token_type: { type: string, enum: [Bearer] }
                  expires_in: { type: integer, example: 900 }
                  scope: { type: string }
                  refresh_token: { type: string, description: Present only when offline_access was granted. }
        "400":
          description: invalid_grant / invalid_request / unsupported_grant_type.
        "401":
          description: invalid_client (bad client authentication).

  /api/oauth/revoke:
    post:
      operationId: oauthRevoke
      tags: [OAuth]
      summary: Revoke a token (RFC 7009).
      description: |
        Revoke a refresh token. Always returns 200 even for an unknown
        token (per RFC 7009, don't leak token validity). Confidential
        clients authenticate with `client_secret`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token, client_id]
              properties:
                token: { type: string }
                client_id: { type: string }
                client_secret: { type: string }
      responses:
        "200":
          description: Acknowledged (regardless of whether the token existed).

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: |
        API-key Bearer token — the service-account method, best for
        server-to-server and ERP integrations. Mint at Developer →
        API keys in the dashboard. Keys carry the `tp_` prefix
        followed by an opaque random suffix, and are company-scoped
        (all-or-nothing — they bypass OAuth scope checks).
    OAuth2:
      type: oauth2
      description: |
        OAuth 2.0 with PKCE — the user-authorized method, best for
        third-party apps and AI assistants that act on a user's
        behalf. A user grants specific scopes on a consent screen;
        the resulting access token is limited to the intersection of
        the user's dashboard role and the granted scopes. Register an
        app at Developer → OAuth Apps, or via Dynamic Client
        Registration (RFC 7591) at `/api/oauth/register`. Discovery
        metadata is at `/.well-known/oauth-authorization-server`.
      flows:
        authorizationCode:
          authorizationUrl: https://app.tracepass.eu/api/oauth/authorize
          tokenUrl: https://app.tracepass.eu/api/oauth/token
          refreshUrl: https://app.tracepass.eu/api/oauth/token
          scopes:
            passports:read: View products and digital product passports
            passports:write: Create, update, and publish passports
            documents:read: Read uploaded documents and extractions
            documents:write: Upload documents and run AI extractions
            suppliers:read: View supplier requests
            suppliers:write: Create and manage supplier requests
            offline_access: Issue a refresh token (stay connected)
    SessionCookie:
      type: apiKey
      in: cookie
      name: tp_session
      description: |
        Dashboard session cookie issued by `/api/auth/login`.
        Used only for the bulk JSON-LD tenant export, which is
        deliberately not exposed via API key.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        maxLength: 255
      description: |
        UUID v4 (or any opaque string ≤ 255 chars) per logical
        operation. The platform stores the first response for 24
        hours and replays it on the same key + same workspace.
        Reusing the same key with a different request body returns
        422.

    PassportId:
      name: id
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/ObjectId"

    ProductId:
      name: id
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/ObjectId"

    PartyRole:
      name: role
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/PartyRoleEnum"

    FieldKey:
      name: key
      in: path
      required: true
      schema:
        type: string
      description: |
        Field key (camelCase) as defined on the passport's
        category template — e.g. `nominalVoltage`,
        `batteryChemistry`, `recycledContentCobalt`.

    Serial:
      name: serial
      in: path
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 100
      description: |
        Passport serial number, as supplied by the customer at
        passport-create time. Used by the by-serial alternate
        addressing forms.

    GtinQuery:
      name: gtin
      in: query
      required: false
      schema:
        type: string
      description: |
        Optional disambiguator for the by-serial routes. A serial is
        unique only WITHIN a GTIN, so if the same serial exists on
        passports under two different GTINs in your account, a
        serial-only lookup is ambiguous and returns 409. Pass the
        `gtin` to resolve precisely — `(account, gtin, serial)` is
        unique. Omit it when your serials are unique account-wide.

    Page:
      name: page
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        default: 1

    Limit:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

  schemas:
    ObjectId:
      type: string
      pattern: "^[a-f0-9]{24}$"
      example: "6650a1b2c3d4e5f6a7b8c9d0"
      description: MongoDB ObjectId hex string (24 chars).

    GTIN:
      type: string
      pattern: "^[0-9]{14}$"
      example: "04012345000015"
      description: GS1 GTIN-14 with valid check digit.

    GLN:
      type: string
      pattern: "^[0-9]{13}$"
      example: "4012345678901"
      description: GS1 Global Location Number (13 digits, mod-10).

    PartyRoleEnum:
      type: string
      enum:
        - manufacturer
        - importer
        - authorisedRepresentative
        - distributor
        - recycler
        - producerResponsibilityOrg

    Party:
      type: object
      required: [legalName]
      description: |
        Economic-operator party. At least one of `gln` or
        `legacyOperatorId` must be set — without an identifier the
        Party doesn't actually identify anyone.
      properties:
        gln:
          $ref: "#/components/schemas/GLN"
        legalName:
          type: string
          minLength: 1
          maxLength: 200
          example: "EuroBat Recyclers GmbH"
        country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: ISO 3166-1 alpha-2 country code.
          example: "DE"
        legacyOperatorId:
          type: string
          minLength: 1
          maxLength: 120
          description: |
            Free-text fallback identifier (VAT, EORI, supplier
            code) for parties without a GLN.
          example: "DE123456789"
        url:
          type: string
          format: uri
          maxLength: 500
        status:
          type: string
          readOnly: true
          description: Server-side status (active, etc.). Returned but ignored on write.

    PartyMap:
      type: object
      additionalProperties:
        $ref: "#/components/schemas/Party"
      description: |
        Map of role → Party. Each role appears at most once.

    GS1Block:
      type: object
      required: [gtin, serialNumber]
      properties:
        gtin:
          $ref: "#/components/schemas/GTIN"
        serialNumber:
          type: string
          minLength: 1
          maxLength: 100
          example: "BP-48V-100-000001"
        digitalLinkUri:
          type: string
          format: uri
          readOnly: true
          example: "https://id.tracepass.eu/p/01/04012345000015/21/BP-48V-100-000001"

    PassportStatus:
      type: string
      enum:
        - draft
        - in_review
        - approved
        - published
        - suspended
        - expired
        - archived

    PassportField:
      type: object
      properties:
        value: {}
        source:
          type: string
          enum: [manual, ai_suggested, ai_approved, reference_db, supplier, system]
        status:
          type: string
        accessLevel:
          type: string
        sourceLocale:
          type: string
        translations:
          type: object
          additionalProperties:
            type: object
            properties:
              value: {}
              source:
                type: string
              generatedAt:
                type: string
                format: date-time
        lastUpdatedAt:
          type: string
          format: date-time
        lastUpdatedBy:
          type: string

    ComplianceFinding:
      type: object
      description: One compliance gap or warning, with regulation provenance.
      properties:
        type:
          type: string
          enum:
            - missing_field
            - unapproved_field
            - missing_party
            - invalid_format
            - conditional_missing
            - unverifiable_conditional
        severity:
          type: string
          enum: [critical, warning]
        target:
          type: string
          description: Field key or party role this finding concerns.
        regulation:
          type: string
          description: Regulation number, e.g. "(EU) 2023/1542".
        article:
          type: string
          description: Article / annex citation, e.g. "Art. 77".
        ruleId:
          type: string
          description: Rule id from the verified spec (e.g. "BAT-1", "CC-1").
        why:
          type: string
          description: One-line explanation of why this matters.
        fix:
          type: string
          description: One-line actionable next step.
      required: [type, severity, why]
    RegistryReadinessResult:
      type: object
      description: >
        Whether a passport would pass the EU DPP Registry's formal
        submission gate. Distinct from the substantive ComplianceVerdict.
      properties:
        ready:
          type: boolean
          description: >
            True only when there are zero critical findings — i.e. the
            passport would pass the registry's formal gate.
        findings:
          type: array
          description: >
            Formal-gate findings, each with a REG-READY-* ruleId
            (REG-READY-MANDATORY / REG-READY-FORMAT / REG-READY-LINK /
            REG-READY-SCOPE). Critical findings block readiness; warnings
            do not.
          items:
            $ref: "#/components/schemas/ComplianceFinding"
      required: [ready, findings]
    ComplianceVerdict:
      type: object
      description: Three-tier compliance verdict for a passport.
      properties:
        verdict:
          type: string
          enum: [compliant, compliant_with_warnings, incomplete]
        category:
          type: string
        conditionalCoverage:
          type: string
          enum: [evaluated, static-only]
          description: |
            Whether per-category conditional rules ran (`evaluated`) or
            no binding conditional exists yet for this category
            (`static-only`). Absence of conditional findings is only
            meaningful alongside this flag.
        critical:
          type: array
          description: Findings that force the `incomplete` verdict.
          items:
            $ref: "#/components/schemas/ComplianceFinding"
        warnings:
          type: array
          description: Findings that downgrade to `compliant_with_warnings`.
          items:
            $ref: "#/components/schemas/ComplianceFinding"
        checkedRules:
          type: array
          description: Rule ids that actually ran (static checks + each conditional rule).
          items:
            type: string
        completionPercentage:
          type: integer
          minimum: 0
          maximum: 100
      required:
        - verdict
        - category
        - conditionalCoverage
        - critical
        - warnings
        - checkedRules
        - completionPercentage
    Passport:
      type: object
      properties:
        _id:
          $ref: "#/components/schemas/ObjectId"
        companyId:
          $ref: "#/components/schemas/ObjectId"
        productId:
          $ref: "#/components/schemas/ObjectId"
        templateId:
          $ref: "#/components/schemas/ObjectId"
        gs1:
          $ref: "#/components/schemas/GS1Block"
        status:
          $ref: "#/components/schemas/PassportStatus"
        completionPercentage:
          type: integer
          minimum: 0
          maximum: 100
        fieldCounts:
          type: object
          properties:
            total:
              type: integer
            empty:
              type: integer
            approved:
              type: integer
            pendingReview:
              type: integer
            flagged:
              type: integer
        fields:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/PassportField"
        parties:
          $ref: "#/components/schemas/PartyMap"
        publishedAt:
          type: string
          format: date-time
        suspendedAt:
          type: string
          format: date-time
        suspensionReason:
          type: string
        archivedAt:
          type: string
          format: date-time
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    PassportSummary:
      type: object
      description: Trimmed passport shape returned in list responses.
      properties:
        _id:
          $ref: "#/components/schemas/ObjectId"
        productId:
          $ref: "#/components/schemas/ObjectId"
        gs1:
          $ref: "#/components/schemas/GS1Block"
        status:
          $ref: "#/components/schemas/PassportStatus"
        completionPercentage:
          type: integer
        publishedAt:
          type: string
          format: date-time

    Product:
      type: object
      properties:
        _id:
          $ref: "#/components/schemas/ObjectId"
        name:
          type: string
        model:
          type: string
        category:
          type: string
        templateId:
          $ref: "#/components/schemas/ObjectId"
        defaultFieldValues:
          type: object
          additionalProperties: {}
        imageUrls:
          type: array
          items:
            type: string
            format: uri
            maxLength: 2048
          maxItems: 20
        passportCount:
          type: integer
        status:
          type: string
          enum: [active, archived]
        sourceLocale:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreatePassportInput:
      type: object
      required: [productId, gs1]
      properties:
        productId:
          $ref: "#/components/schemas/ObjectId"
        gs1:
          type: object
          required: [gtin, serialNumber]
          properties:
            gtin:
              $ref: "#/components/schemas/GTIN"
            serialNumber:
              type: string
              minLength: 1
              maxLength: 100
        parties:
          $ref: "#/components/schemas/PartyMap"
        confirmOverage:
          type: boolean
          description: |
            Accept the per-passport overage charge when the plan's
            `maxDpps` quota is already exhausted. Required only
            after a 402 response on a previous attempt.

    UpdateFieldInput:
      type: object
      required: [value]
      properties:
        value:
          description: |
            New value, of the type the field's template entry
            expects. Number fields take a raw number, not a string.
        source:
          type: string
          enum:
            - manual
            - ai_suggested
            - ai_approved
            - reference_db
            - supplier
            - system
          description: |
            Tag the origin of the value. Default `manual`.
            `ai_suggested` and `supplier` land in the review queue;
            everything else writes as approved.
        sourceLocale:
          type: string
          description: |
            ISO 639-1 locale of the value. One of the 24 EU
            locales. Defaults to the passport's `sourceLocale`.

    CreateProductInput:
      type: object
      required: [name, model, category]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        model:
          type: string
          minLength: 1
          maxLength: 100
          description: Unique within the workspace.
        category:
          type: string
          description: |
            Seeded category slug — `batteries`, `textiles`,
            `jewelry`, `electronics`, `furniture`, `chemicals`,
            `packaging`, `toys`, `fmcg`, `tyres`, `iron-steel`,
            `construction`.
        description:
          type: string
          maxLength: 2000
        defaultFieldValues:
          type: object
          additionalProperties: {}
        imageUrls:
          type: array
          items:
            type: string
            format: uri
            maxLength: 2048
          maxItems: 20
        sourceLocale:
          type: string

    UpdateProductInput:
      type: object
      description: |
        Send only the keys you want to change. `imageUrls`
        REPLACES the existing array. `description: null` clears
        the description.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        model:
          type: string
          minLength: 1
          maxLength: 100
        description:
          oneOf:
            - type: string
              maxLength: 2000
            - type: "null"
        defaultFieldValues:
          type: object
          additionalProperties: {}
        imageUrls:
          type: array
          items:
            type: string
            format: uri
            maxLength: 2048
          maxItems: 20
        status:
          type: string
          enum: [active, archived]
        sourceLocale:
          type: string

    UploadImageResponse:
      type: object
      required: [imageUrl, imageUrls]
      properties:
        imageUrl:
          type: string
          format: uri
        imageUrls:
          type: array
          items:
            type: string
            format: uri

    PaginationEnvelope:
      type: object
      required: [items, total, page, limit, totalPages]
      properties:
        total:
          type: integer
        page:
          type: integer
        limit:
          type: integer
        totalPages:
          type: integer

    PaginatedPassports:
      allOf:
        - $ref: "#/components/schemas/PaginationEnvelope"
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/PassportSummary"

    PaginatedProducts:
      allOf:
        - $ref: "#/components/schemas/PaginationEnvelope"
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/Product"

    BatchResultItem:
      oneOf:
        - type: object
          required: [index, status, data]
          properties:
            index:
              type: integer
            status:
              type: string
              enum: [created]
            data:
              $ref: "#/components/schemas/Passport"
        - type: object
          required: [index, status, error]
          properties:
            index:
              type: integer
            status:
              type: string
              enum: [error]
            error:
              type: string

    BatchSummary:
      type: object
      required: [created, errors, total]
      properties:
        created:
          type: integer
        errors:
          type: integer
        total:
          type: integer

    BatchPassportsResponse:
      type: object
      required: [results, summary]
      properties:
        results:
          type: array
          items:
            $ref: "#/components/schemas/BatchResultItem"
        summary:
          $ref: "#/components/schemas/BatchSummary"

    BatchCreatePassportsInput:
      type: object
      required: [passports]
      description: |
        Top-level `confirmOverage` is the BATCH-LEVEL flag — set
        it to accept the per-passport overage charge if the entire
        batch would exceed the workspace's `maxDpps` quota. Wins
        over any per-item `confirmOverage` (the single-create
        flag, which is also accepted on each item but only relevant
        if a single item would exhaust the budget on its own).
      properties:
        passports:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: "#/components/schemas/CreatePassportInput"
        confirmOverage:
          type: boolean

    BatchProductResultItem:
      oneOf:
        - type: object
          required: [index, status, data]
          properties:
            index:
              type: integer
            status:
              type: string
              enum: [created]
            data:
              $ref: "#/components/schemas/Product"
        - type: object
          required: [index, status, error]
          properties:
            index:
              type: integer
            status:
              type: string
              enum: [error]
            error:
              type: string

    BatchProductsResponse:
      type: object
      required: [results, summary]
      properties:
        results:
          type: array
          items:
            $ref: "#/components/schemas/BatchProductResultItem"
        summary:
          $ref: "#/components/schemas/BatchSummary"

    QrJsonEnvelope:
      type: object
      required: [passportId, gtin, serial, publicUrl, status, qrSvg, qrPngDataUri, colors]
      description: |
        `format=json` response from the QR endpoints. Carries the
        SVG markup, a base64-encoded PNG data URI, and the
        encoded URL + passport status — useful for clients that
        want both renderings in one round-trip plus the metadata
        the QR encodes.
      properties:
        passportId:
          $ref: "#/components/schemas/ObjectId"
        gtin:
          $ref: "#/components/schemas/GTIN"
        serial:
          type: string
        publicUrl:
          type: string
          format: uri
        status:
          $ref: "#/components/schemas/PassportStatus"
        qrSvg:
          type: string
          description: Inline `<svg>` markup, ready to embed.
        qrPngDataUri:
          type: string
          description: "data:image/png;base64,..."
        colors:
          type: object
          properties:
            foreground:
              type: string
            background:
              type: string

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: string
        details:
          type: object

    OverageRequiredBody:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [overage_required]
        planLimit:
          type: integer
        currentUsage:
          type: integer
        requested:
          type: integer
        extraPriceCents:
          type: integer
        message:
          type: string

    TenantExport:
      type: object
      description: Bulk JSON-LD export envelope.
      properties:
        "@context":
          type: string
          format: uri
        "@type":
          type: string
          enum: [TenantExport]
        exportedAt:
          type: string
          format: date-time
        company:
          type: object
          properties:
            _id:
              $ref: "#/components/schemas/ObjectId"
            name:
              type: string
            country:
              type: string
        counts:
          type: object
          properties:
            products:
              type: integer
            passports:
              type: integer
            templates:
              type: integer
        products:
          type: array
          items:
            $ref: "#/components/schemas/Product"
        passports:
          type: array
          items:
            $ref: "#/components/schemas/Passport"
        templates:
          type: array
          items:
            type: object
            properties:
              id:
                $ref: "#/components/schemas/ObjectId"
              category:
                type: string
              version:
                type: string

    EpcisDocument:
      type: object
      description: |
        A GS1 EPCIS 2.0 JSON-LD document. The shape is defined by the
        GS1 EPCIS 2.0 standard, not by TracePass — only the top-level
        envelope is sketched here.
      properties:
        "@context":
          description: EPCIS 2.0 JSON-LD context (string or array).
        type:
          type: string
          enum: [EPCISDocument, EPCISQueryDocument]
        schemaVersion:
          type: string
          example: "2.0"
        creationDate:
          type: string
          format: date-time
        epcisBody:
          type: object
          description: |
            Carries `eventList` (an array of EPCIS events) for an
            EPCISDocument, or `queryResults` for an EPCISQueryDocument.

    EpcisCaptureResult:
      type: object
      description: The 202 response from the EPCIS Capture interface.
      properties:
        captureJobId:
          type: string
          description: Poll GET /api/v1/epcis/capture/{id} with this id.
        status:
          type: string
          enum: [success, failed]
        eventCount:
          type: integer
          description: Events found in the submitted payload.
        capturedCount:
          type: integer
          description: Events actually stored (after idempotency dedup).
        errors:
          type: array
          items:
            type: object
            properties:
              index:
                type: integer
              message:
                type: string

    EpcisCaptureJob:
      type: object
      description: An EPCIS capture-job record, returned by the poll endpoint.
      properties:
        captureJobId:
          type: string
        status:
          type: string
          enum: [running, success, failed]
        eventCount:
          type: integer
        capturedCount:
          type: integer
        errors:
          type: array
          items:
            type: object
            properties:
              index:
                type: integer
              message:
                type: string
        createdAt:
          type: string
          format: date-time
        finishedAt:
          type: string
          format: date-time

  responses:
    BadRequest:
      description: Validation error or malformed request.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    Forbidden:
      description: Plan-gate or workspace permission denied.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    NotFound:
      description: Resource doesn't exist or is in a different workspace.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    Conflict:
      description: |
        Conflict — duplicate model on a product, GTIN registered
        to a different tenant, serial collision within a GTIN.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    AmbiguousSerial:
      description: |
        The serial maps to more than one passport in your account
        (serials are unique per GTIN, not per account). The request
        is rejected rather than acting on the wrong passport. Pass
        `?gtin=<gtin>` to disambiguate, or address the passport by
        its id instead. `count` is how many passports the serial
        matched.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                enum: [ambiguous_serial]
              message:
                type: string
              count:
                type: integer
            required: [error, message, count]

    IdempotencyConflict:
      description: |
        Idempotency-Key reused with a different request body. Use
        a new key for the new request, or reuse the original body.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    OverageRequired:
      description: |
        DPP quota exhausted; retry with `confirmOverage: true` to
        accept the per-passport charge.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OverageRequiredBody"

    RateLimit:
      description: |
        Daily v1 write or passport-read budget exhausted. Resets
        at 00:00 UTC.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
