# TracePass v1 API — OpenAPI 3.1 specification
# ---------------------------------------------------------------------
# The 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 caps apply only to Free
    (100 writes and 100 passport reads per API key per UTC day) and
    Basic (200 + 200) as free-tier abuse prevention; other reads are
    uncounted, and 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, delete, 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: Scope
    description: |
      EU DPP regulatory scope checking — determine whether a product
      falls in scope of DPP obligations, run a catalogue-wide scope
      analysis, and receive webhook notifications when scope status
      changes. Single-check (`POST /api/v1/scope/check`) is available
      on every plan; catalogue analysis requires Growth or higher.
    externalDocs:
      url: https://www.tracepass.eu/docs/check-scope
  - name: Regulatory
    description: |
      Regulatory data endpoints for the DPP Assistant — required DPP
      fields by category with EU article citations, recent regulatory
      changes, registry guidance, and the company compliance profile
      (GET / PATCH / DELETE). Free with a TracePass account; daily
      answer cap shared across dashboard, API and MCP connections.
    externalDocs:
      url: https://www.tracepass.eu/docs/dpp-assistant
  - 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. A draft takes no slot from the
        plan's `maxDpps` limit, so create never returns an overage
        error and never charges. The plan limit, and any
        per-passport overage charge, apply when the passport is
        published in the dashboard. A `402` here means billing is
        not in good standing (see `PaymentRequired`).

        **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. When the `parties` block carries an LEI
            that GLEIF reports as lapsed, retired or inactive, or whose
            legal name differs, the response adds `warnings`; an LEI
            GLEIF does not know, or reports as annulled or duplicate,
            is a 400 instead (see `upsertParty`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Passport"
                  - type: object
                    properties:
                      warnings:
                        $ref: "#/components/schemas/LeiWarnings"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "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/ResourceId"
        - 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.
      description: |
        What DELETE does depends on the passport's status.

        **`marked_for_deletion` — the second step of a deletion.**
        Deleting a passport that has been published takes two steps:
        first `POST /api/v1/passports/{id}/mark-for-deletion`, then
        this DELETE. It is a soft delete: nothing is erased. The
        passport disappears from the API (every v1 read answers 404
        from then on) and from every public surface. Scanning its QR
        code shows a page saying the passport has ended, and its
        machine-readable formats answer 410 Gone. Returns the passport
        with `status: deleted` and fires the `passport.deleted`
        webhook.

        **`draft` or `in_review`, never published — permanent delete.**
        Status must be `draft` or `in_review` AND `publishedAt` must
        be `null`. Removes the passport with everything that belongs
        only to it: its extractions, scan and service events,
        ownership transfers, EPCIS event links, and any uploaded
        documents and stored files no other record uses. Shared
        documents stay, with the link removed. Irreversible. No
        webhook; the deletion is recorded in your account's audit
        trail. Returns 403 on Free plans with a `reason` pointing at
        the archive endpoint.

        **Any other status — 409.** The body carries a `hint`
        ("A published passport is deleted in two steps: mark it for
        deletion first.") and a `reason`.

        Deletion through the API needs a paid plan. Counts as one v1
        write. Honours `Idempotency-Key`.
      responses:
        "200":
          description: |
            Deleted. A passport that was marked for deletion returns the
            passport with `status: deleted` (soft delete). A
            never-published passport returns a `message` and a
            `summary` of what was removed (permanent delete).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/Passport"
                  - $ref: "#/components/schemas/PassportHardDeleteResult"
              examples:
                softDelete:
                  summary: Soft delete (passport was marked for deletion)
                  value:
                    _id: "6650b2c3d4e5f6a7b8c9d0e1"
                    status: deleted
                    deletedAt: "2026-11-02T09:30:00.000Z"
                    deletedReason: recycled
                hardDelete:
                  summary: Permanent delete (never-published passport)
                  value:
                    message: Passport permanently deleted
                    summary:
                      passportId: "6650b2c3d4e5f6a7b8c9d0e1"
                      serialNumber: SN-001
                      gtin: "09506000134369"
                      deletedCounts:
                        extractions: 3
                        serviceEvents: 0
                        scanEvents: 0
                        ownershipTransfers: 0
                        supplierRequestRefsRemoved: 0
                        epcisEventRefsRemoved: 0
                        epcisEventsDeleted: 0
                        documentRefsRemoved: 1
                        documentsDeleted: 1
                        filesDeleted: 5
                      dppsActiveDelta: 0
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          description: Plan gate — permanent delete of a never-published passport is paid-only.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: Permanent delete is not available on your plan
                reason: Hard-delete is a paid-plan feature. Use POST /api/v1/passports/{id}/archive instead.
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            Not eligible: the passport is neither marked for deletion
            nor a never-published draft. Mark it for deletion first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: Passport is not eligible for permanent deletion
                hint: "A published passport is deleted in two steps: mark it for deletion first."
                reason: This passport has been published. Archive it instead — the QR remains traceable for audit.
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/mark-for-deletion:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: markPassportForDeletion
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Mark a passport for deletion.
      description: |
        The first of the two steps that delete a passport; the second
        is `DELETE /api/v1/passports/{id}`. (A never-published `draft`
        or `in_review` passport can be deleted directly.) Works on a passport in
        any status except archived or already marked for deletion.

        A marked passport can no longer be edited. Marking a published
        passport frees its plan slot. Fires the
        `passport.marked_for_deletion` webhook.

        **Battery passports need a `reason`.** Under the Battery
        Regulation a battery passport ends only once the battery has
        been recycled (Regulation (EU) 2023/1542, Art. 77(8)), so the
        request names why this one ends: `recycled`, `never_placed`
        (never placed on the market) or `duplicate_or_error`. Without
        one, a battery passport answers 400 `reason_required`. For
        other categories `reason` is optional, and must be one of the
        same values when given. `never_placed` on a passport registered
        as placed on the market answers 409
        `reason_contradicts_placement`.

        **Refused while a newer passport links to it.** While a newer
        second-life passport in your account names this one as its
        predecessor (its `lineage` block), the mark answers 409
        `has_live_successor`. Delete the newer passport first.

        Deletion through the API needs a paid plan: on Free the mark
        answers 403 `deletion_not_on_plan`. Counts as one v1 write.
        Honours `Idempotency-Key`. A by-serial alternate exists at
        `POST /api/v1/passports/by-serial/{serial}/mark-for-deletion`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeletionReasonInput"
            example:
              reason: recycled
      responses:
        "200":
          description: Marked for deletion. Returns the passport.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
              example:
                _id: "6650b2c3d4e5f6a7b8c9d0e1"
                status: marked_for_deletion
                markedForDeletionAt: "2026-11-01T10:00:00.000Z"
                markedForDeletionReason: recycled
        "400":
          $ref: "#/components/responses/DeletionReasonInvalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/DeletionNotOnPlan"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/MarkForDeletionConflict"
        "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). A battery whose importer places it
          on the market without a trade name or postal address gets a
          warning (`ruleId: "IMP-1"`, Art. 41(3)). A company with no
          stored operator identifier gets advice (`ruleId: "OID-1"`,
          warning, no regulation, since no law requires the identifier in
          the passport). 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.

        Six 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 address the Registry would be given
          resolves: the address frozen when a registration was prepared
          (`registryUpi`), otherwise the passport's current public address
          (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;
        - **`REG-READY-SEMANTIC`** — for electric-vehicle,
          light-means-of-transport and industrial (over 2 kWh) batteries,
          the passport's data is checked against the Commission's battery
          data model (semantic package 1.0.0), the model the Registry
          validates submissions against. A missing or malformed value in
          the public information is a block; a gap in the restricted
          information (Battery Regulation Annex XIII points 2–4) is a
          warning. A missing EU Registry organisation identifier (an
          account setting) or an unrecognised battery category is also a
          block. Portable, SLI and industrial batteries of 2 kWh or less
          fall outside the Commission's model and get no finding here.
          Each finding names the field in plain words and, where there is
          one, carries a `fix`.

        `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/ResourceId"
                  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/ResourceId"
                  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/PassportArchived"
        "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). Changes made
        outside the API are captured too, within a day. Passports that
        predate snapshots start with a `baseline` snapshot.

        **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.

        **Identifier lock:** after a passport is first published,
        identity fields (`gtin`, `serialNumber`, `uniqueProductIdentifier`,
        `globalUniqueProductIdentifier`, `productIdentifier`,
        `identifierStandard`, `batteryUniqueIdentifier`,
        `batteryIdDmcCode`, `batteryPassportIdentifier`) cannot change
        once they hold an `approved` value. Writing the same value back
        (including a GTIN with or without leading-zero padding, or a
        trimmed-whitespace match) is a no-op — not a lock. An empty
        field may be filled; a `pending_review` value may be corrected.
        On a lock the response is 409 `identifier_locked` and `keys`
        names the affected fields. Archiving the passport locks ALL
        writes: 409 `passport_archived`. A concurrent write race
        (another write landed between this read and write) returns 409
        `concurrent_update` — reload and retry.

        **No-op:** when the incoming value, computed write status and
        source locale are all identical to the stored state, the write
        is skipped entirely (no version bump, no audit entry, no
        snapshot) and the response body carries `unchanged: true`
        alongside the current field and version.

        **Required-field guard:** sending `null` or an empty string for
        a field the regulation requires for this passport's category is
        refused with 400 when the passport is already published. The
        `fields` array in the response body names every affected key.
        Draft passports remain freely editable.

        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, or no-op when the value was already identical.
            When `unchanged: true` is present, nothing was written and no
            version was bumped.
          content:
            application/json:
              schema:
                type: object
                required: [field, version]
                properties:
                  field:
                    $ref: "#/components/schemas/PassportField"
                  version:
                    type: integer
                    description: Current passport version (post-write, or unchanged on a no-op).
                  unchanged:
                    type: boolean
                    description: >
                      Present and `true` when the write was a no-op — the
                      incoming value, status and locale matched the stored
                      state so nothing was written.
              examples:
                updated:
                  summary: Field updated
                  value:
                    field:
                      value: 5.24
                      source: manual
                      status: approved
                      accessLevel: public
                      sourceLocale: en
                      lastUpdatedAt: "2026-05-09T10:30:00.000Z"
                      lastUpdatedBy: "api_key:tp_89b2482d"
                    version: 4
                noOp:
                  summary: No-op (unchanged)
                  value:
                    field:
                      value: 5.24
                      source: manual
                      status: approved
                      accessLevel: public
                      sourceLocale: en
                      lastUpdatedAt: "2026-05-09T10:30:00.000Z"
                      lastUpdatedBy: "api_key:tp_89b2482d"
                    version: 4
                    unchanged: true
        "400":
          description: |
            Bad request. Covers:
            - invalid field key (key not on template)
            - invalid field value (wrong type or enum option, or a string /
              url value that breaks the template's `pattern`, `minLength` or
              `maxLength`)
            - `ai_suggested` source on a never-AI field
            - emptying a required field on a published passport
              (`fields` array names the affected keys)
            - `field_value_too_large`: the value exceeds a size limit —
              at most 1,000 array items (nested arrays included), 20,000
              characters in any single string, 256 KB serialised, nesting
              depth 8
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required: [error]
                    properties:
                      error:
                        type: string
                      details:
                        type: array
                        items:
                          type: object
                  - type: object
                    required: [error, message, fields]
                    properties:
                      error:
                        type: string
                        enum: [required_field_empty]
                      message:
                        type: string
                        example: "Cannot empty required field on a published passport: ratedCapacity"
                      fields:
                        type: array
                        items:
                          type: string
                        description: Keys of the required fields that would be emptied.
                        example: ["ratedCapacity"]
                  - type: object
                    required: [error, message, key, limitType, limit]
                    properties:
                      error:
                        type: string
                        enum: [field_value_too_large]
                      message:
                        type: string
                        example: "Field 'hazardousSubstances' value exceeds the items limit (1000 items)."
                      key:
                        type: string
                        example: hazardousSubstances
                      limitType:
                        type: string
                        enum: [items, characters, bytes, depth]
                      limit:
                        type: integer
                        example: 1000
        "413":
          $ref: "#/components/responses/FieldPayloadTooLarge"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/PassportLocked"
        "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.

        An LEI (`operatorIdentifier` or `facilityIdentifier` with
        scheme `iso6523`, icd `0199`) is verified against the GLEIF
        register. Rejected (400): not in the GLEIF index,
        ANNULLED, DUPLICATE (the message names the LEI to use),
        CANCELLED. Saved with a `warnings` entry: LAPSED (not renewed;
        still the entity), RETIRED or an inactive entity, or a
        `legalName` that matches none of GLEIF's names for that LEI.

        Once a battery passport is placed on the market
        (`placeOnMarketStatus: placed`), a write that changes a stored
        `operatorIdentifier` or `facilityIdentifier` answers 409
        `operator_locked`.
      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
                  warnings:
                    $ref: "#/components/schemas/LeiWarnings"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/PartyWriteConflict"
        "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. On a battery passport
        placed on the market, removing a party that carries the
        operator or facility identifier answers 409 `operator_locked`.
      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"
        "409":
          $ref: "#/components/responses/PartyWriteConflict"
        "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.

        **Refused once placed on the market.** A passport registered
        as placed on the market, or with its registration under way,
        answers 409 `passport_placed_on_market`: it must stay public,
        so correct its data instead.
      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"
        "400":
          description: Wrong status to suspend from.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "409":
          description: |
            `passport_placed_on_market`: the passport is registered as
            placed on the market, or its registration is under way.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: passport_placed_on_market
                message: "This passport is registered as placed on the market, so it must stay public and cannot be suspended. Correct its data instead."
        "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 and the QR code dies for
        good, unless the passport has been forwarded to a new
        provider. 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.

        **Battery passports need a `reason`.** Under the Battery
        Regulation a battery passport ends only once the battery has
        been recycled (Regulation (EU) 2023/1542, Art. 77(8)), so
        archiving one names why: `recycled`, `never_placed` (never
        placed on the market) or `duplicate_or_error`. Without one, a
        battery passport answers 400 `reason_required`. For other
        categories the body is optional; a `reason`, when given, must
        be one of the same values. `never_placed` on a passport
        registered as placed on the market answers 409
        `reason_contradicts_placement`. The reason is carried in the
        `passport.archived` webhook payload.

        A passport marked for deletion cannot be archived (409
        `passport_already_marked_for_deletion`).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeletionReasonInput"
            example:
              reason: never_placed
      responses:
        "200":
          description: Archived. Idempotent — an already archived passport returns 200 with the existing document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "400":
          $ref: "#/components/responses/DeletionReasonInvalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/ArchiveMarkedConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/registry-upi:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: preparePassportRegistryUpi
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Get the address to register a battery passport in the EU DPP Registry.
      description: |
        Returns the unique product identifier (UPI) to paste into the EU
        DPP Registry's online form for this battery passport. The
        economic operator registers it on the Registry website under its
        own EU Login (Product group: Batteries; Granularity: Item);
        TracePass prepares and records, and never submits.

        The first call freezes the address on the passport
        (`registryUpi`): the Registry keeps that URL, so it must keep
        resolving even if the account's domain changes later. Every call
        records the preparation (`registrationPreparedAt`,
        `registrationPreparedVia: form`). A preparation is not a
        registration: record the Registry's answer with
        `POST /api/v1/passports/{id}/registration`.

        The passport must be a published battery passport, not already
        registered in production, with no blocking registry-readiness
        finding (the same check as `GET
        /api/v1/passports/{id}/registry-readiness`).

        **Paid plans only** (402 on Free: a Free passport expires after
        30 days). OAuth callers must act for an admin or owner. No request
        body. Counts as one v1 write, only when it succeeds. Honours
        `Idempotency-Key`.
      responses:
        "200":
          description: The address to paste.
          content:
            application/json:
              schema:
                type: object
                required: [upi, preparedAt]
                properties:
                  upi:
                    type: string
                    format: uri
                  preparedAt:
                    type: string
                    format: date-time
              example:
                upi: "https://id.tracepass.eu/01/04012345000016/21/A1B2C3"
                preparedAt: "2026-11-06T10:00:00.000Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            The passport cannot be registered yet. Switch on `error`:
            `not_a_battery`, `not_published`, `registered_in_production`,
            `registry_not_ready` (with `findingDetails`, blocking findings
            first) or `invalid_upi` (the address is not an `https://` URL
            of at most 2000 characters).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RegistryPrepareConflict"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
  /api/v1/registry/upload-file:
    parameters:
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: buildRegistryUploadFile
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Build an EU DPP Registry upload file for up to 100 battery passports.
      description: |
        Builds the XML file the economic operator uploads on the EU DPP
        Registry website (DPP Registration → upload a file) for the given
        passports. Registration is the operator's own act under its own
        EU Login; TracePass prepares the file and never submits it.

        **Only passports that would pass go in.** The Registry fails the
        whole file on one bad entry, so every other passport is returned
        in `excluded` with its `reason`: `not_found`, `not_battery`,
        `not_published`, `registered_in_production`,
        `registration_in_progress` (being sent to an EN 18222 registry),
        `readiness_blocked` (with the blocking findings in `details`),
        `invalid_upi`, `duplicate_upi` (the same address as another
        passport in the file) or `over_limit` (past 100).

        Each included passport's address is frozen (`registryUpi`) and the
        preparation recorded (`registrationPreparedVia: file`). Save `xml`
        under `filename` and upload it unchanged: the name uses only the
        characters the Registry accepts.

        JSON rather than raw XML, so the response replays under
        `Idempotency-Key` and carries the exclusions. **Paid plans only**
        (402 on Free). OAuth callers must act for an admin or owner. Counts
        one v1 write per passport included.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [passportIds]
              properties:
                passportIds:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    $ref: "#/components/schemas/ResourceId"
            example:
              passportIds: ["6650b2c3d4e5f6a7b8c9d0e1", "6650b2c3d4e5f6a7b8c9d0e2"]
      responses:
        "200":
          description: The upload file and what was left out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RegistryUploadFile"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
  /api/v1/passports/{id}/registration:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    post:
      operationId: recordPassportRegistration
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Record the EU DPP Registry's answer for a battery passport.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      description: |
        Records what the economic operator reports from the EU DPP
        Registry website. TracePass records the operator's statement and
        does not verify it with the Registry.

        - **`environment: production`** — the passport is registered in
          the production Registry. Sets `placeOnMarketStatus: placed`,
          `registrationMode: website`, `placedOnMarketAt` (the date of
          placing on the market: today by default, not in the future, not
          before the passport was first published) and, if given,
          `registrationId`. The operator and facility parties lock (a
          party write that would change a stored identifier answers 409
          `operator_locked`), the backup copy is queued and the
          `passport.placed_on_market` webhook fires.
        - **`environment: acceptance`** — a test in the Registry's
          acceptance environment: `passed`, or `failed` with the
          Registry's error code. It has no legal effect and changes
          nothing but `registryAcceptanceTest`.

        **Paid plans only** (402 on Free). OAuth callers must act for an
        admin or owner. Counts as one v1 write, only when it succeeds.
        Honours `Idempotency-Key`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegistrationRecord"
            examples:
              production:
                value:
                  environment: production
                  placedOnMarketAt: "2027-03-01"
                  registrationId: "URI-000-00-000"
              acceptance:
                value:
                  environment: acceptance
                  result: failed
                  errorCode: SEMANTIC_VALIDATION_UNAVAILABLE
      responses:
        "200":
          description: The passport after the record.
          content:
            application/json:
              schema:
                type: object
                required: [passport]
                properties:
                  passport:
                    $ref: "#/components/schemas/Passport"
        "400":
          description: |
            Validation failed, or the date is refused: `invalid_date`
            (not `YYYY-MM-DD`), `date_in_future`,
            `date_before_publication`, `registration_id_too_long`.
          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"
        "409":
          description: |
            `not_a_battery`, `not_published`, or (production)
            `already_registered`: the passport is already placed or being
            sent to an EN 18222 registry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
    delete:
      operationId: undoPassportRegistration
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Undo a mistaken EU DPP Registry record.
      description: |
        Clears a recorded result. `environment=acceptance` removes the
        acceptance test. `environment=production` returns the passport to
        "prepared" (the frozen address and the preparation stay) and
        unlocks the operator parties; only a `website` record can be
        undone, never a registration an EN 18222 registry confirmed. This
        changes nothing in the Registry itself. The undo is recorded.

        **Paid plans only.** OAuth callers must act for an admin or owner.
        Counts as one v1 write.
      parameters:
        - name: environment
          in: query
          required: true
          schema:
            type: string
            enum: [production, acceptance]
      responses:
        "200":
          description: The passport after the undo.
          content:
            application/json:
              schema:
                type: object
                required: [passport]
                properties:
                  passport:
                    $ref: "#/components/schemas/Passport"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "`nothing_to_undo` — no record of this kind that can be undone."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /api/v1/passports/{id}/place-on-market:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: placePassportOnMarket
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Send a battery passport to an EN 18222 registry API.
      description: |
        Sends a published battery passport to a registry that accepts the
        EN 18222 `registerDPP` call, using the registry API access your
        account has configured (API key or bearer token), so the entry is
        made under your own credentials. **Battery passports only.**

        **This is not the way into the EU DPP Registry.** The Commission's
        Registry does not accept this call or these credentials: the
        economic operator registers there on the Registry website, under
        its own EU Login. For that path, get the address to paste
        (`POST /api/v1/passports/{id}/registry-upi`) or an upload file
        (`POST /api/v1/registry/upload-file`), then record the result
        (`POST /api/v1/passports/{id}/registration`).

        **Paid plans only** (402 on Free): a Free passport expires after
        30 days, and a registered passport that stops resolving breaks
        the registration.

        **Asynchronous.** The call checks the passport, moves
        `placeOnMarketStatus` to `placing` and answers `202` at once;
        the registry call follows in the background, with automatic
        retries while the registry is unreachable. Read the passport
        (`GET /api/v1/passports/{id}`) to follow it:
        - `placed` — the registry accepted it. `placedOnMarketAt` is
          set, and `registrationId` when the registry returned one.
          Fires the `passport.placed_on_market` webhook.
        - `rejected` — the registry refused it, or could not be
          reached once the retries ran out. `registrationRejectedAt`
          and `registrationRejectionReason` say when and why, and the
          `passport.placement_rejected` webhook fires. Fix the
          cause and call this endpoint again: a rejected passport may
          be placed again, and the new attempt clears the old
          rejection.

        If the passport has no DPP ID yet, one is assigned and returned
        as `dppId`.

        **After placement the operator is locked.** A party write that
        would change a stored `operatorIdentifier` or
        `facilityIdentifier` answers 409 `operator_locked`; other party
        edits stay allowed. To correct a locked identifier, mark the
        passport for deletion and create a new one.

        **Refusals (409), checked in this order — switch on `error`:**
        - `not_a_battery` — the passport is not in the battery category.
        - `not_published` — only a `published` passport can be placed.
        - `already_placed` — placement is in progress (`placing`) or
          done (`placed`).
        - `operator_id_missing` — the account has no economic-operator
          identifier; set it in the company settings.
        - `registry_not_configured` — no registry access is configured
          for the account; an admin sets it in the dashboard under
          Settings → EN 18222 registry API.
        - `registry_host_not_allowed` — TracePass does not send
          registrations to the registry address configured for the
          account; check the registry URL under Settings → EN 18222
          registry API.
        - `registry_not_ready` — the registry cannot take this passport
          yet. `findings` lists the reasons from the registry-readiness
          check (`GET /api/v1/passports/{id}/registry-readiness` returns
          them structured).

        No request body. Counts as one v1 write, and only when the call
        is accepted. Honours `Idempotency-Key`.
      responses:
        "202":
          description: Accepted — placement is under way (`placing`).
          content:
            application/json:
              schema:
                type: object
                required: [passport, passportId, status, dppId]
                properties:
                  passport:
                    $ref: "#/components/schemas/Passport"
                  passportId:
                    $ref: "#/components/schemas/ResourceId"
                  status:
                    type: string
                    enum: [placing]
                  dppId:
                    type: string
                    description: The passport's DPP ID, the identifier the registry entry carries.
              example:
                passport:
                  _id: "6650b2c3d4e5f6a7b8c9d0e1"
                  status: published
                  placeOnMarketStatus: placing
                passportId: "6650b2c3d4e5f6a7b8c9d0e1"
                status: placing
                dppId: "https://id.tracepass.eu/dpp/7b1e4c2a-5f3d-4e8a-9c6b-2d1f0a3e5b7c"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No passport with this id in your account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: passport_not_found
        "409":
          $ref: "#/components/responses/PlaceOnMarketConflict"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "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 is checked against N writes up front: if
        it would overflow the daily-write budget, the entire batch
        returns 429 and nothing is created. Only the passports
        actually created count against the budget. Every passport is
        created as a draft, and a draft takes no slot from the
        plan's `maxDpps` limit, so a batch never returns an overage
        error and never charges. The plan limit, and any
        per-passport overage charge, apply when each passport is
        published in the dashboard. A `402` here means billing is
        not in good standing (see `PaymentRequired`).

        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/PaymentRequired"
        "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).

        A passport started in the dashboard without its product
        identifier ("Add it later") has no QR code until the
        identifier is set: the call answers 409 `identifier_required`.
      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"
        "409":
          description: |
            The passport has no product identifier yet, so it has no QR
            code. `error` is `identifier_required`. Set the identifier on
            the passport in the dashboard, then retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "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}` — including the no-op
        `unchanged: true` response and the required-field guard.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateFieldInput"
      responses:
        "200":
          description: |
            Field updated, or no-op when the value was already identical.
            When `unchanged: true` is present, nothing was written and no
            version was bumped.
          content:
            application/json:
              schema:
                type: object
                required: [field, version]
                properties:
                  field:
                    $ref: "#/components/schemas/PassportField"
                  version:
                    type: integer
                  unchanged:
                    type: boolean
                    description: >
                      Present and `true` when the write was a no-op.
        "400":
          $ref: "#/components/responses/BadRequest"
        "413":
          $ref: "#/components/responses/FieldPayloadTooLarge"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/BySerialPassportConflict"
        "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":
          description: |
            Two mutually exclusive causes — switch on `error`:
            `ambiguous_serial` (see the `AmbiguousSerial` response;
            add `?gtin=<gtin>`) or `passport_placed_on_market` (the
            passport is registered as placed on the market, or its
            registration is under way).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "400":
          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`, including
        the `reason` body that battery passports require.
        **Irreversible.**
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeletionReasonInput"
      responses:
        "200":
          description: Archived. Idempotent — an already archived passport returns 200 with the existing document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "400":
          $ref: "#/components/responses/DeletionReasonInvalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            Two mutually exclusive causes — switch on `error`:
            `ambiguous_serial` (see the `AmbiguousSerial` response;
            add `?gtin=<gtin>`), `passport_already_marked_for_deletion`
            (the passport is marked for deletion and cannot be archived)
            or `reason_contradicts_placement` (`never_placed` on a
            passport registered as placed on the market).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/mark-for-deletion:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/GtinQuery"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: markPassportForDeletionBySerial
      tags: [Passports]
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      summary: Mark a passport for deletion (by serial).
      description: |
        Same shape as POST `/api/v1/passports/{id}/mark-for-deletion`,
        including the `reason` body that battery passports require.
        The passport is then deleted with
        `DELETE /api/v1/passports/{id}`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeletionReasonInput"
            example:
              reason: recycled
      responses:
        "200":
          description: Marked for deletion. Returns the passport.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "400":
          $ref: "#/components/responses/DeletionReasonInvalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/DeletionNotOnPlan"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            Either `ambiguous_serial` (see the `AmbiguousSerial`
            response; add `?gtin=<gtin>`) or one of the
            mark-for-deletion conflicts listed on
            `MarkForDeletionConflict`.
          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/ResourceId"
                  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/ResourceId"
                  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/BySerialPassportArchivedConflict"
        "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: 500
            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"
        "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"
        "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: "battery"
              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), and the stored files
        that belonged only to this product.

        **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 }
                          filesDeleted: { 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: |
        Archive (one-way: no endpoint unarchives a product).
        `Product.status` flips to `archived`. Archived products
        still appear in `GET /api/v1/products`; pass
        `status=active` to leave them out. Allowed only once every
        passport of the product is archived. Archiving does not
        block creating new passports against the product, and it
        breaks no QR code 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 is checked against N writes up front: if
        it would overflow the daily-write budget, the entire batch
        returns 429 and nothing is created. Only the passports
        actually created count against the budget.
      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 stored image's public 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 product stops referencing that image URL.
      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) — same trust level as deleting the company. No plan
        or billing gate: every plan, Free included, whatever the
        billing status and after cancelling (EU Data Act). The
        pragmatic flow is: open Settings → Data export in the
        dashboard and click "Download JSON-LD"; "Export everything"
        on the same page builds a full-account ZIP that also holds
        the documents, supplier requests, passport versions, battery
        measurements and scan analytics.
        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"
        "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) —
        same privileged-action trust level as the JSON-LD tenant export.
        No plan or billing gate: every plan, Free included, whatever the
        billing status and after cancelling ("Download EPCIS" in
        Settings → Data export). Programmatic access requires a
        session cookie from `/api/auth/login`; the per-passport v1
        EPCIS routes cover API-key access.

        Synchronous response with `Content-Disposition: attachment`.
        No pagination.
      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.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  # =====================================================================
  # EPCIS 2.0
  # =====================================================================
  /api/exports/tenant/registry-batch:
    get:
      operationId: tenantRegistryBatchExport
      tags: [Exports]
      summary: Upload file for the EU DPP Registry, whole account.
      description: |
        The XML file the economic operator uploads on the EU DPP Registry
        website (DPP Registration → upload a file), for every published
        battery passport of the account that would pass, at most 100.
        Registration is the operator's own act under its own EU Login;
        TracePass prepares the file and never submits it. For a chosen
        set of passports over the API, use
        `POST /api/v1/registry/upload-file`; both apply the same rules.

        **Left out, and counted in the response headers:** passports in
        other categories (`X-Registry-Excluded-Non-Battery`; BATTERIES is
        the only product group the Registry offers), passports already
        registered in production or being sent to an EN 18222 registry
        (`X-Registry-Excluded-Registered`), passports with a blocking
        registry-readiness finding or an address the Registry would refuse
        (`X-Registry-Excluded-Not-Ready`), repeated addresses
        (`X-Registry-Excluded-Duplicate`) and anything past 100
        (`X-Registry-Excluded-Overflow`). The Registry fails the whole
        file on one bad entry, so nothing that would fail goes in.
        `X-Registry-Items` is the number included.

        Each included passport's address is frozen (`registryUpi`) and
        the preparation recorded.

        **Not API-key reachable.** Dashboard session with the admin role,
        on a **paid plan** (402 on Free: a Free passport expires after 30
        days, and a registered passport that stops resolving breaks the
        registration). This is a registration file, not a data export:
        the passport data itself stays in the ungated exports.

        Synchronous response with `Content-Disposition: attachment`.
      security:
        - SessionCookie: []
      responses:
        "200":
          description: The upload file.
          content:
            application/xml:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "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:
              allOf:
                - $ref: "#/components/schemas/EpcisDocument"
                - type: object
                  properties:
                    confirmOverage:
                      type: boolean
                      description: |
                        A TracePass key beside the EPCIS envelope. Send
                        `true` to accept the per-block overage charge when
                        the call would take a paid plan past its monthly
                        event quota; without it that call answers 402
                        `epcis_events_overage_required`. Any value other
                        than `true` counts as not confirmed.
      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 where EPCIS has been switched off by support
        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"
        "403":
          description: EPCIS query add-on not enabled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: |
            EPCIS query is not enabled 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: 142
                        requiredFieldCount:
                          type: integer
                          description: >-
                            Category-agnostic required count. Where a template carries
                            `requiredFieldCountByCategory`, prefer that map — for battery
                            this flat number understates the EV and LMT totals.
                          example: 41
                        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: 56
                            LMT: 56
                            industrial_gt_2kwh: 41
                        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"

  # =====================================================================
  # Scope services — EU DPP regulatory scope checking
  # =====================================================================

  /api/v1/scope/check:
    post:
      operationId: scopeCheck
      tags: [Scope]
      summary: Check product scope against EU DPP regulations.
      description: |
        Evaluates whether a product falls in scope of EU DPP obligations
        given its category, facts, role in the supply chain, and optional
        locale. Returns a deterministic scope answer with the relevant
        regulation article, confidence level, and a human-readable
        rationale.

        Auth: Bearer API key OR OAuth with `regulatory:read` OR
        `passports:read`. Does not count against the daily passport-read cap.

        Rate limit: Free and Basic plans are capped at a daily scope-check
        quota. A 429 with `error: scope_answer_cap` is returned when the
        cap is reached; `Retry-After` gives the seconds until midnight UTC
        reset.
      security:
        - BearerAuth: []
        - OAuth2:
            - regulatory:read
        - OAuth2:
            - passports:read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                category:
                  type: string
                  enum:
                    - battery
                    - construction
                    - detergents
                    - electronics
                    - fmcg
                    - furniture
                    - jewelry
                    - packaging
                    - paints-coatings
                    - steel
                    - textile
                    - toys
                    - tyres
                    - vehicles
                    - other
                  description: |
                    DPP product category key. Use `other` for categories
                    not yet covered by a specific regulation. `vehicles`
                    is in scope (End-of-Life Vehicles Regulation) but
                    has no passport template.
                  example: battery
                facts:
                  type: object
                  additionalProperties: true
                  description: |
                    Act-namespaced product facts as key-value pairs
                    (e.g. `{ "battery.use": "stationary_energy_storage",
                    "battery.capacityKWh": 10 }`). Unknown keys or
                    out-of-range values return 400 `invalid_input`.
                    Common facts are not namespaced: `ownBrand: true`
                    states that you place the product on the market
                    under your own name or trademark.
                role:
                  type: string
                  maxLength: 120
                  example: importer
                  description: |
                    Your role in the act's own terms: `manufacturer`,
                    `importer`, `distributor`, `authorised_representative`
                    or `fulfilment_service_provider` (an act may define
                    more). Selling under your own name is `manufacturer`
                    with `facts.ownBrand: true`. Determines which duties
                    are listed as yours. Defaults from the company
                    profile when omitted; with no role at all, the
                    answer lists the duties of every role.
                placedOnMarket:
                  type: [object, "null"]
                  description: |
                    When the product was (or will be) placed on the
                    market. A placing date is a day, a month or a year
                    (`2027-03-01`, `2027-03`, `2027`); precision, when
                    given, must fit the date string.
                  properties:
                    date:
                      type: string
                      pattern: '^\d{4}(-\d{2}(-\d{2})?)?$'
                      description: "Date as YYYY-MM-DD, YYYY-MM or YYYY."
                      example: "2027-03"
                    precision:
                      type: string
                      enum: [day, month, year]
                      description: "Precision of the date string. Optional; inferred from the string length when omitted."
                lifecycleEvent:
                  type: [string, "null"]
                  enum:
                    - prepared_for_reuse
                    - prepared_for_repurposing
                    - repurposed
                    - remanufactured
                    - repaired
                    - became_waste
                    - recycled
                  description: |
                    A post-first-sale lifecycle event that may affect scope
                    obligations (e.g. a repaired battery may still need a
                    passport).
                contains:
                  type: array
                  maxItems: 5
                  description: |
                    Component products (e.g. cells in a battery pack).
                    Each entry may carry its own `label` and `facts` to
                    resolve component-level scope rules. At most 5 items.
                  items:
                    type: object
                    properties:
                      label:
                        type: string
                        maxLength: 60
                        description: "Human-readable label for the component."
                      facts:
                        type: object
                        additionalProperties: true
                        description: "Act-namespaced facts for this component."
                locale:
                  type: string
                  example: en
                  description: |
                    Response language — `en`, `bg`, `de`, or `it`.
                    Defaults to the `Accept-Language` header, then `en`.
                productId:
                  type: [string, "null"]
                  description: |
                    Pre-fill facts from this product's saved scope answers.
                    Must belong to your company; returns 404 otherwise.
                    `null` is the same as leaving it out.
                passportId:
                  type: [string, "null"]
                  description: |
                    Pre-fill facts from this passport's field values and
                    the product's saved answers. Must belong to your
                    company; returns 404 otherwise. `null` is the same as
                    leaving it out.
            example:
              category: battery
              facts:
                battery.use: stationary_energy_storage
                battery.capacityKWh: 10
              role: importer
              locale: en
      responses:
        "200":
          description: Scope answer envelope.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - answer
                  - locale
                  - data
                  - ask
                  - profileUsed
                  - citations
                  - ruleIds
                  - asOf
                  - ruleStatus
                  - machineTranslated
                  - disclaimer
                properties:
                  status:
                    type: string
                    enum: [answered, needs_input, not_covered]
                    description: |
                      `answered` — a deterministic answer is available.
                      `needs_input` — blocking facts are missing (see `ask`).
                      `not_covered` — no rule covers this product/category.
                  answer:
                    type: string
                    description: Human-readable scope answer in the requested locale.
                  locale:
                    type: string
                    description: Locale the answer was rendered in.
                  data:
                    type: object
                    properties:
                      inScope:
                        type: [string, "null"]
                        enum: [yes, no, depends, pending, null]
                        description: |
                          `yes` — in scope now. `no` — out of scope.
                          `pending` — in scope from a future date.
                          `depends` — depends on unanswered facts.
                      verdict:
                        type: string
                        description: Raw verdict from the rule engine.
                      governing:
                        type: object
                        description: |
                          Primary governing act result when one applies. Fields
                          include `celex`, `name`, `verdict`, `citation`, `level`,
                          `role`, `duties`, `appliesFrom`, and other act-specific
                          data. The exact shape depends on the category and rule set.
                        additionalProperties: true
                      alsoApplies:
                        type: array
                        items:
                          type: object
                          additionalProperties: true
                        description: Other acts that also apply alongside the governing act.
                      suggestions:
                        type: array
                        items:
                          type: object
                  ask:
                    type: array
                    description: |
                      Facts the engine needs to refine or complete the answer.
                    items:
                      type: object
                      required: [fact, question, blocking, act, type]
                      properties:
                        fact:
                          type: string
                          description: Fact key (e.g. `battery.use`).
                        question:
                          type: string
                          description: Human-readable question for the fact.
                        blocking:
                          type: boolean
                          description: Required to give any verdict when `true`.
                        act:
                          type: string
                          description: CELEX of the act that needs this fact.
                        type:
                          type: string
                          enum: [enum, boolean, string, number]
                        values:
                          type: array
                          items:
                            type: string
                          description: Allowed values for `enum`-type facts.
                        valueLabels:
                          type: object
                          additionalProperties:
                            type: string
                          description: Human-readable labels for each allowed value.
                  profileUsed:
                    type: array
                    description: Profile rules applied (currently always empty).
                    items:
                      type: object
                  citations:
                    type: array
                    description: EU law citations backing the answer.
                    items:
                      type: object
                      required: [celex, ref]
                      properties:
                        celex:
                          type: string
                        ref:
                          type: string
                        quote:
                          type: string
                        url:
                          type: string
                          format: uri
                  ruleIds:
                    type: array
                    items:
                      type: string
                    description: |
                      Versioned rule IDs in the format
                      `<celex>#<rule-name>@<version>` (e.g.
                      `32023R1542#duty.storage@1`).
                  asOf:
                    type: string
                    format: date
                    description: Date the rules were current on (YYYY-MM-DD).
                  ruleStatus:
                    type: string
                    enum: [verified, needs_recheck, draft]
                    description: Confidence in the rules behind this answer.
                  recheckSince:
                    type: string
                    format: date
                    description: |
                      Present when `ruleStatus` is `needs_recheck`; the date
                      the governing act was updated.
                  machineTranslated:
                    type: boolean
                    description: Whether the answer text was machine-translated.
                  disclaimer:
                    type: string
              example:
                status: answered
                answer: "Industrial batteries matching the facts you gave need a battery passport from 18 February 2027 (Battery Regulation (EU) 2023/1542, Art. 77(1)). …"
                locale: en
                data:
                  inScope: "yes"
                  verdict: in_scope
                  governing:
                    celex: "32023R1542"
                    name: "Battery Regulation (EU) 2023/1542"
                    verdict: in_scope
                    citation:
                      ref: "Art. 77(1)"
                      quote: "From 18 February 2027 each LMT battery, each industrial battery with a capacity greater than 2 kWh … shall have an electronic record ('battery passport')."
                    appliesFrom:
                      date: "2027-02-18"
                      precision: day
                    level:
                      value: item
                      ref: "Art. 77(2)"
                    role:
                      asked: importer
                      actTerm: importer
                      definedIn: "Art. 3(1)(64)"
                      placingOperator: true
                    duties:
                      - id: "32023R1542#duty.unique_identifier@1"
                        holder: placing_operator
                        ref: "Art. 77(3)"
                        status: active
                  alsoApplies: []
                ask:
                  - fact: battery.incorporatedIn
                    question: "Is it built into military equipment, equipment sent into space, or equipment specifically designed for nuclear installations?"
                    blocking: false
                    act: "32023R1542"
                    type: enum
                    values: [none, military, space, nuclear_safety, other]
                profileUsed: []
                citations:
                  - celex: "32023R1542"
                    ref: "Art. 77(1)"
                    quote: "From 18 February 2027 each LMT battery…"
                    url: "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32023R1542"
                ruleIds:
                  - "32023R1542#duty.unique_identifier@1"
                  - "32023R1542#industrial@3"
                asOf: "2026-10-09"
                ruleStatus: verified
                machineTranslated: false
                disclaimer: "Regulatory information as of 9 Oct 2026, not legal advice. Check the cited articles before relying on it. — TracePass"
        "400":
          description: |
            Invalid input. The `field` property names the offending field;
            `allowed` lists the valid values when the field is an enum.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: invalid_input
                field: category
                message: "Unknown category 'sprockets'. Allowed values: battery, textile, electronics, …"
                allowed:
                  - battery
                  - textile
                  - electronics
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: |
            Product or passport not found, or belongs to another company.
            Returned when `productId` or `passportId` is provided but
            does not resolve to a resource owned by the authenticated
            company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: not_found
        "429":
          description: |
            Daily scope-check cap reached. `Retry-After` header gives
            the seconds until midnight UTC reset.
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the daily cap resets (midnight UTC).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: scope_answer_cap
                limit: 50
                resetAt: "2026-10-12T00:00:00.000Z"
        "503":
          description: Scope rules temporarily unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: scope_rules_unavailable

  /api/v1/scope/catalogue:
    get:
      operationId: scopeCatalogue
      tags: [Scope]
      summary: Get the catalogue scope report.
      description: |
        Returns the most recent catalogue scope run results: for each
        passport/product pair evaluated, whether it is in scope, the
        applicable regulation article, and whether the scope status
        changed since the last run.

        Paginated via cursor. Each page counts as one v1 passport read
        against the daily cap.

        Auth: Bearer API key OR OAuth with `passports:read`. Requires the
        Growth plan or higher (`scopeCatalogueEnabled`); returns 403 with
        `error: plan_upgrade_required` when the plan gate is not met.
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:read
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: "`Bearer <api-key>` or `Bearer <access-token>`."
        - name: classification
          in: query
          schema:
            type: string
            enum: [missing, draft_only, published_incomplete, published, not_required, open_questions, not_covered]
          description: Filter results by passport classification.
        - name: category
          in: query
          schema:
            type: string
          description: Filter results by product category key.
        - name: changed
          in: query
          schema:
            type: boolean
          description: When `true`, return only passports whose scope status changed.
        - name: cursor
          in: query
          schema:
            type: string
          description: Opaque pagination cursor from the previous page's `nextCursor`.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          description: Number of items per page (1–100, default 20).
      responses:
        "200":
          description: Catalogue scope report page.
          content:
            application/json:
              schema:
                type: object
                required: [run, running, totals, items, nextCursor, disclaimer]
                properties:
                  run:
                    type: object
                    description: Metadata of the most recent completed run.
                    required: [id, finishedAt, rulesFingerprint]
                    properties:
                      id:
                        type: string
                      finishedAt:
                        type: string
                        format: date-time
                      rulesFingerprint:
                        type: string
                        description: Hash of the rule set used for this run.
                  running:
                    type: [object, "null"]
                    description: |
                      The currently active run when one is queued or running;
                      `null` when no run is in progress.
                    properties:
                      id:
                        type: string
                      status:
                        type: string
                        enum: [queued, running]
                      trigger:
                        type: string
                        enum: [manual, nightly, rules_change]
                      createdAt:
                        type: string
                        format: date-time
                  totals:
                    type: object
                    required: [rows, byClassification, tiles]
                    properties:
                      rows:
                        type: integer
                        description: Total number of product rows evaluated.
                      byClassification:
                        type: object
                        description: Product count per passport classification.
                        properties:
                          missing:
                            type: integer
                          draft_only:
                            type: integer
                          published_incomplete:
                            type: integer
                          published:
                            type: integer
                          not_required:
                            type: integer
                          open_questions:
                            type: integer
                          not_covered:
                            type: integer
                      tiles:
                        type: object
                        description: Product count per dashboard tile (includes `changed`).
                        properties:
                          now:
                            type: integer
                          later:
                            type: integer
                          waiting:
                            type: integer
                          not_required:
                            type: integer
                          depends:
                            type: integer
                          not_covered:
                            type: integer
                          changed:
                            type: integer
                  items:
                    type: array
                    items:
                      type: object
                      required: [productId, productName, category, gtin, passportIds, passportCount, publishedCount, summary, classification, changed, changedAt]
                      properties:
                        productId:
                          type: string
                        productName:
                          type: string
                        category:
                          type: string
                        gtin:
                          type: [string, "null"]
                        passportIds:
                          type: array
                          items:
                            type: string
                        passportCount:
                          type: integer
                        publishedCount:
                          type: integer
                        summary:
                          type: object
                          description: Condensed scope result for this product.
                          properties:
                            status:
                              type: string
                              enum: [answered, needs_input, not_covered]
                            inScope:
                              type: [string, "null"]
                              enum: [yes, no, depends, pending, null]
                            governing:
                              type: object
                              description: Primary governing act (at minimum `celex`; may carry `appliesFrom`).
                              additionalProperties: true
                            alsoApplies:
                              type: array
                              items:
                                type: object
                            openQuestions:
                              type: array
                              items:
                                type: string
                              description: Fact keys the answer still needs.
                            ruleStatus:
                              type: string
                              enum: [verified, needs_recheck, draft]
                        classification:
                          type: string
                          enum: [missing, draft_only, published_incomplete, published, not_required, open_questions, not_covered]
                        changed:
                          type: boolean
                          description: Whether this product's classification changed in the last run.
                        changedAt:
                          type: [string, "null"]
                          format: date-time
                          description: Timestamp of the last classification change.
                  nextCursor:
                    type: [string, "null"]
                    description: Opaque pagination cursor for the next page; `null` on the last page.
                  disclaimer:
                    type: string
              example:
                run:
                  id: "6acbb533c5553f06d833479e"
                  finishedAt: "2026-10-11T16:11:31.619Z"
                  rulesFingerprint: "86a23cce6480bffb91d360b7a199b473"
                running: null
                totals:
                  rows: 1
                  byClassification:
                    missing: 0
                    draft_only: 0
                    published_incomplete: 0
                    published: 0
                    not_required: 0
                    open_questions: 1
                    not_covered: 0
                  tiles:
                    now: 0
                    later: 0
                    waiting: 0
                    not_required: 0
                    depends: 1
                    not_covered: 0
                    changed: 0
                items:
                  - productId: "6acbb529ef6a5037c63fff80"
                    productName: "e2e-fixture product"
                    category: battery
                    gtin: "09506000E2EFIX"
                    passportIds: ["6acbb529ef6a5037c63fff81"]
                    passportCount: 1
                    publishedCount: 1
                    summary:
                      status: needs_input
                      inScope: depends
                      governing:
                        celex: "32023R1542"
                      alsoApplies: []
                      openQuestions: [battery.sealed, battery.weightKg, battery.capacityKWh, battery.use, battery.vehicleCategory]
                      ruleStatus: verified
                    classification: open_questions
                    changed: false
                    changedAt: null
                nextCursor: null
                disclaimer: "Regulatory information as of 9 Oct 2026, not legal advice. Check the cited articles before relying on it. — TracePass"
        "400":
          description: |
            Invalid query parameter. The `field` property names the
            offending parameter; `allowed` lists valid values for enums.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: invalid_input
                field: classification
                message: "Unknown classification value."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Plan upgrade required. The catalogue scope check is available
            from the Growth plan.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: plan_upgrade_required
                feature: scopeCatalogueEnabled
                message: "The catalogue scope check is available from the Growth plan."
        "429":
          description: Daily passport-read cap reached.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /api/v1/scope/catalogue/runs:
    post:
      operationId: startCatalogueRun
      tags: [Scope]
      summary: Trigger a new catalogue scope run.
      description: |
        Enqueues a full re-evaluation of every passport in the company's
        catalogue against the current scope rules. Returns 202 immediately
        with a `runId`; poll `GET /api/v1/scope/catalogue/runs/{id}` for
        status. Once `status` is `done`, the updated results are available
        via `GET /api/v1/scope/catalogue`.

        Idempotent within a short window: supply the same
        `Idempotency-Key` header to avoid double-triggering a run. A
        409 is returned if a run is already in progress for this
        company. A 429 with `error: run_cooldown` is returned when a
        run completed too recently.

        Auth: Bearer API key OR OAuth with `passports:read`. Requires
        the Growth plan or higher.
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:read
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: "`Bearer <api-key>` or `Bearer <access-token>`."
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
          description: |
            Client-generated key (UUID recommended). The server returns the
            same 202 response for duplicate requests with the same key
            within the idempotency window.
      responses:
        "202":
          description: Run accepted and queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  runId:
                    type: string
                  status:
                    type: string
                    enum: [queued]
              example:
                runId: "6728abc123def456789abcde"
                status: queued
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Plan upgrade required. The catalogue scope check is available
            from the Growth plan.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: plan_upgrade_required
                feature: scopeCatalogueEnabled
                message: "The catalogue scope check is available from the Growth plan."
        "409":
          description: A catalogue run is already queued or running for this company.
          content:
            application/json:
              schema:
                type: object
                required: [error, message]
                properties:
                  error:
                    type: string
                    enum: [run_in_progress]
                  message:
                    type: string
                  runId:
                    type: [string, "null"]
                    description: ID of the active run, when known.
                  run:
                    type: [object, "null"]
                    description: Serialised active run (same shape as GET /runs/{id} 200), when available.
                    additionalProperties: true
              example:
                error: run_in_progress
                message: "A catalogue run is already queued or running."
                runId: "6acbb533c5553f06d833479e"
                run: null
        "429":
          description: |
            Run cooldown: a run completed too recently. `Retry-After` header
            gives the seconds until the cooldown expires.
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the run cooldown expires.
          content:
            application/json:
              schema:
                type: object
                required: [error, message, retryAfter, run]
                properties:
                  error:
                    type: string
                    enum: [run_cooldown]
                  message:
                    type: string
                  retryAfter:
                    type: integer
                    description: Seconds until the cooldown expires (mirrors the `Retry-After` header).
                  run:
                    type: object
                    description: The completed run that triggered the cooldown (same shape as GET /runs/{id} 200).
                    additionalProperties: true
              example:
                error: run_cooldown
                message: "A catalogue run finished a few minutes ago. Try again shortly."
                retryAfter: 600
                run:
                  id: "6acbb533c5553f06d833479e"
                  trigger: manual
                  status: done
                  rulesFingerprint: "86a23cce6480bffb91d360b7a199b473"
                  counts:
                    evaluated: 1
                    distinctFactSets: 1
                    changed: 1
                    removed: 0
                    answersChanged: 0
                  createdAt: "2026-10-11T16:11:31.583Z"
                  startedAt: "2026-10-11T16:11:31.588Z"
                  finishedAt: "2026-10-11T16:11:31.619Z"
                  error: null

  /api/v1/scope/catalogue/runs/{id}:
    get:
      operationId: getCatalogueRun
      tags: [Scope]
      summary: Get the status and counts of a catalogue scope run.
      description: |
        Poll this endpoint after `POST /api/v1/scope/catalogue/runs` until
        `status` is `done` or `failed`. Once done, the fresh results are
        available via `GET /api/v1/scope/catalogue`. A run belonging to
        another company returns 404. Does not count against daily caps.

        Auth: Bearer API key OR OAuth with `passports:read`. Requires
        the Growth plan or higher.
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:read
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: "`Bearer <api-key>` or `Bearer <access-token>`."
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The run ID from `POST /api/v1/scope/catalogue/runs`.
          example: "6728abc123def456789abcde"
      responses:
        "200":
          description: Run status and counts.
          content:
            application/json:
              schema:
                type: object
                required: [id, trigger, status, rulesFingerprint, counts, createdAt, startedAt, finishedAt, error]
                properties:
                  id:
                    type: string
                    description: Run ID.
                  trigger:
                    type: string
                    enum: [manual, nightly, rules_change]
                    description: What triggered the run.
                  status:
                    type: string
                    enum: [queued, running, done, failed]
                  rulesFingerprint:
                    type: [string, "null"]
                    description: Hash of the rule set used. `null` when the run has not started.
                  counts:
                    type: [object, "null"]
                    description: Per-run evaluation counts; `null` until the run finishes.
                    properties:
                      evaluated:
                        type: integer
                        description: Total fact-sets evaluated.
                      distinctFactSets:
                        type: integer
                        description: Distinct fact-set fingerprints seen.
                      changed:
                        type: integer
                        description: Products whose classification changed.
                      removed:
                        type: integer
                        description: Products removed from the catalogue since the prior run.
                      answersChanged:
                        type: integer
                        description: Products whose scope answer changed.
                  createdAt:
                    type: string
                    format: date-time
                    description: When the run was enqueued.
                  startedAt:
                    type: [string, "null"]
                    format: date-time
                    description: When the run started processing; `null` when still queued.
                  finishedAt:
                    type: [string, "null"]
                    format: date-time
                    description: When the run completed; `null` when still running.
                  error:
                    type: [string, "null"]
                    description: Error message when `status` is `failed`; otherwise `null`.
              example:
                id: "6acbb533c5553f06d833479e"
                trigger: manual
                status: done
                rulesFingerprint: "86a23cce6480bffb91d360b7a199b473"
                counts:
                  evaluated: 1
                  distinctFactSets: 1
                  changed: 1
                  removed: 0
                  answersChanged: 0
                createdAt: "2026-10-11T16:11:31.583Z"
                startedAt: "2026-10-11T16:11:31.588Z"
                finishedAt: "2026-10-11T16:11:31.619Z"
                error: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Plan upgrade required. The catalogue scope check is available
            from the Growth plan.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: plan_upgrade_required
                feature: scopeCatalogueEnabled
                message: "The catalogue scope check is available from the Growth plan."
        "404":
          description: Run not found (or belongs to another company).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: not_found

  # =====================================================================
  # Regulatory assistant — required fields, changes, guidance
  # =====================================================================

  /api/v1/regulatory/required-fields:
    get:
      operationId: getRegulatoryRequiredFields
      tags: [Regulatory]
      summary: Required DPP fields for a category with EU article citations.
      description: |
        Returns the mandatory fields for a DPP product category, each
        annotated with the EU regulation article that requires it and —
        for battery passports — the role that holds the field
        (manufacturer / importer / distributor).

        Auth: Bearer API key OR OAuth with `regulatory:read` OR
        `passports:read`. Counts as one scope answer against the daily cap
        when called via the assistant channel (`X-Source: assistant`).

        **Account gate (OAuth + `X-Source: assistant` only):** the
        requesting user's email must be verified and not from a disposable
        domain. API-key callers bypass this gate.
      security:
        - BearerAuth: []
        - OAuth2:
            - regulatory:read
        - OAuth2:
            - passports:read
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: "`Bearer <api-key>` or `Bearer <access-token>`."
        - name: category
          in: query
          required: true
          schema:
            type: string
          description: |
            Template category key (e.g. `battery`, `textile`,
            `electronics`). Must be a key returned by
            `GET /api/v1/templates`.
          example: battery
        - name: group
          in: query
          required: false
          schema:
            type: string
          description: |
            Optional sub-group filter (e.g. `PortableIndustrialBattery`).
            When omitted, all groups for the category are returned.
        - name: subType
          in: query
          required: false
          schema:
            type: string
          description: |
            Optional sub-type filter within a group (e.g. `LFP`). When
            omitted, all sub-types are returned.
        - name: locale
          in: query
          required: false
          schema:
            type: string
            default: en
            enum: [en, bg, de, it]
          description: Language for field labels and article descriptions.
      responses:
        "200":
          description: Required fields with EU citations.
          content:
            application/json:
              schema:
                type: object
                required: [category, fieldCount, requiredFields, ruleIds]
                properties:
                  category:
                    type: string
                    description: The requested category key.
                  fieldCount:
                    type: integer
                    description: Total number of required fields returned.
                  requiredFields:
                    type: array
                    items:
                      type: object
                      required: [key, label, regulation, article]
                      properties:
                        key:
                          type: string
                          description: Field key (matches the template schema).
                        label:
                          type: string
                          description: Human-readable field name in the requested locale.
                        regulation:
                          type: string
                          description: |
                            Primary regulation name (e.g. `EU 2023/1542 (Battery
                            Regulation)`).
                        article:
                          type: string
                          description: |
                            The specific article reference (e.g. `Art. 10(1)(a)`).
                        holder:
                          type: [string, "null"]
                          description: |
                            Supply-chain role that holds this field; `null` when
                            not role-specific.
                          enum: [manufacturer, importer, distributor, null]
                  ruleIds:
                    type: array
                    items:
                      type: string
                    description: |
                      Rule identifiers backing this answer — pass back to
                      `POST /api/v1/scope/reports` to route a correction.
              example:
                category: battery
                fieldCount: 2
                requiredFields:
                  - key: carbonFootprint
                    label: Carbon footprint
                    regulation: "EU 2023/1542 (Battery Regulation)"
                    article: "Art. 10(1)(a)"
                    holder: manufacturer
                  - key: energyDensity
                    label: Energy density
                    regulation: "EU 2023/1542 (Battery Regulation)"
                    article: "Art. 10(1)(c)"
                    holder: manufacturer
                ruleIds:
                  - "32023R1542#field.carbonFootprint@1"
                  - "32023R1542#field.energyDensity@1"
        "400":
          description: |
            Invalid or missing `category` (or invalid `group`/`subType`).
            `allowed` lists the valid values.
          content:
            application/json:
              schema:
                type: object
                required: [error, field, message, allowed]
                properties:
                  error:
                    type: string
                    enum: [invalid_input]
                  field:
                    type: string
                  message:
                    type: string
                  allowed:
                    type: array
                    items:
                      type: string
              example:
                error: invalid_input
                field: category
                message: "Invalid option: expected one of \"battery\"|\"textile\"|..."
                allowed: [battery, textile, electronics, furniture]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Account not eligible (OAuth + `X-Source: assistant` only).
            `reason` is `email_not_verified` or `disposable_email`.
          content:
            application/json:
              schema:
                type: object
                required: [error, reason]
                properties:
                  error:
                    type: string
                    enum: [account_not_eligible]
                  reason:
                    type: string
                    enum: [email_not_verified, disposable_email]
              example:
                error: account_not_eligible
                reason: email_not_verified
        "429":
          description: |
            Daily scope-answer cap reached for this company. The counter
            resets at 00:00 UTC.
          content:
            application/json:
              schema:
                type: object
                required: [error, resetsAt]
                properties:
                  error:
                    type: string
                    enum: [scope_answer_cap]
                  resetsAt:
                    type: string
                    format: date-time
              example:
                error: scope_answer_cap
                resetsAt: "2026-10-12T00:00:00.000Z"
        "503":
          description: |
            Regulatory data temporarily unavailable. Retry after the
            `Retry-After` header value (seconds).
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
                    enum: [regulatory_data_unavailable]
              example:
                error: regulatory_data_unavailable

  /api/v1/regulatory/changes:
    get:
      operationId: getRegulatoryChanges
      tags: [Regulatory]
      summary: Recent regulatory changes for one or more product categories.
      description: |
        Returns a summary of what changed in law and standards for the
        specified product categories since a given date. Useful for keeping
        a compliance profile up-to-date and for answering "what changed?"
        questions.

        Auth: Bearer API key OR OAuth with `regulatory:read` OR
        `passports:read`. Counts as one scope answer.
      security:
        - BearerAuth: []
        - OAuth2:
            - regulatory:read
        - OAuth2:
            - passports:read
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: "`Bearer <api-key>` or `Bearer <access-token>`."
        - name: categories
          in: query
          required: false
          schema:
            type: array
            items:
              type: string
          style: form
          explode: false
          description: |
            Comma-separated list of category keys. When omitted, returns
            changes across all categories in the regulatory data feed.
          example: "battery,textile"
        - name: since
          in: query
          required: false
          schema:
            type: string
            format: date
          description: |
            ISO-8601 date. Only changes published on or after this date
            are returned. Defaults to 90 days ago.
          example: "2026-01-01"
        - name: locale
          in: query
          required: false
          schema:
            type: string
            default: en
            enum: [en, bg, de, it]
          description: Language for labels and summaries.
      responses:
        "200":
          description: Recent regulatory changes.
          content:
            application/json:
              schema:
                type: object
                required: [since, changes, ruleIds]
                properties:
                  since:
                    type: string
                    format: date
                    description: The effective `since` filter applied.
                  changes:
                    type: array
                    items:
                      type: object
                      required: [category, date, summary, instrument]
                      properties:
                        category:
                          type: string
                          description: Affected product category key.
                        date:
                          type: string
                          format: date
                          description: Date the change was published or entered into force.
                        summary:
                          type: string
                          description: |
                            Short plain-language summary of what changed, in the
                            requested locale.
                        instrument:
                          type: string
                          description: |
                            Regulation or standard that changed (e.g. `EU 2023/1542`).
                        article:
                          type: [string, "null"]
                          description: Article reference when the change is article-specific.
                  ruleIds:
                    type: array
                    items:
                      type: string
              example:
                since: "2026-01-01"
                changes:
                  - category: battery
                    date: "2026-02-18"
                    summary: "Carbon footprint thresholds under Art. 10(3) were clarified in Commission Delegated Regulation (EU) 2026/287."
                    instrument: "EU 2026/287"
                    article: "Art. 10(3)"
                ruleIds:
                  - "32026R0287#change.art10_3@1"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Account not eligible (OAuth + `X-Source: assistant` only).
          content:
            application/json:
              schema:
                type: object
                required: [error, reason]
                properties:
                  error: { type: string, enum: [account_not_eligible] }
                  reason: { type: string, enum: [email_not_verified, disposable_email] }
        "429":
          description: Daily scope-answer cap reached.
          content:
            application/json:
              schema:
                type: object
                required: [error, resetsAt]
                properties:
                  error: { type: string, enum: [scope_answer_cap] }
                  resetsAt: { type: string, format: date-time }
        "503":
          description: Regulatory data temporarily unavailable.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error: { type: string, enum: [regulatory_data_unavailable] }

  /api/v1/regulatory/registry-guidance:
    get:
      operationId: getRegulatoryRegistryGuidance
      tags: [Regulatory]
      summary: How to register and which identifiers/data carriers to use.
      description: |
        Returns guidance on DPP registry registration, identifier schemes
        (GTIN, serial, GS1 Digital Link), data carrier requirements (QR
        code, NFC, RFID), and where a passport is actually created for a
        given topic.

        Auth: Bearer API key OR OAuth with `regulatory:read` OR
        `passports:read`. Counts as one scope answer.
      security:
        - BearerAuth: []
        - OAuth2:
            - regulatory:read
        - OAuth2:
            - passports:read
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: "`Bearer <api-key>` or `Bearer <access-token>`."
        - name: topic
          in: query
          required: true
          schema:
            type: string
            enum:
              - registration
              - identifiers
              - data_carriers
              - qr_code
              - nfc_rfid
              - gs1_digital_link
              - access_rights
          description: |
            The guidance topic to retrieve. One of: `registration`,
            `identifiers`, `data_carriers`, `qr_code`, `nfc_rfid`,
            `gs1_digital_link`, `access_rights`.
          example: registration
        - name: category
          in: query
          required: false
          schema:
            type: string
          description: |
            Optional product category to tailor the guidance (e.g.
            `battery` returns battery-specific registry requirements
            alongside the general guidance).
          example: battery
        - name: locale
          in: query
          required: false
          schema:
            type: string
            default: en
            enum: [en, bg, de, it]
          description: Language for the guidance text.
      responses:
        "200":
          description: Registry guidance for the requested topic.
          content:
            application/json:
              schema:
                type: object
                required: [topic, guidance, sources, ruleIds]
                properties:
                  topic:
                    type: string
                    description: The topic as requested.
                  guidance:
                    type: string
                    description: |
                      Plain-language guidance text in the requested locale,
                      including relevant article and annex references.
                  sources:
                    type: array
                    items:
                      type: object
                      required: [instrument, article]
                      properties:
                        instrument:
                          type: string
                        article:
                          type: string
                  ruleIds:
                    type: array
                    items:
                      type: string
              example:
                topic: registration
                guidance: "Manufacturers placing batteries on the EU market must register in the EU Battery Registry before the first placement. The registration obligation applies from 18 August 2024 under Art. 47 of EU 2023/1542."
                sources:
                  - instrument: "EU 2023/1542"
                    article: "Art. 47"
                ruleIds:
                  - "32023R1542#guidance.registration@1"
        "400":
          description: |
            Missing or invalid `topic`. `allowed` lists valid values.
          content:
            application/json:
              schema:
                type: object
                required: [error, field, message, allowed]
                properties:
                  error: { type: string, enum: [invalid_input] }
                  field: { type: string }
                  message: { type: string }
                  allowed: { type: array, items: { type: string } }
              example:
                error: invalid_input
                field: topic
                message: "Invalid option: expected one of \"registration\"|\"identifiers\"|..."
                allowed: [registration, identifiers, data_carriers, qr_code, nfc_rfid, gs1_digital_link, access_rights]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Account not eligible (OAuth + `X-Source: assistant` only).
          content:
            application/json:
              schema:
                type: object
                required: [error, reason]
                properties:
                  error: { type: string, enum: [account_not_eligible] }
                  reason: { type: string, enum: [email_not_verified, disposable_email] }
        "429":
          description: Daily scope-answer cap reached.
          content:
            application/json:
              schema:
                type: object
                required: [error, resetsAt]
                properties:
                  error: { type: string, enum: [scope_answer_cap] }
                  resetsAt: { type: string, format: date-time }
        "503":
          description: Regulatory data temporarily unavailable.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error: { type: string, enum: [regulatory_data_unavailable] }

  # =====================================================================
  # Company compliance profile
  # =====================================================================

  /api/v1/company/compliance-profile:
    get:
      operationId: getComplianceProfile
      tags: [Regulatory]
      summary: Read the company compliance profile.
      description: |
        Returns the stored compliance profile for the authenticated
        company — the company facts the DPP Assistant uses to tailor its
        answers (role, product categories, EU markets, size, sales channels,
        data systems). Returns `{ "complianceProfile": {} }` when no profile
        has been saved yet.

        Auth: Bearer API key OR OAuth with `regulatory:read` OR
        `passports:read`. Does not count as a scope answer.
      security:
        - BearerAuth: []
        - OAuth2:
            - regulatory:read
        - OAuth2:
            - passports:read
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: "`Bearer <api-key>` or `Bearer <access-token>`."
      responses:
        "200":
          description: Company compliance profile (may be empty).
          content:
            application/json:
              schema:
                type: object
                required: [complianceProfile]
                properties:
                  complianceProfile:
                    type: object
                    description: |
                      The stored profile. Empty object `{}` when nothing
                      has been saved yet.
                    properties:
                      role:
                        type: string
                        enum: [manufacturer, importer, distributor, authorised_representative, fulfilment_service_provider]
                      ownBrand:
                        type: boolean
                      categories:
                        type: array
                        items: { type: string }
                      markets:
                        type: array
                        items: { type: string }
                        description: ISO-3166-1 alpha-2 codes or `EU`.
                      sizeBand:
                        type: string
                        enum: [sme, large]
                      salesChannels:
                        type: array
                        items:
                          type: string
                          enum: [own_shop, marketplace, retail, b2b, fulfilment]
                      dataSystems:
                        type: string
                        maxLength: 200
                      products:
                        type: array
                        maxItems: 50
                        items:
                          type: object
                          required: [name, category]
                          properties:
                            name: { type: string }
                            category: { type: string }
                            facts:
                              type: object
                              additionalProperties: {}
                  updatedAt:
                    type: string
                    format: date-time
                    description: When the profile was last updated; absent when empty.
              examples:
                populated:
                  summary: Profile with stored facts
                  value:
                    complianceProfile:
                      role: manufacturer
                      categories: [battery]
                      markets: [EU]
                      sizeBand: sme
                    updatedAt: "2026-10-11T19:01:12.152Z"
                empty:
                  summary: No profile saved yet
                  value:
                    complianceProfile: {}
        "401":
          $ref: "#/components/responses/Unauthorized"
    patch:
      operationId: patchComplianceProfile
      tags: [Regulatory]
      summary: Update (merge) the company compliance profile.
      description: |
        Merges the provided fields into the stored compliance profile and
        returns the updated profile with `updatedAt`. Fields not included
        in the request are left unchanged. Set a field to `null` to remove
        it.

        Auth: Bearer API key OR OAuth with `passports:write`. For OAuth
        principals, the user must have the `admin` or `owner` role.
        API-key callers are not role-gated. Does not count as a scope
        answer.
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: "`Bearer <api-key>` or `Bearer <access-token>`."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                role:
                  type: [string, "null"]
                  enum: [manufacturer, importer, distributor, authorised_representative, fulfilment_service_provider, null]
                  description: Company's role in the supply chain.
                ownBrand:
                  type: [boolean, "null"]
                  description: Whether the company sells under its own brand.
                categories:
                  type: [array, "null"]
                  items: { type: string }
                  description: Template category keys the company deals with.
                markets:
                  type: [array, "null"]
                  items: { type: string }
                  description: ISO-3166-1 alpha-2 codes or `EU` for all EU markets.
                sizeBand:
                  type: [string, "null"]
                  enum: [sme, large, null]
                  description: Company size.
                salesChannels:
                  type: [array, "null"]
                  items:
                    type: string
                    enum: [own_shop, marketplace, retail, b2b, fulfilment]
                dataSystems:
                  type: [string, "null"]
                  maxLength: 200
                  description: Short description of data systems (ERP, PIM, etc.).
                products:
                  type: [array, "null"]
                  maxItems: 50
                  description: Named products the company sells, with facts learned about each. Replaces the stored list.
                  items:
                    type: object
                    required: [name, category]
                    properties:
                      name:
                        type: string
                        minLength: 1
                        maxLength: 200
                      category:
                        type: string
                        minLength: 1
                        maxLength: 80
                        description: A template category key.
                      facts:
                        type: object
                        additionalProperties: {}
                        description: Product-specific facts, keyed by fact name.
            example:
              role: manufacturer
              categories: [battery]
              markets: [EU]
              sizeBand: sme
      responses:
        "200":
          description: Updated compliance profile.
          content:
            application/json:
              schema:
                type: object
                required: [complianceProfile, updatedAt]
                properties:
                  complianceProfile:
                    type: object
                    description: The full profile after the merge.
                  updatedAt:
                    type: string
                    format: date-time
              example:
                complianceProfile:
                  role: manufacturer
                  categories: [battery]
                  markets: [EU]
                  sizeBand: sme
                updatedAt: "2026-10-11T19:01:12.152Z"
        "400":
          description: |
            Invalid field value: wrong type, an enum miss, or a string /
            url value that breaks the template's `pattern`, `minLength` or
            `maxLength`. `field` names the offending key; `allowed` lists
            valid options for enum fields.
          content:
            application/json:
              schema:
                type: object
                required: [error, field, message]
                properties:
                  error: { type: string, enum: [invalid_input] }
                  field: { type: string }
                  message: { type: string }
                  allowed: { type: array, items: { type: string } }
              example:
                error: invalid_input
                field: role
                message: "Invalid option: expected one of \"manufacturer\"|\"importer\"|..."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Admin or owner role required (OAuth principals only).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: Forbidden
                message: "admin role required to update the compliance profile"
    delete:
      operationId: deleteComplianceProfile
      tags: [Regulatory]
      summary: Delete the company compliance profile.
      description: |
        Removes the entire `complianceProfile` sub-document from the
        company record. Returns `{ "deleted": true }` on success. After
        deletion, `GET /api/v1/company/compliance-profile` returns an
        empty profile. Does not count as a scope answer.

        Auth: Bearer API key OR OAuth with `passports:write`. For OAuth
        principals, the user must have the `admin` or `owner` role.
      security:
        - BearerAuth: []
        - OAuth2:
            - passports:write
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: "`Bearer <api-key>` or `Bearer <access-token>`."
      responses:
        "200":
          description: Profile deleted.
          content:
            application/json:
              schema:
                type: object
                required: [deleted]
                properties:
                  deleted:
                    type: boolean
                    enum: [true]
              example:
                deleted: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Admin or owner role required (OAuth principals only).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: Forbidden
                message: "admin role required to delete the compliance profile"

  # =====================================================================
  # Scope answer reports
  # =====================================================================

  /api/v1/scope/reports:
    post:
      operationId: postScopeReport
      tags: [Scope]
      summary: Report a wrong or unclear scope or regulatory answer.
      description: |
        Submits a correction report to a review queue. Include the
        `ruleIds` from the response envelope of the scope-check or
        regulatory-assistant call to route the report quickly. Does not
        count as a scope answer.

        Auth: Bearer API key OR OAuth with `regulatory:read` OR
        `passports:read`.
      security:
        - BearerAuth: []
        - OAuth2:
            - regulatory:read
        - OAuth2:
            - passports:read
      parameters:
        - name: Authorization
          in: header
          required: true
          schema:
            type: string
          description: "`Bearer <api-key>` or `Bearer <access-token>`."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [what]
              properties:
                what:
                  type: string
                  description: |
                    Plain-language description of what is wrong or unclear.
                    Include the product type, the facts used, and what the
                    answer said that was incorrect.
                ruleIds:
                  type: array
                  items: { type: string }
                  description: |
                    Rule IDs from the `ruleIds` array in the response
                    envelope that the report concerns.
                channel:
                  type: string
                  enum: [api, mcp, assistant, dashboard]
                  description: Channel the original answer came from.
            example:
              what: "The answer said toy safety applies from 2030-08-01 but I believe the date for products already on the market is different."
              ruleIds: ["32025R2509#duty.dpp_toys@1"]
              channel: assistant
      responses:
        "201":
          description: Report created.
          content:
            application/json:
              schema:
                type: object
                required: [id]
                properties:
                  id:
                    type: string
                    description: Report ID.
              example:
                id: "6acbdcfd8683429825453952"
        "400":
          description: Missing or invalid `what` field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error: invalid_input
                field: what
                message: "Invalid input: expected string, received undefined"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # =====================================================================
  # 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)
            regulatory:read: Check product scope against EU DPP regulations
            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. A second request with the same key while the first is
        still running returns 409 `idempotency_key_in_use`; retry
        with the same key once the first has finished.

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

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

    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:
    ResourceId:
      type: string
      pattern: "^[a-f0-9]{24}$"
      example: "6650a1b2c3d4e5f6a7b8c9d0"
      description: A TracePass resource ID — 24 lowercase hexadecimal characters.

    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
        tradeName:
          type: string
          maxLength: 200
          description: |
            Registered trade name or registered trade mark. For a battery
            importer, one of the details it must mark on the battery (Battery
            Regulation Art. 41(3)). Shown on the public passport.
          example: "EuroBat"
        postalAddress:
          type: string
          maxLength: 500
          description: |
            Postal address with a single contact point, as one line. Shown on
            the public passport.
          example: "Industriestraße 42, 80339 Munich, Germany"
        email:
          type: string
          format: email
          description: |
            Contact e-mail address. Shown on the public passport, so use a
            company contact address, not a person's.
          example: "compliance@example.com"
        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`). An LEI (iso6523,
            icd `0199`) is also verified against the GLEIF register (see
            `upsertParty`). 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
      description: |
        `marked_for_deletion` is the first step of a deletion
        (`POST /api/v1/passports/{id}/mark-for-deletion`); a marked
        passport can no longer be edited. `deleted` appears only in
        the response of the `DELETE /api/v1/passports/{id}` that
        deletes it: a deleted passport is never returned by a read,
        and every v1 call on it answers 404.
      enum:
        - draft
        - in_review
        - approved
        - published
        - suspended
        - expired
        - archived
        - marked_for_deletion
        - deleted

    DeletionReason:
      type: string
      description: |
        Why a passport ends. Required for battery passports when they
        are marked for deletion or archived: under the Battery
        Regulation a battery passport ends only once the battery has
        been recycled (Regulation (EU) 2023/1542, Art. 77(8)).
        `never_placed` means the battery was never placed on the
        market.
      enum: [recycled, never_placed, duplicate_or_error]

    DeletionReasonInput:
      type: object
      description: |
        Optional request body. `reason` is required for battery
        passports and optional for other categories; when given it
        must be one of the listed values.
      properties:
        reason:
          $ref: "#/components/schemas/DeletionReason"

    PassportHardDeleteResult:
      type: object
      description: Receipt of a permanent delete of a never-published passport.
      properties:
        message: { type: string }
        summary:
          type: object
          properties:
            passportId: { type: string }
            serialNumber: { type: string }
            gtin: { type: string }
            deletedCounts:
              type: object
              description: What was removed with the passport.
              properties:
                extractions: { type: integer }
                serviceEvents: { type: integer }
                scanEvents: { type: integer }
                ownershipTransfers: { type: integer }
                supplierRequestRefsRemoved: { type: integer }
                epcisEventRefsRemoved: { type: integer }
                epcisEventsDeleted: { type: integer }
                documentRefsRemoved: { type: integer }
                documentsDeleted: { type: integer }
                filesDeleted: { type: integer }
            dppsActiveDelta:
              type: integer
              enum: [-1, 0]
              description: How the live-active counter moved (always -1 or 0).

    PassportField:
      type: object
      properties:
        value: {}
        source:
          type: string
          enum: [manual, ai_suggested, ai_approved, reference_db, supplier, system, derived]
          description: >-
            Who set the stored value. `derived` is a value calculated from
            other fields by a formula, not written by an AI model.
        aiProvenance:
          $ref: "#/components/schemas/AiProvenance"
        aiTranslated:
          type: boolean
          description: >-
            Present and `true` on a `?lang=` response when the returned value
            is a machine translation (EU AI Act Art. 50(2) marking).
        sourceProvider:
          type: string
          description: >-
            Identifier of the reference source that vouches for an
            AI-suggested value (for example a public register), with
            `sourceUrl` linking to its record where one is published. An open
            string: the set of sources can grow, so treat unknown values as
            "a named source". Absent when no single source vouches for the
            stored value, such as a value read from your own document or
            submitted by a supplier.
          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
              aiProvenance:
                $ref: "#/components/schemas/AiProvenance"
        lastUpdatedAt:
          type: string
          format: date-time
        lastUpdatedBy:
          type: string

    AiProvenance:
      type: object
      description: >-
        Marks a value an AI system generated (EU AI Act Art. 50(2)). It stays
        on the field after a person approves or edits the value; only the two
        review flags change. Public passport outputs (viewer, JSON-LD,
        credential, PDF) mark the `research` and `translation` kinds.
      properties:
        kind:
          type: string
          enum: [research, translation, extraction]
          description: >-
            `research`: found and written by the AI. `translation`: machine
            translated. `extraction`: read from a document you uploaded.
        generatedAt:
          type: string
          format: date-time
        model:
          type: string
          description: The model that generated the value. Returned only to the owning account.
        humanReviewed:
          type: boolean
        humanEdited:
          type: boolean

    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
            - inconsistent_values
        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 (24 hexadecimal characters).
        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]
    RegistryPrepareConflict:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [not_a_battery, not_published, registered_in_production, registry_not_ready, invalid_upi]
        findingDetails:
          type: array
          description: "`registry_not_ready` only: the readiness findings, blocking (`critical`) ones first."
          items:
            type: object
            required: [severity, why]
            properties:
              severity:
                type: string
                enum: [critical, warning]
              why:
                type: string
              fix:
                type: string
              target:
                type: string
              ruleId:
                type: string
    RegistryUploadFile:
      type: object
      required: [xml, filename, included, excluded]
      properties:
        xml:
          type: string
          description: The upload file, to save under `filename` and upload unchanged.
        filename:
          type: string
          description: A name of `a–z A–Z 0–9 . _ -` only, which the Registry requires.
        included:
          type: array
          items:
            type: object
            required: [id, upi]
            properties:
              id:
                $ref: "#/components/schemas/ResourceId"
              upi:
                type: string
                format: uri
        excluded:
          type: array
          items:
            type: object
            required: [id, reason]
            properties:
              id:
                type: string
              reason:
                type: string
                enum:
                  - not_found
                  - not_battery
                  - not_published
                  - registered_in_production
                  - registration_in_progress
                  - readiness_blocked
                  - invalid_upi
                  - duplicate_upi
                  - over_limit
              details:
                type: array
                description: "`readiness_blocked` only: the blocking findings."
                items:
                  type: string
    RegistrationRecord:
      description: What the operator reports from the EU DPP Registry website.
      oneOf:
        - type: object
          title: Production registration
          required: [environment]
          properties:
            environment:
              type: string
              enum: [production]
            placedOnMarketAt:
              type: string
              description: "The date of placing on the market, `YYYY-MM-DD`. Defaults to today; not in the future, not before first publication."
            registrationId:
              type: string
              maxLength: 200
              description: The Registry's registration identifier, if you have it.
        - type: object
          title: Acceptance test
          required: [environment, result]
          properties:
            environment:
              type: string
              enum: [acceptance]
            result:
              type: string
              enum: [passed, failed]
            errorCode:
              type: string
              maxLength: 100
              description: "The Registry's error code, for `failed` (e.g. `SEMANTIC_VALIDATION_UNAVAILABLE`)."
      discriminator:
        propertyName: environment
    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-SEMANTIC /
            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/ResourceId"
        companyId:
          $ref: "#/components/schemas/ResourceId"
        productId:
          $ref: "#/components/schemas/ResourceId"
        templateId:
          $ref: "#/components/schemas/ResourceId"
        identifier:
          allOf:
            - $ref: "#/components/schemas/ProductIdentifier"
          description: |
            Absent on a draft started in the dashboard without its
            identifier ("Add it later"). Such a passport has no QR code and
            cannot be published until the identifier is set; `identifierKey`
            and `gs1` are absent too. Passports created through the API
            always carry one.
        identifierKey:
          type: string
          description: |
            Canonical dedup key in `<scheme>:<parts>` form, e.g.
            `gs1:00012345678905:SN-001` or `did:did:example:123`.
            Globally unique — 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
        archiveReason:
          $ref: "#/components/schemas/DeletionReason"
        markedForDeletionAt:
          type: string
          format: date-time
        markedForDeletionReason:
          $ref: "#/components/schemas/DeletionReason"
        deletedAt:
          type: string
          format: date-time
          description: Set on the passport the soft DELETE returns; a deleted passport is never returned by a read.
        deletedReason:
          $ref: "#/components/schemas/DeletionReason"
        placeOnMarketStatus:
          type: string
          enum: [placing, placed, rejected]
          description: |
            Battery passports: where the production registry entry stands.
            Absent on a passport never placed. `placing` — sent to an
            EN 18222 registry API, waiting for its answer; `placed` —
            registered (`registrationMode` says how it was recorded);
            `rejected` — refused, or the registry could not be reached.
            Only a production result sets this; a test in the acceptance
            Registry never does.
        registrationId:
          type: string
          description: The registration identifier, when the registry returned one (`api`) or the operator entered it (`website`). Set on `placed`.
        placedOnMarketAt:
          type: string
          format: date-time
          description: The date of placing on the market — when the EN 18222 registry accepted the passport (`api`), or the date the operator entered (`website`). Set on `placed`.
        registrationRejectedAt:
          type: string
          format: date-time
          description: When the placement was rejected. Set on `rejected`, cleared by a new attempt.
        registrationRejectionReason:
          type: string
          description: |
            Why the placement was rejected: the registry's own message,
            or a code such as `registry_unreachable` when the registry
            could not be reached once the retries ran out. For a
            `website` registration it is the EU DPP Registry's error code
            from the operator's imported results file (e.g.
            `NOT_RESOLVABLE_UPI`). Set on `rejected`, cleared by a new
            attempt.
        registrationMode:
          type: string
          enum: [api, website]
          description: |
            How the production placement was recorded. `api` — an EN 18222
            registry answered `place-on-market`. `website` — the operator
            registered on the EU DPP Registry website and recorded it
            (`POST /api/v1/passports/{id}/registration`); this is the
            operator's statement, not verified with the Registry.
        registryUpi:
          type: string
          format: uri
          description: |
            The unique product identifier handed to the operator for the
            EU DPP Registry, frozen the first time it was prepared. The
            Registry keeps this URL, so it stays the same even if the
            account's domain changes later.
        registrationPreparedAt:
          type: string
          format: date-time
          description: The latest preparation (address handed over or upload file built). A preparation is not a registration.
        registrationPreparedVia:
          type: string
          enum: [form, file]
          description: "`form` — the address for the Registry's online form; `file` — an upload file."
        registryAcceptanceTest:
          type: object
          description: |
            The latest result recorded for the EU DPP Registry's
            acceptance (test) environment. It has no legal effect and
            changes nothing else on the passport.
          required: [result, at]
          properties:
            result:
              type: string
              enum: [passed, failed]
            errorCode:
              type: string
              description: The Registry's error code, for `failed`.
            at:
              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/ResourceId"
        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/ResourceId"
        noPredecessorReason:
          type: string

    PassportSuccessor:
      type: object
      properties:
        passportId:
          $ref: "#/components/schemas/ResourceId"
        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"
        confirmOverage:
          type: boolean
          description: |
            Reserved for accepting a per-block overage charge. No plan
            charges for measurements past the allowance today (paid plans
            accept and count them at no charge), so it has no effect yet.
            The Free plan stops at its allowance with 402
            `measurements_hard_block`.

    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/ResourceId"

    PassportMeasurement:
      type: object
      properties:
        _id:
          $ref: "#/components/schemas/ResourceId"
        passportId:
          $ref: "#/components/schemas/ResourceId"
        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/ResourceId"
        productId:
          $ref: "#/components/schemas/ResourceId"
        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/ResourceId"
        name:
          type: string
        model:
          type: string
        category:
          type: string
        templateId:
          $ref: "#/components/schemas/ResourceId"
        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/ResourceId"
        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"

    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 — `battery`, `textile`,
            `jewelry`, `electronics`, `furniture`, `detergents`,
            `paints-coatings`, `packaging`, `toys`, `fmcg`, `tyres`,
            `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. A product's status is not set here: archive
        it with `POST /api/v1/products/{id}/archive` (a `status` key
        in this body is ignored).
      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
        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"
            warnings:
              $ref: "#/components/schemas/LeiWarnings"
        - type: object
          required: [index, status, error]
          properties:
            index:
              type: integer
            status:
              type: string
              enum: [error]
            error:
              type: string

    LeiWarnings:
      type: array
      description: |
        Non-blocking findings from the GLEIF check of an LEI the write
        set (see `upsertParty`). Present only when non-empty. The value
        was saved; the warning says what GLEIF reports about it.
      items:
        type: object
        required: [path, message]
        properties:
          role:
            $ref: "#/components/schemas/PartyRoleEnum"
          path:
            type: array
            items:
              type: string
            example: [operatorIdentifier]
          message:
            type: string
            example: "LEI 353800SZHNPYLZTIX835 has lapsed: its GLEIF record has not been renewed. It still identifies the entity."

    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]
      properties:
        passports:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: "#/components/schemas/CreatePassportInput"

    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>`). All five
        schemes resolve to the same public viewer.
      properties:
        passportId:
          $ref: "#/components/schemas/ResourceId"
        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

    PassportLockedBody:
      type: object
      required: [error, message]
      description: |
        Response body for a 409 write-lock refusal.
        - `passport_archived` — the passport is archived and permanently
          read-only. Suspended and expired passports remain editable.
        - `identifier_locked` — one or more identity fields (`keys`)
          cannot change because the passport is published and those
          values are already approved. An empty field may be filled; a
          `pending_review` value may be corrected. Writing the same
          value back (including GTIN with or without leading-zero
          padding, or a trimmed-whitespace match) is not a lock.
        - `concurrent_update` — another write changed the passport
          between this read and write. Reload and retry.
        - `operator_locked` — the battery passport is placed on the
          market, so a party's `operatorIdentifier` or
          `facilityIdentifier` (named in `keys` as
          `<role>.<field>`) cannot change.
      properties:
        error:
          type: string
          enum: [passport_archived, identifier_locked, concurrent_update, operator_locked]
        message:
          type: string
          description: Human-readable explanation of the lock.
        keys:
          type: array
          items:
            type: string
          description: |
            The keys that triggered an `identifier_locked` error
            (identifier field keys) or an `operator_locked` error
            (`<role>.operatorIdentifier` / `<role>.facilityIdentifier`).
            Omitted for other codes.

    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/ResourceId"
            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/ResourceId"
              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, a
        battery `lineage` rule failed, or the GTIN is an example number from
        GS1's documentation (GS1 Prefix 952, or GS1's example company prefix
        9506000, such as 09506000134352), which identifies no real product.
        Lineage failures carry the rule code in `error` (e.g.
        `duplicate_predecessor`, `predecessor_not_found`,
        `status_trigger_mismatch`) and a human-readable `message`; an example
        GTIN carries `gs1_example_gtin`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    PaymentRequired:
      description: |
        The subscription is past due, unpaid or canceled. Writes that add
        or change content are blocked until billing is resolved. Every
        read keeps working, and published passports stay accessible.
        Scope checks, wrong-answer reports, catalogue runs and
        compliance-profile changes keep working too.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error: "Subscription is past due — resolve billing to continue creating new content. Published passports remain accessible."

    PassportLocked:
      description: |
        The passport is locked and the write was rejected.
        Switch on `error` to distinguish:

        - `passport_archived` — the passport is archived and permanently
          read-only. Suspended and expired passports remain editable.
          A passport marked for deletion is read-only too and answers
          the same code, with its own `message`.
        - `identifier_locked` — one or more identity fields named in
          `keys` cannot change after the passport is published and the
          value is approved. Fill an empty field or correct a
          `pending_review` value freely; writing an equivalent value
          (GTIN with/without leading-zero padding, trimmed whitespace)
          is not a lock.
        - `concurrent_update` — another write landed between this read
          and write. Reload the passport and retry.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PassportLockedBody"
          examples:
            archived:
              summary: Passport archived
              value:
                error: passport_archived
                message: "This passport is archived and can no longer be changed."
            marked_for_deletion:
              summary: Passport marked for deletion
              value:
                error: passport_archived
                message: "This passport is marked for deletion and can no longer be changed."
            identifier_locked:
              summary: Identifier field locked
              value:
                error: identifier_locked
                message: "Identifier fields cannot change after the passport is published: gtin."
                keys: [gtin]
            concurrent_update:
              summary: Concurrent update race
              value:
                error: concurrent_update
                message: "The passport changed while this edit was being saved. Reload it and try again."

    BySerialPassportConflict:
      description: |
        409 on a by-serial write route. Two mutually exclusive
        causes — switch on `error`:

        - `ambiguous_serial` — the serial matches more than one
          passport in your account (serials are unique per GTIN,
          not per account). Add `?gtin=<gtin>` to disambiguate.
          `count` is how many passports matched.
        - `passport_archived`, `identifier_locked`,
          `concurrent_update` — see the `PassportLocked` response.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: "#/components/schemas/PassportLockedBody"
              - type: object
                required: [error, message, count]
                properties:
                  error:
                    type: string
                    enum: [ambiguous_serial]
                  message:
                    type: string
                  count:
                    type: integer

    PassportArchived:
      description: |
        The passport is archived and permanently read-only, so the write
        was rejected. Suspended and expired passports remain editable.
        A passport marked for deletion is read-only too and answers the
        same code, with its own `message`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PassportLockedBody"
          examples:
            archived:
              summary: Passport archived
              value:
                error: passport_archived
                message: "This passport is archived and can no longer be changed."

    BySerialPassportArchivedConflict:
      description: |
        409 on a by-serial write route. Two mutually exclusive
        causes — switch on `error`:

        - `ambiguous_serial` — the serial matches more than one
          passport in your account (serials are unique per GTIN,
          not per account). Add `?gtin=<gtin>` to disambiguate.
          `count` is how many passports matched.
        - `passport_archived` — see the `PassportArchived` response.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: "#/components/schemas/PassportLockedBody"
              - type: object
                required: [error, message, count]
                properties:
                  error:
                    type: string
                    enum: [ambiguous_serial]
                  message:
                    type: string
                  count:
                    type: integer

    DeletionReasonInvalid:
      description: |
        The `reason` is missing or not one of the accepted values.
        Switch on `error`:

        - `reason_required` — a battery passport needs a `reason`
          (`recycled`, `never_placed` or `duplicate_or_error`).
        - `invalid_reason` — the value is not one of those three.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            reason_required:
              summary: Battery passport without a reason
              value:
                error: reason_required
                message: "A reason is required for battery passports: send { \"reason\": \"recycled\" | \"never_placed\" | \"duplicate_or_error\" }. A battery passport ends only when the battery has been recycled."
            invalid_reason:
              summary: Unknown reason
              value:
                error: invalid_reason
                message: "Invalid reason. Must be one of: recycled, never_placed, duplicate_or_error."

    DeletionNotOnPlan:
      description: Deletion through the API needs a paid plan.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error: deletion_not_on_plan
            message: "Deletion is available on every paid plan; archive the passport instead."

    MarkForDeletionConflict:
      description: |
        The passport cannot be marked for deletion. Switch on `error`:

        - `already_marked_for_deletion` — it is already marked; delete
          it with `DELETE /api/v1/passports/{id}`.
        - `passport_archived` — an archived passport cannot be marked.
        - `has_live_successor` — a newer second-life passport in your
          account names this one as its predecessor. Delete the newer
          passport first.
        - `reason_contradicts_placement` — `never_placed` on a passport
          registered as placed on the market.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            already_marked_for_deletion:
              summary: Already marked
              value:
                error: already_marked_for_deletion
                message: "This passport is already marked for deletion."
            passport_archived:
              summary: Archived
              value:
                error: passport_archived
                message: "This passport is archived. It cannot be marked for deletion."
            has_live_successor:
              summary: A newer passport links to it
              value:
                error: has_live_successor
                message: "This passport has a live successor. Delete the successor first before marking this passport for deletion."

    ArchiveMarkedConflict:
      description: |
        The passport cannot be archived. Switch on `error`:

        - `passport_already_marked_for_deletion` — it is marked for
          deletion.
        - `reason_contradicts_placement` — `never_placed` on a passport
          registered as placed on the market.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error: passport_already_marked_for_deletion
            message: "This passport is already marked for deletion and cannot be archived."

    PartyWriteConflict:
      description: |
        The party write was refused. Switch on `error`:

        - `passport_archived` — the passport is archived (or marked for
          deletion) and read-only.
        - `operator_locked` — the battery passport is placed on the
          market, and the write would change a stored
          `operatorIdentifier` or `facilityIdentifier`. Other party
          edits stay allowed. To correct a locked identifier, mark the
          passport for deletion and create a new one.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PassportLockedBody"
          examples:
            archived:
              summary: Passport archived
              value:
                error: passport_archived
                message: "This passport is archived and can no longer be changed."
            operator_locked:
              summary: Operator identifier locked after placement
              value:
                error: operator_locked
                message: "Operator and facility identifiers are locked after Place on market. To correct, mark for deletion and re-create."
                keys: [manufacturer.operatorIdentifier]

    PlaceOnMarketConflict:
      description: |
        The passport cannot be placed on the market. Switch on `error`
        (the gates run in this order, and the first that fails answers):

        - `not_a_battery` — only battery passports are sent to a
          registry.
        - `not_published` — the passport is not `published`.
        - `already_placed` — placement is in progress or done.
        - `operator_id_missing` — the account has no economic-operator
          identifier.
        - `registry_not_configured` — the account has no registry access
          configured.
        - `registry_host_not_allowed` — TracePass does not send
          registrations to the configured registry address.
        - `registry_not_ready` — the registry cannot take this passport
          yet; `findings` lists why.
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error:
                type: string
                enum:
                  - not_a_battery
                  - not_published
                  - already_placed
                  - operator_id_missing
                  - registry_not_configured
                  - registry_host_not_allowed
                  - registry_not_ready
              message:
                type: string
              findings:
                type: array
                description: |
                  `registry_not_ready` only: the registry-readiness check's
                  findings, one human-readable sentence each. Blocking and
                  advisory findings both appear; `GET
                  /api/v1/passports/{id}/registry-readiness` returns them
                  structured, with their severity.
                items:
                  type: string
              findingDetails:
                type: array
                description: |
                  `registry_not_ready` only: the same findings with their
                  severity, blocking (`critical`) ones first, each group in the
                  check's order. Added beside `findings`, which is unchanged.
                items:
                  type: object
                  required: [severity, why]
                  properties:
                    severity:
                      type: string
                      enum: [critical, warning]
                    why:
                      type: string
                    fix:
                      type: string
                    target:
                      type: string
                      description: Field key or party role the finding concerns.
                    ruleId:
                      type: string
          examples:
            not_a_battery:
              summary: Not a battery passport
              value:
                error: not_a_battery
            already_placed:
              summary: Already placing or placed
              value:
                error: already_placed
            registry_not_ready:
              summary: Formal gaps block the registry entry
              value:
                error: registry_not_ready
                findings:
                  - "Required field \"Battery Carbon Footprint — Total\" has no value."
                  - "Batteries must be registered at item (serialised) granularity. A GS1 serial number is required by the Battery Regulation (EU) 2023/1542."
                findingDetails:
                  - severity: critical
                    why: "Required field \"Battery Carbon Footprint — Total\" has no value."
                  - severity: critical
                    why: "Batteries must be registered at item (serialised) granularity. A GS1 serial number is required by the Battery Regulation (EU) 2023/1542."

    FieldPayloadTooLarge:
      description: |
        The declared request body is larger than 1 MB. A field value that
        fits the body but breaks a size limit answers 400
        `field_value_too_large` instead.
      content:
        application/json:
          schema:
            type: object
            required: [error, message]
            properties:
              error:
                type: string
                enum: [payload_too_large]
              message:
                type: string
                example: "Request body must not exceed 1048576 bytes."

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