# 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 (a 13-digit
#     EAN is accepted on input and stored as GTIN-14); 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.

        **Second-life batteries (Art. 77(7) of Regulation (EU) 2023/1542).**
        A battery that is repurposed, remanufactured or prepared for reuse
        gets a new passport linked to the original one(s). Send a `lineage`
        block naming each predecessor, either by `internalPassportId` (one
        of your own passports) or by its resolvable `identifier`. The
        platform derives `batteryStatus` from the triggers, stores the block
        immutably, and adds a `successors` link on your own predecessor
        passports. A rule violation returns 422 with a `code`-style `error`
        (see `PassportCreateUnprocessable`).
      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/PassportCreateUnprocessable"
        "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:
                $ref: "#/components/schemas/PaginatedPassports"
        "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 (battery Reg (EU)
          2023/1542 Art. 77(1) scope, a warning until the obligation
          applies on 18 February 2027 and critical from then, plus
          Annex XIII field-applicability; construction Reg (EU)
          2024/3110; CE-marking consistency),
          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.

        `byRegulation` additionally groups those same findings by the
        regulation that raised them, worst first, so a single REACH gap
        does not read as "the DPP obligation is unmet" when it is fully
        met. It is derived from the findings above and never contradicts
        `verdict` — the rolled-up verdict stays authoritative for
        publishing. A regulation with no findings is absent from the
        array rather than reported as passing.

        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.

        Five 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);
        - **`REG-READY-GRANULARITY`** — the passport carries a serial
          number, since batteries register at item-level granularity
          (a missing serial is a block);
        - **`REG-READY-COMMODITY`** — the product's commodity code
          (CN/TARIC) is well-formed where the category carries one; for
          batteries, which carry no commodity code, this is an advisory
          warning only, never 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}/condition-flags:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    get:
      operationId: getConditionFlags
      tags: [Passports]
      summary: Read the condition-classification flags for a passport.
      description: |
        Returns the resolved condition profile for a passport —
        `Record<flagKey, { value, status, source }>`. Condition flags
        are reviewer-approved yes/no facts (e.g. `hasBMS`,
        `rechargeable`, `externalStorageOnly`, `isStationaryBess` for
        batteries) that gate conditional legal duties: an `approved`
        flag whose gate applies makes the gated field required, and an
        empty such field is a **hard publish block** (`conditional_missing`).
        Absent or `pending_review` flags never block publishing.

        Valid flag keys are category-specific (battery: `hasBMS`,
        `rechargeable`, `externalStorageOnly`, `isStationaryBess`).
        Categories with no flags in the registry return an empty profile.

        The `audit` array is stripped from the v1 response; use the
        dashboard to inspect per-flag history.

        Read-only. Counts as one v1 passport read against the daily cap.
      responses:
        "200":
          description: Condition profile.
          content:
            application/json:
              schema:
                type: object
                required: [passportId, conditionProfile]
                properties:
                  passportId:
                    $ref: "#/components/schemas/ObjectId"
                  conditionProfile:
                    $ref: "#/components/schemas/ConditionProfile"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"
    patch:
      operationId: setConditionFlags
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Set or clear condition-classification flags on a passport.
      description: |
        Write one or more condition flags. The body is
        `Record<flagKey, boolean | null>` — `true`/`false` set the flag,
        `null` clears it. Only keys registered for the passport's category
        are accepted (unknown keys return 400).

        **Warning:** approving a flag can make previously optional fields
        required. If the passport is already published, enabling a flag
        whose gate applies will raise a `conditional_missing` finding on the
        next compliance check — fix those fields before or immediately after
        setting the flag or the passport will be unrepublishable until they
        are filled.

        v1 writes default to `status: "approved"` (trusted integration).
        Each flag change is audited with `"via API key <prefix>"` in the
        flag's internal audit trail.

        Supports `Idempotency-Key`. Counts as one v1 write.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConditionFlagsPatch"
            example:
              hasBMS: true
              rechargeable: false
      responses:
        "200":
          description: Condition profile after the update.
          content:
            application/json:
              schema:
                type: object
                required: [passportId, conditionProfile, version]
                properties:
                  passportId:
                    $ref: "#/components/schemas/ObjectId"
                  conditionProfile:
                    $ref: "#/components/schemas/ConditionProfile"
                  version:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/snapshots:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    get:
      operationId: listPassportSnapshots
      tags: [Passports]
      summary: List immutability snapshots for a passport.
      description: |
        Returns a paginated list of **immutability snapshots** for a
        passport, newest first. Each entry re-verifies the stored
        content hash so an at-rest tamper is visible (`hashValid: false`)
        rather than silently trusted.

        A snapshot is written on publish and after **every change** to a
        non-draft (published, suspended, expired or archived) passport —
        field values, statuses, translations, parties, status transitions,
        supplier answers, AI writes, restores — whenever what the passport
        asserts actually changed (EN 18221:2026 4.2). A nightly sweep
        catches changes made outside the API. Passports that predated
        snapshots got a `baseline` snapshot on the first sweep.

        **Point in time:** `?at=<ISO 8601>` returns the single snapshot
        valid at that instant — the `SnapshotDetail` shape plus
        `validFrom` and `validUntil` (null while current) — instead of
        the list. 404 when `at` predates the passport's first snapshot.

        Read-only; counts as one v1 passport read against the daily cap.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: at
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Return the snapshot valid at this instant instead of the list.
      responses:
        "200":
          description: Paginated snapshot list, or with `?at=` the snapshot valid then (`SnapshotDetail` plus `validFrom` / `validUntil`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SnapshotList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/snapshots/{snapshotId}:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - name: snapshotId
        in: path
        required: true
        schema:
          type: string
          pattern: "^[0-9a-f]{24}$"
        description: The id of the snapshot.
    get:
      operationId: getPassportSnapshot
      tags: [Passports]
      summary: Full archival record of one snapshot.
      description: |
        Returns the complete authority-level **JSON-LD** the passport
        asserted at snapshot time, plus a re-verification of the content
        hash. A tampered at-rest document surfaces as `hashValid: false`
        rather than being silently trusted.

        The same tenant-scoping and 404-for-foreign-ids semantics as
        the parent list endpoint apply: an unknown snapshot id and a
        snapshot that belongs to a different passport or company both
        return 404.

        Read-only; counts as one v1 passport read against the daily cap.
      responses:
        "200":
          description: Snapshot detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SnapshotDetail"
        "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 naming the
        credential (`via API key <prefix>` or `via OAuth app <client>`)
        and, when the `X-Source` header is set, the calling client
        (e.g. `(mcp)`). The channel is recorded only; it never changes
        the status.

        Status: an API key writes `approved`. An OAuth token writes with
        its user's dashboard rights, so a role below admin lands
        `pending_review`. A value sent with `source: "ai_suggested"`
        always lands `pending_review`, and is refused (400) on a field
        that only the economic operator may state or that must be
        measured (e.g. battery `stateOfHealth`). Send such a value with
        another source if your organisation is making that statement.

        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`,
        `legacyOperatorId`, or `operatorIdentifier` must be set in
        the body — without an identifier the Party doesn't actually
        identify anyone. If both `gln` and `operatorIdentifier`
        (scheme `gln`) are set they must match (400). An
        `operatorIdentifier` of scheme `gln` also fills `gln`.

        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.

        Each item may carry its own `lineage` block (second-life battery
        successors). A lineage rule violation fails that item only: its
        result has `status: "error"` and `error` starting with the rule code,
        e.g. `duplicate_predecessor: …`.
      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: symbology
          in: query
          required: false
          schema:
            type: string
            enum: [qr, datamatrix]
            default: qr
          description: |
            The 2D symbology to render. EN 18220:2026 permits QR Code
            (ISO/IEC 18004) and Data Matrix (ISO/IEC 16022); both encode the
            same URL. Data Matrix suits small parts and direct part marking.
            With `datamatrix`, an 8-char `backgroundColor` renders opaque
            unless its alpha is `00` (fully transparent).
        - 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 or symbology 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: symbology
          in: query
          required: false
          schema:
            type: string
            enum: [qr, datamatrix]
            default: qr
          description: |
            The 2D symbology to render. EN 18220:2026 permits QR Code
            (ISO/IEC 18004) and Data Matrix (ISO/IEC 16022); both encode the
            same URL. Data Matrix suits small parts and direct part marking.
            With `datamatrix`, an 8-char `backgroundColor` renders opaque
            unless its alpha is `00` (fully transparent).
        - 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"

  /api/v1/passports/by-serial/{serial}/condition-flags:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/GtinQuery"
    get:
      operationId: getConditionFlagsBySerial
      tags: [Passports]
      summary: Read condition-classification flags (by serial).
      description: |
        Same contract as
        `GET /api/v1/passports/{id}/condition-flags`, addressed
        by your own serial number instead of our internal id.
        Pass `?gtin=<gtin>` if a serial is not unique within
        your account (same disambiguation rule as all other
        by-serial routes; 409 `ambiguous_serial` without it).
      responses:
        "200":
          description: Condition profile.
          content:
            application/json:
              schema:
                type: object
                required: [passportId, conditionProfile]
                properties:
                  passportId:
                    $ref: "#/components/schemas/ObjectId"
                  conditionProfile:
                    $ref: "#/components/schemas/ConditionProfile"
        "400":
          description: Invalid serial.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/AmbiguousSerial"
        "429":
          $ref: "#/components/responses/RateLimit"
    patch:
      operationId: setConditionFlagsBySerial
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Set or clear condition flags (by serial).
      description: |
        Same contract as
        `PATCH /api/v1/passports/{id}/condition-flags`, addressed
        by your own serial number. See the by-id operation for the
        full warning about flag approval making fields required and
        potentially blocking publishing.

        Supports `Idempotency-Key`. Counts as one v1 write.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConditionFlagsPatch"
            example:
              hasBMS: true
              rechargeable: false
      responses:
        "200":
          description: Condition profile after the update.
          content:
            application/json:
              schema:
                type: object
                required: [passportId, conditionProfile, version]
                properties:
                  passportId:
                    $ref: "#/components/schemas/ObjectId"
                  conditionProfile:
                    $ref: "#/components/schemas/ConditionProfile"
                  version:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/AmbiguousSerial"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

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

  /api/v1/passports/{id}/measurements:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    post: &measurementsPost
      operationId: captureMeasurements
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Push battery measurements (state of health, cycles, …).
      description: |
        Battery passports only (Art. 77 / Annex XIII 4 of Regulation (EU)
        2023/1542): push over-life measurements from your own equipment,
        e.g. a BMS reporting state of health. Up to 500 per call.

        Every measurement is stored. The newest one per field (by
        `measuredAt`) also becomes the passport's current value for that
        field, and sets `dynamicDataAsOf`. An older measurement arriving
        late is kept in the history but does not overwrite a newer value.
        An `externalId` makes a measurement idempotent: a repeat is counted
        in `duplicates`, not stored or metered again.

        Accepted `fieldKey`s are the Annex XIII point 4 use-data fields
        (state of health, fades, cycle counts, dynamic values, state of
        charge, negative events, temperature history). `batteryStatus` is not
        a measurement: lifecycle status changes go through the dashboard,
        and a second-life battery gets a successor passport (`lineage`). A
        field the Regulation keeps off this battery category (e.g. state of
        certified energy on an LMT battery) is refused with
        `field_not_applicable`. The passport must be published.

        Measurements are metered per calendar period against the plan's
        monthly allowance (`maxMeasurementsPerMonth`), not against the daily
        v1 write budget. Past the allowance, paid plans keep accepting and
        counting them at no charge; the Free plan stops at its allowance.
        Viewing a passport is never metered.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MeasurementsInput"
            example:
              measurements:
                - fieldKey: stateOfHealth
                  value: 96.4
                  measuredAt: "2027-03-01T06:00:00Z"
                  externalId: "bms-7781-2027-03-01"
                - fieldKey: numberOfFullEquivalentChargingCycles
                  value: 112
                  measuredAt: "2027-03-01T06:00:00Z"
      responses:
        "200":
          description: Measurements accepted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeasurementsCaptureResult"
        "400":
          description: |
            `invalid_field_key` (not an accepted measurement key, including
            `batteryStatus`), `invalid_value` (wrong type or out of the field's
            range) or `measuredAt_in_future`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: |
            `not_a_battery`, `passport_not_active` (not published),
            `field_not_applicable`, or an Idempotency-Key reused with a
            different body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"
    get: &measurementsList
      operationId: listMeasurements
      tags: [Passports]
      summary: List a passport's measurement history.
      description: |
        Newest first. Filter by `fieldKey` and a `from`/`to` window on
        `measuredAt`; page with `cursor` (the previous page's `nextCursor`).
      parameters:
        - name: fieldKey
          in: query
          required: false
          schema:
            type: string
        - name: from
          in: query
          required: false
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: false
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: A page of measurements.
          content:
            application/json:
              schema:
                type: object
                required: [data, hasMore, nextCursor]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/PassportMeasurement"
                  hasMore:
                    type: boolean
                  nextCursor:
                    type: [string, "null"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: "`not_a_battery`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/measurements/latest:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    get: &measurementsLatest
      operationId: getLatestMeasurements
      tags: [Passports]
      summary: Latest measurement per field.
      description: |
        One entry per accepted measurement key: the newest measurement, or
        `null` when none has been received.
      responses:
        "200":
          description: Latest values.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    additionalProperties:
                      oneOf:
                        - $ref: "#/components/schemas/LatestMeasurement"
                        - type: "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: "`not_a_battery`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/measurements:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/GtinQuery"
    post:
      <<: *measurementsPost
      operationId: captureMeasurementsBySerial
      summary: Push battery measurements (by serial).
    get:
      <<: *measurementsList
      operationId: listMeasurementsBySerial
      summary: List a passport's measurement history (by serial).

  /api/v1/passports/by-serial/{serial}/measurements/latest:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/GtinQuery"
    get:
      <<: *measurementsLatest
      operationId: getLatestMeasurementsBySerial
      summary: Latest measurement per field (by serial).

  /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:
                $ref: "#/components/schemas/PaginatedProducts"
        "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, or the file's bytes don't match the declared image type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: Product already has 20 images (max), or the virus scanner found malware in the file.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"
        "503":
          description: The virus scanner is briefly unavailable; nothing was stored. Retry shortly.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /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: Free plan. The export is available on every paid plan, whatever the billing status.
          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"
        "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/exports/tenant/registry-batch:
    get:
      operationId: tenantRegistryBatchExport
      tags: [Exports]
      summary: Batch registration file for the EU DPP Registry.
      description: |
        Builds the batch file an economic operator uploads to the EU
        DPP Registry, as XML. Sibling of the JSON-LD and EPCIS tenant
        exports.

        **Publishing a passport does not register it.** The duty to
        register sits with the economic operator placing the product on
        the market (Reg (EU) 2023/1542), and TracePass has no registry
        client. This endpoint hands over the payload already in the
        shape the registry accepts, so the step the operator owes is an
        upload rather than a data-assembly exercise.

        **Batteries only** — BATTERIES is the only product group the
        registry offers, so passports in other categories are excluded
        rather than exported into a group the registry would reject.
        The response reports how many were left out, so the caller is
        not guessing.

        **Published passports only:** an unpublished passport's link
        does not resolve, and the registry probes it mechanically.

        At most 100 items per file.

        **Not API-key reachable.** Auth is dashboard JWT with the admin
        role — the same privileged-action gate as the other tenant
        exports. There is deliberately **no plan gate**: this is the
        customer's own regulatory obligation, and putting a paywall
        between them and a duty they cannot otherwise discharge is not
        a trade worth making.

        Synchronous response with `Content-Disposition: attachment`.
      security:
        - SessionCookie: []
      responses:
        "200":
          description: The batch registration file.
          content:
            application/xml:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin role required.
        "404":
          description: Workspace not found.
  /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: Billing not in good standing (past due, unpaid or cancelled), or the plan's monthly EPCIS event quota is reached (epcis_events_hard_block / epcis_events_overage_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: |
        Search your captured supply-chain events using EPCIS 2.0
        query parameters, returned as an `EPCISQueryDocument`.

        Eight parameters are supported: `EQ_bizStep`,
        `EQ_disposition`, `EQ_eventType`, `MATCH_epc`,
        `EQ_bizLocation`, `EQ_readPoint`, `GE_eventTime` and
        `LT_eventTime`. `EQ_bizStep`, `EQ_disposition` and `MATCH_epc`
        accept `|`-separated values to match any of several; the other
        parameters take a single value. Matching is exact against the
        value as captured (a short-form `shipping` does not match the
        CBV URI form, and vice versa). Any other parameter returns `400
        epcis_query_invalid` rather than being silently ignored, so a
        typo fails loudly instead of widening your result set.

        Results are newest-first and only ever include events your own
        workspace captured and approved. A response caps at 1000
        events; if there were more, `X-TracePass-Result-Truncated:
        true` is set — narrow the window with `GE_eventTime` /
        `LT_eventTime` to page through.

        A workspace with the `epcisCaptureEnabled` flag turned off
        gets `403 epcis_query_not_available`. Where query is not
        provisioned for the deployment the response is `503
        epcis_query_node_unavailable`. Counts as one read.
      parameters:
        - name: EQ_bizStep
          in: query
          required: false
          schema:
            type: string
          description: |
            Match events with this bizStep. `|`-separate to match any
            of several, e.g. `shipping|receiving`.
        - 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: Billing not in good standing (past due, unpaid or cancelled).
          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.

        **No authentication required.** This is regulatory reference
        data — the identical field definitions ship as the Apache-2.0
        `@tracepass/dpp-schemas` npm package — so it is readable
        without a key. Bounded by an IP rate limit (60 requests/min).
      security: []
      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: 124
                        requiredFieldCount:
                          type: integer
                          description: >-
                            Category-agnostic required count. Where a template carries
                            `requiredFieldCountByCategory`, prefer that map — for battery
                            this flat number matches none of the per-category totals.
                          example: 38
                        requiredFieldCountByCategory:
                          type: [object, "null"]
                          description: >-
                            Required-field totals per sub-category, present only where
                            required-ness varies: battery (keyed by the categories that owe a
                            passport under Art. 77(1) of Reg (EU) 2023/1542 — `EV`, `LMT`,
                            `industrial_gt_2kwh`), detergents (`consumer`,
                            `industrial_institutional`) and fmcg (`productSubcategory`). `portable`, `SLI`
                            and `industrial_lte_2kwh` are deliberately ABSENT rather than
                            zero: those batteries owe no passport at all, which is a
                            different statement from "zero fields required".
                          additionalProperties:
                            type: integer
                          example:
                            EV: 76
                            LMT: 79
                            industrial_gt_2kwh: 61
                        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
                            dateBasis:
                              type: string
                              enum: [statutory, indicative, none]
                              description: >-
                                statutory = dates fixed by an adopted act; indicative = planning
                                estimates with no legal force; none = no passport act is adopted
                                or scheduled, and the dates are the sentinel 9999-12-31 — never
                                display or compare them.
        "429":
          description: Rate limit exceeded (60 requests/min per IP).
          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.

        **No authentication required** — see the list endpoint above.
        Bounded by an IP rate limit (60 requests/min).
      security: []
      parameters:
        - name: category
          in: path
          required: true
          schema:
            type: string
            enum: [battery, textile, electronics, construction, steel, detergents, paints-coatings, 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, "null"]
                  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 }
                      dateBasis:
                        type: string
                        enum: [statutory, indicative, none]
                        description: >-
                          statutory = dates fixed by an adopted act; indicative = planning
                          estimates with no legal force; none = no passport act is adopted or
                          scheduled, and the dates are the sentinel 9999-12-31 — never display
                          or compare them.
                  fieldCount:
                    type: integer
                  requiredFieldCount:
                    type: integer
                    description: >-
                      Count of fields whose category-agnostic `required` is true.
                      Where a template uses per-category applicability, see
                      `requiredFieldCountByCategory` for the per-category totals.
                  requiredFieldCountByCategory:
                    type: [object, "null"]
                    description: >-
                      Per-category required-field totals, present only for templates
                      that use `requiredBy` (today: battery). Keys are the in-scope
                      category values.
                    additionalProperties:
                      type: integer
                  fields:
                    type: array
                    items:
                      type: object
                      properties:
                        key:
                          type: string
                          example: batteryUniqueIdentifier
                        label:
                          type: [string, "null"]
                        description:
                          type: [string, "null"]
                        dataType:
                          type: string
                          example: string
                        unit:
                          type: [string, "null"]
                        required:
                          type: boolean
                          description: >-
                            Category-agnostic default. For templates that carry
                            `requiredBy`, prefer that map — this boolean is the
                            fallback used when the passport's category is unset or
                            absent from the map. Note the battery exception below:
                            for an out-of-scope battery category this boolean does
                            NOT apply, because no field is mandatory at all.
                        requiredBy:
                          type: [object, "null"]
                          description: >-
                            Per-category required-ness, present only where a field's
                            applicability varies by product category (today: the
                            battery template, keyed by `batteryCategory`). Absent means
                            the field is category-agnostic — read `required` instead.
                            IMPORTANT for battery: the map is keyed ONLY by the three
                            categories that owe a battery passport under Art. 77(1) of
                            Reg (EU) 2023/1542 — `EV`, `LMT`, `industrial_gt_2kwh`.
                            A `portable`, `SLI` or `industrial_lte_2kwh` battery owes
                            no passport, so NO field is required for it. Check scope
                            first; falling through to `required` for those three
                            values asserts an obligation the Regulation does not
                            impose. An in-scope category that is not a key in the
                            map falls back to `required` (e.g. `{EV: notApplicable}`
                            means optional for LMT and industrial).
                          additionalProperties:
                            type: string
                            enum: [required, conditional, notApplicable]
                          example:
                            EV: required
                            LMT: notApplicable
                            industrial_gt_2kwh: conditional
                        entryProperties:
                          type: object
                          description: >-
                            For an `array` field whose entries are objects: the
                            members of one entry. Absent on untyped lists. Battery
                            composition, for example, is
                            `{substanceName, casNumber, massPercent}`, with
                            `massPercent` as a share of the battery's total mass.
                          additionalProperties:
                            type: object
                            required: [type]
                            properties:
                              type:
                                type: string
                                enum: [string, number, boolean]
                              description:
                                type: string
                              required:
                                type: boolean
                              enum:
                                type: array
                                items:
                                  type: string
                              format:
                                type: string
                                enum: [date, iso3166-alpha2, cas-number]
                          example:
                            substanceName: { type: string, required: true }
                            casNumber: { type: string, format: cas-number }
                            massPercent: { type: number, required: true }
                        accessLevel:
                          type: string
                          enum: [public, restricted, authority]
                        category:
                          type: string
                          description: Localized sub-section header this field belongs to.
                        enumOptions:
                          type: [array, "null"]
                          items:
                            type: object
                            properties:
                              value: { type: string }
                              label: { type: [string, "null"] }
                        validation:
                          type: object
                          properties:
                            minLength: { type: [integer, "null"] }
                            maxLength: { type: [integer, "null"] }
                            min: { type: [number, "null"] }
                            max: { type: [number, "null"] }
                            pattern: { type: [string, "null"] }
                        regulationRef:
                          type: [object, "null"]
                          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
                            instrument:
                              type: [string, "null"]
                              description: CELEX id of the instrument.
                              example: 32023R1542
                            provision:
                              type: [string, "null"]
                              example: Annex XIII 1(e)
                            kind:
                              type: [string, "null"]
                              enum: [legislation, standard, scheme, national, international, specification, null]
                              description: |
                                What kind of claim the reference is. Only
                                `legislation` can make a field required.
                            standards:
                              type: array
                              items: { type: string }
                              description: |
                                Keys into the standards registry
                                (`standards.json` in @tracepass/dpp-schemas)
                                for the technical standards this field relates
                                to. Relating to a standard is not a claim of
                                conformity and never makes a field required.
                              example: [EN 10204, EN 10025]
                            description:
                              type: [string, "null"]
        "429":
          description: Rate limit exceeded (60 requests/min per IP).
          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 and update passports (publishing stays in the dashboard)
            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: 64
      description: |
        UUID v4 (or any opaque string ≤ 64 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]{8}$|^[0-9]{12}$|^[0-9]{13}$|^[0-9]{14}$"
      example: "04012345000015"
      description: >-
        GS1 GTIN with a valid check digit. Accepts 8 digits (GTIN-8), 12 digits
        (GTIN-12/UPC-A), 13 digits (GTIN-13/EAN-13), or 14 digits (GTIN-14).
        All forms are left-padded to 14 digits before storage; stored and
        returned values are always 14 digits.

    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`, `legacyOperatorId`,
        or `operatorIdentifier` must be set — without an identifier the
        Party doesn't actually identify anyone.

        If both `gln` and `operatorIdentifier` (scheme `gln`) are present
        they must carry the same 13-digit value; a mismatch returns 400
        with `path: operatorIdentifier`. An `operatorIdentifier` of scheme
        `gln` also fills the top-level `gln` field. `facilityIdentifier`
        never fills `gln`. Typed identifiers are not populated by AI
        extraction or CSV import.
      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.
        operatorIdentifier:
          description: |
            Structured operator identifier per EN 18219 §6.2–6.5. One of
            four schemes: iso6523, gln, did, doi. When scheme is `gln` this
            also fills the top-level `gln` field; they must match if both
            are set (400 with path `operatorIdentifier`). Not populated by
            AI extraction or CSV import.
          oneOf:
            - $ref: "#/components/schemas/OperatorIdentifierISO6523"
            - $ref: "#/components/schemas/OperatorIdentifierGLN"
            - $ref: "#/components/schemas/OperatorIdentifierDID"
            - $ref: "#/components/schemas/OperatorIdentifierDOI"
          discriminator:
            propertyName: scheme
            mapping:
              iso6523: "#/components/schemas/OperatorIdentifierISO6523"
              gln: "#/components/schemas/OperatorIdentifierGLN"
              did: "#/components/schemas/OperatorIdentifierDID"
              doi: "#/components/schemas/OperatorIdentifierDOI"
        facilityIdentifier:
          description: |
            Structured facility identifier per EN 18219 §6.2–6.5. Same four
            schemes as `operatorIdentifier`; the `gln` variant also accepts
            an optional `extension` (GS1 SGLN sub-location code). Never fills
            the top-level `gln` field. Not populated by AI extraction or CSV
            import.
          oneOf:
            - $ref: "#/components/schemas/OperatorIdentifierISO6523"
            - $ref: "#/components/schemas/FacilityIdentifierGLN"
            - $ref: "#/components/schemas/OperatorIdentifierDID"
            - $ref: "#/components/schemas/OperatorIdentifierDOI"
          discriminator:
            propertyName: scheme
            mapping:
              iso6523: "#/components/schemas/OperatorIdentifierISO6523"
              gln: "#/components/schemas/FacilityIdentifierGLN"
              did: "#/components/schemas/OperatorIdentifierDID"
              doi: "#/components/schemas/OperatorIdentifierDOI"

    OperatorIdentifierISO6523:
      type: object
      required: [scheme, icd, value]
      description: |
        ISO/IEC 6523 operator identifier. ICD 0199 = LEI (ISO 17442 check
        digits), 0088 = GLN, 0060 = DUNS. EN 18219 §6.2.
      properties:
        scheme:
          type: string
          enum: [iso6523]
        icd:
          type: string
          pattern: "^\\d{4}$"
          description: "4-digit ISO/IEC 6523 ICD (International Code Designator)."
          example: "0199"
        value:
          type: string
          minLength: 1
          maxLength: 256
          description: The identifier value within the specified ICD scheme.
          example: "529900T8BM49AURSDO55"

    OperatorIdentifierGLN:
      type: object
      required: [scheme, gln]
      description: |
        GS1 GLN operator identifier. Also fills the Party's top-level `gln`
        field; they must agree if both are set. EN 18219 §6.3.
      properties:
        scheme:
          type: string
          enum: [gln]
        gln:
          type: string
          pattern: "^\\d{13}$"
          description: 13-digit GS1 Global Location Number.
          example: "4012345678901"

    FacilityIdentifierGLN:
      type: object
      required: [scheme, gln]
      description: |
        GS1 GLN facility identifier. Optionally carries a GS1 SGLN
        sub-location extension. Does not fill the Party's top-level `gln`
        field. EN 18219 §6.3.
      properties:
        scheme:
          type: string
          enum: [gln]
        gln:
          type: string
          pattern: "^\\d{13}$"
          description: 13-digit GS1 Global Location Number.
          example: "4012345678901"
        extension:
          type: string
          minLength: 1
          maxLength: 80
          description: GS1 SGLN sub-location extension (identifies a specific location within the GLN).
          example: "1"

    OperatorIdentifierDID:
      type: object
      required: [scheme, did]
      description: |
        W3C DID (Decentralised Identifier). Syntax-checked only per
        EN 18219 §6.4.2(b) — it becomes a verifiable operator identifier
        when an authorised register issues a VC; the platform does not
        perform VC verification. EN 18219 §6.4.
      properties:
        scheme:
          type: string
          enum: [did]
        did:
          type: string
          pattern: "^did:[a-z0-9]+:.+"
          maxLength: 512
          description: "Decentralised Identifier matching did:<method>:<method-specific-id>. W3C DID Core requires the method to be lowercase alphanumerics only."
          example: "did:web:example.com"

    OperatorIdentifierDOI:
      type: object
      required: [scheme, doi]
      description: |
        ISO 26324 DOI operator identifier. A `doi:` or
        `https://doi.org/` / `https://dx.doi.org/` prefix is accepted on
        input and stripped; stored bare. EN 18219 §6.5.
      properties:
        scheme:
          type: string
          enum: [doi]
        doi:
          type: string
          pattern: "^(?:doi:|https?:\\/\\/(?:dx\\.)?doi\\.org\\/)?10\\.\\d{4,9}\\/\\S+$"
          maxLength: 512
          description: |
            Digital Object Identifier. Bare `10.<4–9 digit registrant>/<suffix>`
            form, or with an accepted prefix (`doi:` / `https://doi.org/` /
            `https://dx.doi.org/`) which is stripped before storage.
          example: "10.1234/example"

    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]
        sourceProvider:
          type: string
          description: >-
            The specific data provider behind an AI-extracted value, where
            `source` only says it came from a reference database. One of
            `eprel`, `open-food-facts`, `pubchem`, `vies`, `echa`, `climatiq`,
            `gs1`, `manufacturers-cache`, or `research` for a web-researched
            value. Absent when the origin has no provider identity (a value
            read from your uploaded document, or one a supplier submitted),
            and absent when the extraction pipeline altered the provider's
            value before storing it — an unlabelled value is never a
            provider's word for it.
          example: eprel
        sourceUrl:
          type: string
          format: uri
          description: >-
            The provider's canonical URL for this value, when it published
            one. Machine-readable companion to `evidence`.
          example: https://eprel.ec.europa.eu/qr/450201
        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]
    SnapshotEntry:
      type: object
      description: >
        A single item in the immutability snapshot list. Contains
        everything needed for an ERP integration to locate and
        inspect a snapshot, including whether the stored hash still
        verifies.
      properties:
        id:
          type: string
          description: The snapshot's TracePass id (MongoDB ObjectId hex).
        version:
          type: integer
          description: The passport version counter at the time of this snapshot.
        reason:
          type: string
          enum: [published, republished, manual, field_edit, status_change, parties, supplier, ai, restore, system, baseline]
          description: Why this snapshot was taken.
        actor:
          type: string
          description: >-
            Who caused the change, when known: `user:<id>`, `apikey:<prefix>`,
            `oauth:<clientId>`, `supplier:<requestId>` or `system:<job>`.
        snapshotAt:
          type: string
          format: date-time
          description: When the snapshot was taken (ISO 8601).
        contentHash:
          type: string
          description: >
            SHA-256 hex digest of the canonical JSON-LD archived at
            snapshot time. Re-verified on every read — if the stored
            document was tampered with at rest, `hashValid` is false.
        hashValid:
          type: boolean
          description: >
            True when the freshly-computed sha256(jsonLd) matches the
            stored `contentHash`. False indicates at-rest tampering.
        restorable:
          type: boolean
          description: >
            True when the snapshot carries `rawFields` and can drive a
            field-level restore. Snapshots taken before `rawFields` was
            captured are evidence-only (permanently `restorable: false`).
        fieldCount:
          type: [integer, "null"]
          description: >
            Number of fields captured in `rawFields`, or `null` for
            pre-`rawFields` snapshots.
      required: [id, version, reason, snapshotAt, contentHash, hashValid, restorable, fieldCount]
    SnapshotList:
      type: object
      description: Paginated list of passport snapshots.
      properties:
        entries:
          type: array
          items:
            $ref: "#/components/schemas/SnapshotEntry"
        total:
          type: integer
          description: Total snapshots matching the query across all pages.
        page:
          type: integer
          description: Current page number (1-based).
        limit:
          type: integer
          description: Page size used for this response.
        totalPages:
          type: integer
          description: Total number of pages at the current limit.
      required: [entries, total, page, limit, totalPages]
    SnapshotDetail:
      allOf:
        - $ref: "#/components/schemas/SnapshotEntry"
        - type: object
          description: >
            Full archival record for one snapshot — all `SnapshotEntry`
            fields plus the complete authority-level JSON-LD the passport
            asserted at snapshot time.
          properties:
            jsonLd:
              type: object
              description: >
                The complete JSON-LD document archived at snapshot time —
                what the public passport viewer would have returned for this
                passport on the snapshot date. The content hash covers this
                field only.
          required: [jsonLd]
    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-GRANULARITY / REG-READY-COMMODITY / REG-READY-SCOPE).
            Critical findings block readiness; warnings do not.
          items:
            $ref: "#/components/schemas/ComplianceFinding"
      required: [ready, findings]
    ConditionFlag:
      type: object
      description: >
        A single resolved condition flag — an approved yes/no fact about
        a product that gates conditional legal duties (e.g. `hasBMS` for
        batteries gates specific Annex XIII fields under Regulation (EU)
        2023/1542).
      required: [value, status]
      properties:
        value:
          type: boolean
          description: The flag's current value (true = condition applies).
        status:
          type: string
          enum: [approved, pending_review]
          description: >
            `approved` — the flag is authoritative and gates conditional
            duties. `pending_review` — the flag has been set but not yet
            reviewed; it does not trigger conditional requirements until
            approved.
        source:
          type: [string, "null"]
          description: >
            Origin of the last write. API writes are tagged
            `api:<key-prefix>`; dashboard writes are tagged with the
            reviewer's name or left null for system writes.

    ConditionProfile:
      type: object
      description: >
        A map of flagKey → ConditionFlag representing the resolved condition
        profile for a passport. Valid flag keys are category-specific:
        `battery` supports `hasBMS`, `rechargeable`, `externalStorageOnly`,
        and `isStationaryBess`. Categories with no flags return an empty
        object. The `audit` array is stripped from v1 responses.
      additionalProperties:
        $ref: "#/components/schemas/ConditionFlag"
      example:
        hasBMS:
          value: true
          status: approved
          source: "api:tp_abc123"
        rechargeable:
          value: true
          status: approved
          source: "api:tp_abc123"

    ConditionFlagsPatch:
      type: object
      description: >
        Partial update for the condition profile. Each key must be
        registered for the passport's category (unknown keys return 400).
        Set a flag with `true`/`false`; clear it with `null`. Provide at
        least one key.
      additionalProperties:
        type: [boolean, "null"]
      minProperties: 1
      example:
        hasBMS: true
        rechargeable: false

    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
        byRegulation:
          type: array
          description: |
            The same findings grouped by the regulation that raised them, worst
            first. Additive and purely derived — it never disagrees with
            `verdict`, it explains it. `verdict` answers "is this publishable";
            this answers "which regime do I go and fix", which a single
            `incomplete` cannot.

            A regulation appears ONLY if it raised at least one finding, so an
            absent regulation means "no finding", never "passed" — listing every
            regulation a category could touch would assert a pass for checks
            that never ran.
          items:
            $ref: "#/components/schemas/RegimeResult"
      required:
        - verdict
        - category
        - conditionalCoverage
        - critical
        - warnings
        - checkedRules
        - completionPercentage
        - byRegulation
    RegimeResult:
      type: object
      description: |
        One regulation's slice of a compliance result. The verdict follows the
        same rule as the overall one — any critical finding is `incomplete`,
        otherwise warnings downgrade to `compliant_with_warnings` — but is
        scoped to this regulation's own findings.
      properties:
        regulation:
          type: string
          description: |
            Regulation number exactly as the findings carry it, e.g.
            `(EU) 2023/1542`. Findings that carry no regulation are grouped
            under the literal `unattributed` rather than dropped, so the two
            presentations always account for the same number of findings.
          example: "(EU) 2023/1542"
        verdict:
          type: string
          enum: [compliant, compliant_with_warnings, incomplete]
        critical:
          type: array
          items:
            $ref: "#/components/schemas/ComplianceFinding"
        warnings:
          type: array
          items:
            $ref: "#/components/schemas/ComplianceFinding"
      required:
        - regulation
        - verdict
        - critical
        - warnings
    # EN 18219 identifier schemes (discriminated by `scheme`).
    ProductIdentifier:
      oneOf:
        - type: object
          description: GS1 GTIN + serial number (scheme "gs1"). Required for battery passports.
          required: [scheme, gtin, serialNumber]
          properties:
            scheme: { type: string, enum: [gs1] }
            gtin:   { $ref: "#/components/schemas/GTIN" }
            serialNumber: { type: string, minLength: 1, maxLength: 100 }
            digitalLinkUri: { type: string, format: uri }
        - type: object
          description: ISO/IEC 15459 identifier (scheme "iso15459"). Also valid for battery passports.
          required: [scheme, issuingAgencyCode, primaryId]
          properties:
            scheme:             { type: string, enum: [iso15459] }
            issuingAgencyCode:  { type: string, minLength: 1, maxLength: 3 }
            primaryId:          { type: string }
            serial:             { type: string }
            raw:
              type: string
              description: >-
                The ISO/IEC 15459 unique identifier: issuing agency code + primary id +
                serial, concatenated. Always derived by the server from the parts; a
                value sent on create is ignored. Present on every read.
            uri:
              type: string
              format: uri
              description: >-
                The ISO/IEC 18975 Data-Identifier query-form resolver URL generated by the server,
                following EN 18219 Table B.12: `https://id.tracepass.eu/?.25P=<IAC><primaryId>&.S=<serial>`
                (`.S` component omitted when there is no serial; parameter values are percent-encoded).
                Always derived by the server from the identifier parts; present on every read.
        - type: object
          description: >-
            IEC 61406 Identification Link — an https URI (scheme "iec61406").
            Not valid for battery passports (Battery Regulation Art. 77(3) requires gs1 or iso15459).
          required: [scheme, uri]
          properties:
            scheme: { type: string, enum: [iec61406] }
            uri:    { type: string, format: uri }
        - type: object
          description: >-
            W3C DID Core identifier (scheme "did").
            Not valid for battery passports (Battery Regulation Art. 77(3) requires gs1 or iso15459).
          required: [scheme, did, method]
          properties:
            scheme: { type: string, enum: [did] }
            did:    { type: string }
            method: { type: string }
        - type: object
          description: >-
            Digital Object Identifier — ISO 26324 (scheme "doi"). Stored as bare `10.<registrant>/<suffix>`.
            Any `https://doi.org/` or `doi:` prefix is stripped on input; the DOI resolves as
            `https://doi.org/<doi>`. `granularity` is REQUIRED per EN 18219 §5.6.2(b): a DOI
            product identifier must declare whether it identifies the product model, a production
            batch, or an individual item. Not valid for battery passports (Battery Regulation
            Art. 77(3) requires gs1 or iso15459).
          required: [scheme, doi, granularity]
          properties:
            scheme: { type: string, enum: [doi] }
            doi:    { type: string, pattern: "^10\\." }
            granularity:
              type: string
              enum: [model, batch, item]
              description: >-
                Whether the DOI identifies the product model, a production batch, or an
                individual item (EN 18219 §5.6.2(b)). Must be supplied on create; returned
                unchanged on read.
      discriminator:
        propertyName: scheme

    Passport:
      type: object
      properties:
        _id:
          $ref: "#/components/schemas/ObjectId"
        companyId:
          $ref: "#/components/schemas/ObjectId"
        productId:
          $ref: "#/components/schemas/ObjectId"
        templateId:
          $ref: "#/components/schemas/ObjectId"
        identifier:
          $ref: "#/components/schemas/ProductIdentifier"
        identifierKey:
          type: string
          description: |
            Canonical dedup key in `<scheme>:<parts>` form, e.g.
            `gs1:00012345678905:SN-001` or `did:did:example:123`.
            Globally unique (sparse index) — the same identifier cannot exist
            across different accounts.
        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"
        lineage:
          $ref: "#/components/schemas/PassportLineage"
        dynamicDataAsOf:
          type: [string, "null"]
          format: date-time
          description: |
            `measuredAt` of the newest battery measurement applied to this
            passport; null before the first one. Returned with `format=full`.
        successors:
          type: array
          description: |
            Forward links to successor passports created from this one
            (same account only). Never changes this passport's fields.
          items:
            $ref: "#/components/schemas/PassportSuccessor"
        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

    LineageTrigger:
      type: string
      description: The Art. 77(7) operation that produced the successor battery.
      enum: [preparation_for_reuse, preparation_for_repurposing, repurposing, remanufacturing]

    LineagePredecessorInput:
      type: object
      description: |
        One predecessor passport. Supply at least one of `identifier` or
        `internalPassportId`. An `internalPassportId` must belong to your
        account (422 `predecessor_not_found` otherwise); its canonical URI
        is filled in for you.
      required: [trigger]
      properties:
        identifier:
          type: string
          maxLength: 2000
          description: Resolvable identifier of the predecessor passport (http(s) URL, `urn:`, or bare token).
        internalPassportId:
          $ref: "#/components/schemas/ObjectId"
        trigger:
          $ref: "#/components/schemas/LineageTrigger"

    LineageInput:
      type: object
      description: |
        Battery passports only. Links a second-life battery's new passport to
        the original battery passport(s), as Art. 77(7) of Regulation (EU)
        2023/1542 requires. Immutable after create. `batteryStatus` is derived
        from the triggers (repurposed, remanufactured, …). Rules (422):
        `lineage_not_applicable` (non-battery), `duplicate_predecessor`,
        `status_trigger_mismatch` (the product's default `batteryStatus`
        contradicts the triggers), `predecessor_reason_required` (no
        predecessors and no reason), `predecessor_not_found`,
        `invalid_gtin_check_digit`. A second-life `batteryStatus` with no
        lineage block returns `successor_passport_required`.
      required: [predecessors]
      properties:
        predecessors:
          type: array
          maxItems: 10
          items:
            $ref: "#/components/schemas/LineagePredecessorInput"
        noPredecessorReason:
          type: string
          maxLength: 500
          description: |
            Only with an empty `predecessors` list: why no original passport
            exists, e.g. a battery placed on the market before 18 Feb 2027.

    PassportLineage:
      type: object
      description: The stored, immutable lineage block of a successor battery passport.
      properties:
        predecessors:
          type: array
          items:
            type: object
            properties:
              identifier:
                type: string
              trigger:
                $ref: "#/components/schemas/LineageTrigger"
              internalPassportId:
                $ref: "#/components/schemas/ObjectId"
        noPredecessorReason:
          type: string

    PassportSuccessor:
      type: object
      properties:
        passportId:
          $ref: "#/components/schemas/ObjectId"
        identifier:
          type: string
          description: Canonical resolvable URI of the successor passport.
        addedAt:
          type: string
          format: date-time

    MeasurementInput:
      type: object
      required: [fieldKey, value, measuredAt]
      properties:
        fieldKey:
          type: string
          description: An Annex XIII point 4 use-data field key of the battery template, e.g. `stateOfHealth`.
        value:
          description: Typed as the template field declares (number for most; an array for `negativeEvents`, an object for `temperatureConditionsHistorical`). At most 16 KB serialised; larger values return 400 `invalid_value`.
        measuredAt:
          type: string
          format: date-time
          description: When the equipment took the measurement. Not in the future.
        externalId:
          type: string
          description: Your idempotency key for this measurement; a repeat is reported as a duplicate, not stored again.
        unit:
          type: string

    MeasurementsInput:
      type: object
      required: [measurements]
      properties:
        measurements:
          type: array
          minItems: 1
          maxItems: 500
          items:
            $ref: "#/components/schemas/MeasurementInput"

    MeasurementsCaptureResult:
      type: object
      properties:
        accepted:
          type: integer
        storedCount:
          type: integer
        materializedCount:
          type: integer
          description: How many became the passport's current value (newer than what it held).
        duplicates:
          type: integer
          description: How many were skipped because their `externalId` had already been received (in this call or an earlier one).
        duplicateExternalIds:
          type: array
          items:
            type: string
        measurementIds:
          type: array
          items:
            $ref: "#/components/schemas/ObjectId"

    PassportMeasurement:
      type: object
      properties:
        _id:
          $ref: "#/components/schemas/ObjectId"
        passportId:
          $ref: "#/components/schemas/ObjectId"
        fieldKey:
          type: string
        value: {}
        measuredAt:
          type: string
          format: date-time
        receivedAt:
          type: string
          format: date-time
        externalId:
          type: string
        unit:
          type: string
        materialized:
          type: boolean
        source:
          type: string

    LatestMeasurement:
      type: object
      properties:
        value: {}
        measuredAt:
          type: string
          format: date-time
        unit:
          type: string
        externalId:
          type: string

    PassportSummary:
      type: object
      description: Trimmed passport shape returned in list responses.
      properties:
        _id:
          $ref: "#/components/schemas/ObjectId"
        productId:
          $ref: "#/components/schemas/ObjectId"
        identifier:
          $ref: "#/components/schemas/ProductIdentifier"
        identifierKey:
          type: string
          description: Canonical dedup key in `<scheme>:<parts>` form.
        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: {}
          description: |
            Seed values for passports created from this product, keyed by
            template field. Each value is checked against the template: a
            numeric string for a number field is stored as a number, and any
            other value the template rejects answers 400
            `Invalid default field values` with `details` (in a batch, that
            item's result is an error).
        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
      description: |
        Exactly one of `identifier` (EN 18219 scheme-tagged) or the legacy
        `gs1` block must be supplied. When `identifier` is provided with
        `scheme: "gs1"` the platform also writes the legacy `gs1` block for
        backward compatibility. Battery passports may only carry `gs1` or
        `iso15459` identifiers (Art. 77(3) Battery Regulation).
      required: [productId]
      properties:
        productId:
          $ref: "#/components/schemas/ObjectId"
        identifier:
          $ref: "#/components/schemas/ProductIdentifier"
        gs1:
          type: object
          description: |
            Legacy GS1 identifier block. Prefer `identifier` with
            `scheme: "gs1"` on new integrations.
          required: [gtin, serialNumber]
          properties:
            gtin:
              $ref: "#/components/schemas/GTIN"
            serialNumber:
              type: string
              minLength: 1
              maxLength: 100
        parties:
          $ref: "#/components/schemas/PartyMap"
        lineage:
          $ref: "#/components/schemas/LineageInput"
        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
            - reference_db
            - supplier
          description: |
            Tag the origin of the value. Default `manual`.
            `ai_suggested` lands in the review queue; the others write
            with the caller's own rights (see the operation). A response
            may still show `ai_approved` or `system`: TracePass sets
            those, and a request claiming them is refused (400).
        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`, `detergents`,
            `paints-coatings`, `packaging`, `toys`, `fmcg`, `tyres`,
            `iron-steel`, `construction`.
        description:
          type: string
          maxLength: 2000
        defaultFieldValues:
          type: object
          additionalProperties: {}
          description: |
            Seed values for passports created from this product, keyed by
            template field. Each value is checked against the template: a
            numeric string for a number field is stored as a number, and any
            other value the template rejects answers 400
            `Invalid default field values` with `details` (in a batch, that
            item's result is an error).
        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: {}
          description: |
            Seed values for passports created from this product, keyed by
            template field. Each value is checked against the template: a
            numeric string for a number field is stored as a number, and any
            other value the template rejects answers 400
            `Invalid default field values` with `details` (in a batch, that
            item's result is an error).
        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, 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.

        `publicUrl` is the URL the QR encodes. For GS1 passports this is
        the Digital Link URI (`id.tracepass.eu/01/<gtin>/21/<serial>`).
        For iec61406, did, and doi passports it is the `/x/<scheme>/<value>`
        resolver form (`id.tracepass.eu/x/did/<encodedDid>`); Caddy rewrites
        `/x/*` → `/p/x/*` so the same viewer serves all five schemes.
      properties:
        passportId:
          $ref: "#/components/schemas/ObjectId"
        gtin:
          $ref: "#/components/schemas/GTIN"
        serial:
          type: string
        identifier:
          type: string
          description: >-
            For non-GS1 passports: the `identifierKey` string, e.g.
            `did:did:example:123abc`. Present when `gtin`/`serial` are absent.
        publicUrl:
          type: string
          format: uri
        symbology:
          type: string
          enum: [qr, datamatrix]
          description: >-
            The symbology rendered. `qrSvg` and `qrPngDataUri` keep their
            names for compatibility and hold the Data Matrix image when
            this is `datamatrix`.
        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
        message:
          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"

    PassportCreateUnprocessable:
      description: |
        Either the Idempotency-Key was reused with a different body, or a
        battery `lineage` rule failed. Lineage failures carry the rule code
        in `error` (e.g. `duplicate_predecessor`, `predecessor_not_found`,
        `status_trigger_mismatch`) and a human-readable `message`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    PaymentRequired:
      description: |
        The subscription is past due, unpaid or canceled. Creating new content
        is blocked until billing is resolved; published passports stay
        accessible.
      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"
