openapi: 3.1.0
info:
  title: OpenDPP Integration API
  version: 1.14.0
  termsOfService: https://opendpp-node.eu/api-terms
  summary: Create, validate, seal and publish EU Digital Product Passports;
    serialize battery units; manage facilities, access grants and webhooks;
    resolve and verify passports publicly.
  description: |-
    OpenDPP is a B2B platform for EU Digital Product Passports (DPPs), aligned with the ESPR data requirements and the EU Battery Regulation. This specification documents the **public integration surface**: everything an external system needs to create, validate, seal, publish, resolve and verify passports.

    ## Authentication
    Authenticate with a tenant **API key** sent as a Bearer token: `Authorization: Bearer op_dpp_token_…`. Keys are created in the Client Console (Developers → API keys), are shown **once** at creation, carry a role plus optional narrowed permissions and optional expiry, and can be revoked at any time. API-key clients are exempt from CSRF requirements. Public endpoints (tagged **Public Resolution**, plus the public validators and the audit verifier) need no credentials.

    ## Tenancy
    Tenant identity is **token-bound** — it is derived from your API key, never from the request host. The same paths work on the apex host and on tenant workspace hosts (`https://<workspace>.opendpp-node.eu`); when a workspace host is used, it must match the key's tenant (requests across workspaces are rejected with `403`).

    ## Versioning & compatibility
    This contract carries a SemVer version, readable at runtime from `GET /api/v1/version`. **Pin the MAJOR.** It equals the `/api/v1` URL major, so a breaking change ships as a new path major (`/api/v2`) that you adopt deliberately — not as an edit to the contract you already integrated against.

    Within a major line:

    - **MINOR** is additive — a new endpoint, a new optional parameter, a new field on a response. A client that ignores what it does not recognise keeps working. Do not treat unknown response fields as errors.
    - **PATCH** is documentation only: wording, examples, descriptions. Nothing observable in the contract changes.

    The tier is not asserted by hand. Every change is diffed structurally against the previous contract in CI, and a version bump lower than the diff requires fails the build — so the number you pin to is derived from the contract itself.

    **One exception, disclosed rather than hidden.** While this contract is pre-GA, a breaking change may exceptionally ship on the existing major line under a recorded waiver instead of forcing a new path major. It is not a standing option: it requires a maintainer to enable it for a single merge, and every use is recorded with its justification. It has been used during the pre-GA period. Once this line reaches GA the waiver is retired, and the MAJOR promise above becomes unconditional. If you need a contract that cannot move under you before then, pin the exact version you generated your client from and upgrade deliberately.

    ## Errors
    Authenticated endpoints return `{ success: false, error, message }` (some omit `success`). Across the developer-facing write/ingest surface (passport / operator / unit / resolver / facility / events / webhooks) the body also carries a **machine-stable `code`** you can branch on instead of parsing `message` — see the `code` enum on the shared **Error** schema for the full set. ESPR metadata validation failures return the richer shape documented as **ValidationFailed** with per-field `errors[]`/`warnings[]` (localizable via `?lang=` or `Accept-Language`; 28 languages). Bulk endpoints report row-level problems as `errors: string[]`. Malformed JSON and query-string violations are rejected before the handler runs and return a `{ statusCode, code, error, message }` body.

    Every response — success or error — carries an **`X-Request-Id`** header; generic (server-error / framework) bodies also include it as `requestId`. Quote it to support to correlate with server logs. Send your own well-formed `X-Request-Id` and it is adopted for end-to-end tracing.

    ## Advisories: `warnings[]` & `notices[]`
    Success responses may carry two non-blocking advisory channels of **coded** items (`AdvisoryItem`: `{ code, path?, message, friendlyMessage }`). **`warnings[]`** are heads-ups the request still succeeded on (`NON_GS1_PRODUCT_ID`, `PII_SHAPE_DETECTED`, `UNIT_NO_SCANNABLE_LINK`, `DRAFT_DEMOTED`, `EORI_NOT_FOUND`); **`notices[]`** are informational — helpful things the API did (`OPERATOR_AUTO_ATTRIBUTED`, `GTIN_AUTO_COPIED`). Branch on the STABLE `code`; treat `message` (developer English) and `friendlyMessage` (end-user, localized via `?lang=`/`Accept-Language` across 28 languages) as display text that may be reworded. Interfaces may also map a `code` to their own localized string.

    ## Rate limits
    Two limits apply, and the one that bites first depends on how you call us.

    **Per API key (authenticated calls).** Each key gets a per-minute budget set by the plan: **Growth 120**, **Scale 600**, **Enterprise unlimited**. A second ceiling of **3x that rate** applies across all of a workspace's keys together, so issuing more keys divides throughput fairly between your own systems rather than multiplying it. Plans below Growth do not include API access. Exceeding either budget returns `429` with a `Retry-After` header giving the seconds to wait.

    **Per IP (all traffic).** A ceiling of **100 requests/min per IP** applies to anonymous traffic. Authenticated calls sit on a higher ceiling, so that several integrations behind one egress address are not held to the anonymous budget. `x-ratelimit-*` response headers report the applicable ceiling. Every plan that can reach the API sits at or above the anonymous figure, so an authenticated caller never meets a stricter limit than the number above.

    Public passport resolution is additionally limited to **30 requests/min per IP** (no headers). The public validator is limited to **10 requests/min per IP**.

    Stay under these limits with client-side queueing; on `429`, back off and retry after the indicated window. A `429` never indicates a credential problem — an invalid or revoked key returns `401`, so do not rotate a key in response to rate limiting.

    ## Sealing & verification
    Passport seals are **advanced electronic seals** — ECDSA P-256 over a Merkle root of the passport content, with an optional RFC 3161 timestamp. (Advanced, not qualified: a qualified seal would require a QTSP.) Anyone can verify a seal — no account required. `POST /api/v1/audit/verify` recomputes every Merkle leaf from the submitted values, so it requires the unredacted document (caller-supplied redacted-leaf hashes are deliberately not trusted). Redacted documents remain verifiable **offline**: masked fields keep their true leaf hashes in `proof.redactedLeaves`, letting any verifier rebuild the sealed root without the privileged values.

    ## Public access tiers
    Public resolution endpoints serve **tiered** views of the same URL: the public tier for anonymous callers; a restricted tier for holders of legitimate-interest (`dpp_li_…`) or authority (`dpp_auth_…`) capability tokens (presented as a Bearer token or `?grant=` query parameter); and the owner tier for the issuing tenant's own credentials.

    ## Webhooks
    Subscribe to passport lifecycle events (`passport.ingested`, `passport.sealed`, `passport.recalled`, or `*`). Deliveries are HMAC-SHA256-signed; see the **webhooks** section of this document for the exact signature scheme, retry schedule, and payloads.

    This document is also served machine-readably at [`/openapi.json`](https://opendpp-node.eu/openapi.json) and [`/openapi.yaml`](https://opendpp-node.eu/openapi.yaml).

    ## Open interoperability kit
    The interoperability boundary — the official AAS + UNTP/W3C-VC schemas, live-reproducible samples, an offline conformance validator, and the field mappings — is **open source** at [github.com/OpenDPP/opendpp-interop](https://github.com/OpenDPP/opendpp-interop) (Apache-2.0). It lets any integrator validate and verify OpenDPP's standards-conformant output without access to the product source.
  contact:
    name: OpenDPP
    url: https://opendpp-node.eu/contact
    email: support@opendpp-node.eu
  license:
    name: Proprietary — © OpenDPP UAB. The specification may be used to build
      integrations and generate clients against the OpenDPP service.
    url: https://opendpp-node.eu/security
externalDocs:
  description: OpenDPP interop boundary kit — official schemas, live-reproducible
    samples, an offline conformance validator, and AAS + UNTP field mappings
    (open source, Apache-2.0).
  url: https://github.com/OpenDPP/opendpp-interop
servers:
  - url: https://opendpp-node.eu
    description: Production (apex host)
  - url: https://{workspace}.opendpp-node.eu
    description: Tenant workspace host — same API; the host's workspace must match
      the API key's tenant.
    variables:
      workspace:
        default: demo
        description: Your workspace subdomain (the demo workspace hosts fictional sample
          data).
tags:
  - name: Passports
    description: "Create, validate, read, update, seal and manage the lifecycle of
      Digital Product Passports. Passport metadata is category-specific:
      machine-readable JSON Schemas are served live at `GET
      /api/v1/schemas/{category}` for textiles, batteries, electronics,
      chemicals and construction; the remaining categories (cosmetics, toys,
      iron-steel, aluminium) are validated by built-in rules — use the dry-run
      validators to check payloads for any category."
  - name: Economic Operators
    description: Register and manage the economic operators (manufacturers/brands,
      identified by EORI or national registry id) that passports are issued on
      behalf of. Operators with passports are archived — never hard-deleted — so
      their passports stay resolvable.
  - name: Battery Units
    description: Per-unit battery serialization (real serials, GS1 AI 21) under a
      SKU-level passport, plus append-only telemetry events (state of health,
      charge cycles, status changes) per the EU Battery Regulation.
  - name: Facilities
    description: Manufacturing facility master data, identified by GS1 GLN-13 (the
      Unique Facility Identifier). GLN, name, activity and country are public in
      passport documents; street addresses are never published.
  - name: Access Grants
    description: "Capability tokens implementing tiered legitimate-interest access:
      issue, approve, deny and revoke `dpp_li_…` / `dpp_auth_…` tokens. Third
      parties request access via the hosted request page; granted tokens unlock
      restricted fields on the public resolution endpoints (Bearer or
      `?grant=`)."
  - name: Webhooks
    description: Subscribe HTTPS endpoints to passport lifecycle events. Deliveries
      are HMAC-SHA256-signed POSTs with retry/backoff — see the webhooks section
      of this document for the signature scheme and payloads.
  - name: Traceability & Audit
    description: UNTP/EPCIS supply-chain traceability events, lineage queries, and
      the public seal verifier. The verifier checks the cryptographic seal AND
      that the signing workspace is bound to the economic operator declared in
      the payload.
  - name: Public Resolution
    description: "Unauthenticated, content-negotiated passport resolution: GS1
      Digital Link paths, passport and unit pages. One URL serves JSON-LD
      (default), an AAS environment (`Accept: application/aas+json`), a signed
      UNTP Verifiable Credential (`Accept: application/vc+jwt` enveloping,
      `application/vc+ld+json` embedded `ecdsa-jcs-2019` Data Integrity, or
      `application/dc+sd-jwt` SD-JWT-VC selective disclosure; the first two also
      item-level on `/unit/:id`), or HTML (`Accept: text/html`). Tiered by
      optional credentials or grant tokens. Rate limit: 30 requests/min per IP."
  - name: Verifiable Credentials
    description: "Issuer trust endpoints that back the UNTP Verifiable Credential
      representations: the workspace's `did:web` DID document (public keys only)
      and its W3C Bitstring Status List for revocation. Unauthenticated; resolve
      these to verify and revocation-check any OpenDPP-issued credential."
  - name: Schemas & Vocabulary
    description: "Machine-readable contracts: per-category ESPR JSON Schemas, the
      W3C JSON-LD context, and the curated materials vocabulary."
  - name: QR Codes
    description: Export GS1-Digital-Link QR codes (PNG/SVG, 128–2048 px, GS1 quiet
      zone) for passports and battery units.
  - name: eIDAS Keys
    description: Tenant signing-key management. Keys are generated and held
      server-side in an encrypted vault; private key material is never returned
      by any endpoint.
  - name: Account
    description: "Identity of the authenticated API key / session: workspace, role,
      permissions, operator scope, and passport usage against the tier quota —
      the integration-facing counterpart to the console's profile endpoints."
  - name: Service
    description: Service metadata and liveness.
paths:
  /api/v1/whoami:
    get:
      operationId: whoami
      tags:
        - Account
      summary: Identity of the authenticated key / session
      description: >-
        Returns a compact, integration-focused view of the calling credential:
        the workspace, the principal's role and resolved permissions, whether
        the session is an API key, the operator the key is scoped to (`null` =
        workspace-wide), and active-passport usage against the tier quota. Use
        it to verify a key works, discover the effective permission set, and
        surface remaining quota.


        Profile, localization and billing details are deliberately not exposed
        here — this is the integration view of the credential.


        **Permission:** none beyond a valid tenant-scoped session — any API key
        can call it. Platform-admin sessions are rejected with `403` (they are
        not tenant-scoped).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: The authenticated identity.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WhoamiResponse"
              example:
                success: true
                tenant:
                  id: 7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b
                  name: Demo Manufacturing UAB
                  subdomain: demo
                  tier: growth
                  subscriptionStatus: active
                auth:
                  role: BRAND_OPERATOR
                  permissions:
                    - passport:read
                    - passport:create
                    - passport:update
                    - passport:seal
                  isApiKeySession: true
                  operatorId: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b
                usage:
                  activePassports: 12
                  passportLimit: 250
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The session is not scoped to a tenant workspace (e.g. a
            platform-admin session).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Forbidden
                message: This session is not scoped to a tenant workspace.
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/passports/{passportId}/units/validate:
    parameters:
      - name: passportId
        in: path
        required: true
        description: The SKU/type-level passport, addressed by its UUID or
          caller-supplied `productId` (GTIN-14 / GRAI / SKU), scoped to your
          tenant.
        schema:
          type: string
        example: "09501101530003"
    post:
      operationId: validateBatteryUnits
      tags:
        - Battery Units
      summary: "Pre-flight: validate battery-unit identifiers without persisting"
      description: >-
        NON-MUTATING pre-flight for bulk unit import. Runs the SAME
        engine-backed AI-21 / GS1 Digital Link conformance + field checks as
        `POST /api/v1/passports/{passportId}/units` and returns a per-item
        verdict — **persisting nothing**. Send a single unit or `{"units":
        [...]}` (≤200). Lets a bulk importer ask "would these serials be
        GS1-conformant?" before committing a batch.


        **Permission:** `battery:write` (gated as the write permission, like
        other validate-only checks; subscription gating → 402). **Validation:**
        `serialNumber` charset/length (`^[A-Za-z0-9._-]{1,20}$`, a URL-safe
        subset of GS1 AI-21 CSET 82) PLUS authoritative GS1-engine conformance
        for EVERY unit — a GTIN-keyed passport's unit Digital Link must parse
        cleanly through the engine, and a non-GTIN passport's AI-21 serial VALUE
        is validated through the same engine (CSET-82 charset + length);
        `status` must be a valid unit status; `manufacturedAt` must be
        Date-parseable. Predecessor linkage is NOT checked here (a
        persistence-time concern). The verdict order matches the input order.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SerializeBatteryUnitsRequest"
            example:
              units:
                - serialNumber: BATT-2026-000451
                - serialNumber: bad serial!
      responses:
        "200":
          description: Per-item conformance verdicts in input order (nothing persisted).
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - count
                  - validCount
                  - results
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  count:
                    type: integer
                    description: Number of units checked.
                  validCount:
                    type: integer
                    description: How many are GS1-conformant.
                  results:
                    type: array
                    items:
                      type: object
                      required:
                        - serialNumber
                        - ok
                      properties:
                        serialNumber:
                          type:
                            - string
                            - "null"
                        ok:
                          type: boolean
                        error:
                          type: string
                          description: Present only when ok=false.
              example:
                success: true
                message: 1 of 2 unit(s) GS1-conformant
                count: 2
                validCount: 1
                results:
                  - serialNumber: BATT-2026-000451
                    ok: true
                  - serialNumber: bad serial!
                    ok: false
                    error: "Invalid serialNumber \"bad serial!\": must be 1-20 URL-safe characters
                      ([A-Za-z0-9._-]) — GS1 AI-21's maximum length is 20"
        "400":
          description: Invalid body envelope (not a JSON object, empty `units`, or more
            than 200 items).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Passport not found under your tenant workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /api/v1/passports/{passportId}/units:
    parameters:
      - name: passportId
        in: path
        required: true
        description: The SKU/type-level passport, addressed either by its UUID **or** by
          its caller-supplied `productId` (GTIN-14 / GRAI / SKU). The UUID
          lookup is tried first, then `productId` — both scoped to your tenant.
        schema:
          type: string
        example: "09501101530003"
    post:
      operationId: serializeBatteryUnits
      tags:
        - Battery Units
      summary: Serialise individual battery units under a passport (bulk, up to 200)
      description: >-
        Creates one or many **individual physical battery units** (EU Battery
        Regulation) under a SKU/type-level passport. Send either a single unit
        object or `{"units": [...]}` with **at most 200 items** (if `units` is
        present and an array it is used; otherwise the whole body is treated as
        one unit).


        **Permission:** `battery:write`. Bearer API key (`op_dpp_token_…`) or
        session JWT; cookie-session clients must send `X-CSRF-Token`.
        Operator-scoped credentials may only serialise under passports of their
        own Economic Operator (403). Write operations pass subscription gating
        (402) and optional tenant MFA enforcement (403).


        **Per-item validation (collected as plain-string errors, not a rejection
        of the whole batch):** `serialNumber` is trimmed then must match
        `^[A-Za-z0-9._-]{1,20}$` (a URL-safe subset of GS1 AI-21 CSET 82, ≤ 20
        chars) AND is validated to full AI-21 conformance by GS1's authoritative
        engine — a GTIN-keyed unit through its full Digital Link, a non-GTIN
        unit through its AI-21 serial value; `status` must be a valid unit
        status; `manufacturedAt` must be Date-parseable; duplicate `(passport,
        serialNumber)` pairs are skipped with *"A unit with this serial already
        exists for this passport"*. Each created unit gets a per-unit GS1
        Digital Link URI `/{01|8003}/{productId}/21/{serialNumber}` carrying the
        **real physical serial** in AI-21.


        **Predecessor linkage (repurpose/remanufacture):** `predecessorUnitId`
        must reference an existing unit **in your tenant** (any passport). A
        recycled predecessor (`ceasedAt` set) is refused — its passport has
        ceased to exist. (A unit *created* with status `RECYCLED` is ceased from
        birth — `ceasedAt` is stamped at creation — and is refused as a
        predecessor exactly like one recycled via the events route.) In one
        transaction the new unit is created, an append-only `STATUS_CHANGE`
        event (`{status, successorUnitId, successorSerial}` payload) is written
        to the predecessor, and the predecessor's status is set to
        `predecessorStatus` (default `REPURPOSED`; only
        `REPURPOSED|REMANUFACTURED|REUSED` allowed).


        **Partial success:** the response is **201 when at least one unit was
        created**; skipped items are listed in `errors`. If *every* item failed
        you get **400 `Serialisation Failed`** with the same string array. A
        `batteryunit.created` audit event and a tenant notification are emitted
        on success.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SerializeBatteryUnitsRequest"
            example:
              units:
                - serialNumber: BATT-2026-000451
                  manufacturedAt: 2026-05-02T08:00:00.000Z
                - serialNumber: BATT-2026-000452
                  status: IN_SERVICE
                  predecessorUnitId: 5a1c9e7d-3b2f-4c8a-9e6d-7f0b1a2c3d4e
                  predecessorStatus: REMANUFACTURED
      responses:
        "201":
          description: At least one unit was serialised. `count` is the number actually
            created; when some items were skipped, `errors` lists one
            plain-English string per skipped item and `message` notes the skip
            count.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SerializeBatteryUnitsResponse"
              example:
                success: true
                message: Serialised 2 individual unit(s)
                count: 2
                units:
                  - id: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
                    serialNumber: BATT-2026-000451
                    digitalLinkUri: https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451
                    passportId: 4c8e1d2a-6b3f-4a9e-8d57-0f1e2a3b4c5d
                    tenantId: 2f9a41c8-5e7b-4d3a-9c16-8b0d5e7a3f21
                    manufacturedAt: 2026-05-02T08:00:00.000Z
                    status: IN_SERVICE
                    ceasedAt: null
                    predecessorUnitId: null
                    createdAt: 2026-06-12T09:41:00.000Z
                    updatedAt: 2026-06-12T09:41:00.000Z
                  - id: 2f6b0c1d-8e4a-4b7c-a93d-5e2f1a0b9c8d
                    serialNumber: BATT-2026-000452
                    digitalLinkUri: https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000452
                    passportId: 4c8e1d2a-6b3f-4a9e-8d57-0f1e2a3b4c5d
                    tenantId: 2f9a41c8-5e7b-4d3a-9c16-8b0d5e7a3f21
                    manufacturedAt: null
                    status: IN_SERVICE
                    ceasedAt: null
                    predecessorUnitId: 5a1c9e7d-3b2f-4c8a-9e6d-7f0b1a2c3d4e
                    createdAt: 2026-06-12T09:41:00.000Z
                    updatedAt: 2026-06-12T09:41:00.000Z
        "400":
          description: 'Three shapes: (1) standard `Bad Request` triple when the body is
            not a JSON object, the `units` array is empty, or more than 200
            units are sent; (2) `Serialisation Failed` (`{success:false,
            error:"Serialisation Failed", errors: string[]}` — **no `message`
            field**) when *every* item in the batch failed per-item
            validation/creation; (3) a syntactically malformed JSON body is
            rejected by the framework before the handler runs, returning a
            `{statusCode:400, error:"Bad Request", message}` body.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatteryUnitSerialiseBadRequest"
              examples:
                badRequest:
                  summary: Batch-level rejection
                  value:
                    success: false
                    error: Bad Request
                    message: A single request may serialise at most 200 units
                serialisationFailed:
                  summary: Every item failed
                  value:
                    success: false
                    error: Serialisation Failed
                    errors:
                      - "Invalid serialNumber \"bad serial!\": must be 1-20
                        URL-safe characters ([A-Za-z0-9._-]) — GS1 AI-21's
                        maximum length is 20"
                      - "[BATT-2026-000451] A unit with this serial already
                        exists for this passport"
                malformedJson:
                  summary: Syntactically invalid JSON body (framework default body)
                  value:
                    statusCode: 400
                    code: FST_ERR_CTP_INVALID_JSON_BODY
                    error: Bad Request
                    message: Body is not valid JSON but content-type is set to 'application/json'
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
    get:
      operationId: listBatteryUnits
      tags:
        - Battery Units
      summary: List serialised battery units under a passport
      description: >-
        Lists **all** serialised units of the passport, newest first
        (`createdAt` DESC). **Paginated** with `?page` (default 1) and `?limit`
        (default 100, max 200) — a SKU may carry many physical units; `count` is
        this page's size, `total`/`totalPages` describe the full set.


        **Permission:** `battery:read`. Operator-scoped credentials may only
        read passports of their own Economic Operator (403). Units are returned
        as stored.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: The passport's units. `productId` echoes the passport's
            caller-supplied identifier; `count` equals `units.length`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatteryUnitListResponse"
              example:
                success: true
                count: 1
                productId: "09501101530003"
                units:
                  - id: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
                    serialNumber: BATT-2026-000451
                    digitalLinkUri: https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451
                    passportId: 4c8e1d2a-6b3f-4a9e-8d57-0f1e2a3b4c5d
                    tenantId: 2f9a41c8-5e7b-4d3a-9c16-8b0d5e7a3f21
                    manufacturedAt: 2026-05-02T08:00:00.000Z
                    status: IN_SERVICE
                    ceasedAt: null
                    predecessorUnitId: null
                    createdAt: 2026-06-12T09:41:00.000Z
                    updatedAt: 2026-06-12T09:41:00.000Z
                page: 1
                limit: 100
                total: 1
                totalPages: 1
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
      parameters:
        - name: page
          in: query
          required: false
          description: 1-based page number (digits only; non-numeric falls back to 1).
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: limit
          in: query
          required: false
          description: Page size. Clamped to 1–200; non-numeric falls back to the default
            100.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 100
  /api/v1/units/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Battery unit UUID (tenant-scoped).
        schema:
          type: string
        example: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
    get:
      operationId: getBatteryUnit
      tags:
        - Battery Units
      summary: Get one battery unit as JSON-LD with its dynamic-data history
      description: >-
        Returns the unit as a **JSON-LD document** (`Content-Type:
        application/ld+json`) in the **privileged tenant view**: `currentState`
        (the latest telemetry snapshot) and `dynamicData` (the **500 most
        recent** events, newest first by `recordedAt`) are included; the public
        `restrictedData` marker is absent. The embedded `ofModel` is the
        SKU/type passport document rendered in the **owner (unredacted)
        variant** — legitimate-interest-tier metadata and owner-only keys are
        NOT masked, unlike the anonymous public document.


        **Caveat:** this authenticated endpoint does **not** load lineage
        relations, so `repurposedFrom` is always `null` and `successorUnits` is
        always `[]` here even when lineage exists; the public resolver view
        (`GET /unit/{id}`) does resolve them.


        **Permission:** `battery:read`. Operator-scoped credentials may only
        read units whose passport belongs to their Economic Operator (403).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: The unit's JSON-LD document (privileged view, telemetry included).
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/BatteryUnitJsonLd"
              example:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - BatteryUnit: https://opendpp-node.eu/ns/dpp#BatteryUnit
                    serialNumber: https://opendpp-node.eu/ns/dpp#serialNumber
                    ofModel: https://opendpp-node.eu/ns/dpp#ofModel
                    currentState: https://opendpp-node.eu/ns/dpp#currentState
                    dynamicData: https://opendpp-node.eu/ns/dpp#dynamicData
                    stateOfHealth: https://opendpp-node.eu/ns/dpp#stateOfHealth
                    cycleCount: https://opendpp-node.eu/ns/dpp#cycleCount
                    restrictedData: https://opendpp-node.eu/ns/dpp#restrictedData
                    repurposedFrom: https://opendpp-node.eu/ns/dpp#repurposedFrom
                    successorUnits: https://opendpp-node.eu/ns/dpp#successorUnits
                "@type": BatteryUnit
                "@id": https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451
                id: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
                serialNumber: BATT-2026-000451
                digitalLinkUri: https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451
                status: IN_SERVICE
                manufacturedAt: 2026-05-02T08:00:00.000Z
                repurposedFrom: null
                successorUnits: []
                ofModel:
                  "@context":
                    - https://opendpp-node.eu/contexts/dpp/v1
                    - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                      economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                      manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                      metadata: https://opendpp-node.eu/ns/dpp#metadata
                      digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                      signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                      status: https://opendpp-node.eu/ns/dpp#status
                      archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                      retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                      category: https://opendpp-node.eu/contexts/dpp/v1#category
                      originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                  "@type": DigitalProductPassport
                  "@id": https://opendpp-node.eu/01/09501101530003
                  id: 4c8e1d2a-6b3f-4a9e-8d57-0f1e2a3b4c5d
                  productId: "09501101530003"
                  digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                  digitalSeal: null
                  signingPublicKey: null
                  status: ACTIVE
                  archivedAt: null
                  retentionUntil: null
                  proof: null
                  createdAt: 2026-05-01T10:00:00.000Z
                  updatedAt: 2026-06-01T10:00:00.000Z
                  economicOperator:
                    "@type": EconomicOperator
                    id: operator-demo-opendpp
                    name: OpenDPP Demo Eco Industries (SAMPLE)
                    regId: EU-DEFAULT-001
                    role: Manufacturer
                  manufacturingFacility: null
                  metadata:
                    category: batteries
                    originCountry: PT
                  category: batteries
                  originCountry: PT
                currentState:
                  stateOfHealth: 97.4
                  cycleCount: 132
                  remainingCapacityAh: 48.7
                  temperatureC: 23.1
                  recordedAt: 2026-06-11T16:20:00.000Z
                dynamicData:
                  - "@type": BatteryUnitEvent
                    eventType: SOH_MEASUREMENT
                    stateOfHealth: 97.4
                    cycleCount: 132
                    remainingCapacityAh: 48.7
                    temperatureC: 23.1
                    payload: null
                    recordedAt: 2026-06-11T16:20:00.000Z
                createdAt: 2026-06-12T09:41:00.000Z
                updatedAt: 2026-06-12T09:41:00.000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      operationId: deleteBatteryUnit
      tags:
        - Battery Units
      summary: "Not deletable: a serialised unit is a marketed physical item (always
        409)"
      description: >-
        **Always refused with 409.** A serialised unit is an item-level battery
        passport for a physical battery placed on the market, so the record —
        including its append-only telemetry — is retained (EU Battery Regulation
        persistence, mirroring the passport-level archive model). End a unit's
        life through the lifecycle instead: append a `STATUS_CHANGE` event via
        `POST /api/v1/units/{id}/events` — `RECYCLED` ceases it (public 410
        tombstone), `DECOMMISSIONED` retires it. Hard removal is reserved for
        the node's retention-gated purge, never an ad-hoc API delete.


        **Permission:** `battery:write`. Cookie-session clients must send
        `X-CSRF-Token`. Operator-scoped credentials may only address units whose
        passport belongs to their Economic Operator (403). Write operations pass
        subscription gating (402) and optional tenant MFA enforcement (403).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      responses:
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Always, for an existing unit under your workspace — the unit
            represents a marketed physical item and cannot be deleted; its
            record and telemetry are retained. The message names the lifecycle
            venue to use instead.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Conflict
                message: Battery unit BATT-2026-000451 represents a marketed physical item and
                  cannot be deleted (Art. 77(8) persistence). Record a
                  STATUS_CHANGE event (RECYCLED to cease it, or DECOMMISSIONED)
                  instead — its telemetry history is retained.
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/units/{id}/events:
    parameters:
      - name: id
        in: path
        required: true
        description: Battery unit UUID (tenant-scoped).
        schema:
          type: string
        example: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
    post:
      operationId: recordBatteryUnitEvent
      tags:
        - Battery Units
      summary: Append an immutable telemetry event to a battery unit
      description: >-
        Appends one **append-only** per-unit dynamic-data record (Annex XIII /
        Art. 77: SoH, cycle count, remaining capacity, temperature, negative
        events). History is immutable — there is **no update or delete path**
        for events.


        **Permission:** `battery:write`. Cookie-session clients must send
        `X-CSRF-Token`. Operator-scoped credentials may only write to units
        whose passport belongs to their Economic Operator (403). Write
        operations pass subscription gating (402) and optional tenant MFA
        enforcement (403).


        **Validation (400 with the standard error triple):** `eventType` is
        required and must be one of
        `SOH_MEASUREMENT|CHARGE_CYCLE|STATUS_CHANGE|NEGATIVE_EVENT|OTHER`;
        `stateOfHealth` 0–100; `cycleCount` and `remainingCapacityAh`
        0–9007199254740991; `temperatureC` −273.15–10000 (each may also be
        `null`/omitted); `status`, if present, must be a valid unit status;
        `recordedAt` must be Date-parseable (defaults to server time when
        omitted). `cycleCount` is truncated to an integer before persisting; a
        `payload` that is not an object or array is silently dropped (stored as
        `null`) — JSON **arrays** pass the server's `typeof` check and are
        persisted verbatim.


        **Status transition:** when `status` is present and differs from the
        unit's current status, the unit is updated **in the same transaction**
        as the event — this works with *any* `eventType`, though `STATUS_CHANGE`
        is the conventional carrier. Transitioning to **`RECYCLED`**
        additionally stamps `ceasedAt` (if not already set; never cleared),
        after which the public unit view becomes a 410 tombstone and the unit
        can no longer gain successor units. `RECYCLED` is terminal: once the
        unit's status is `RECYCLED` this endpoint refuses **every** further
        event with **400** `Terminal Unit Status` — neither `status` nor
        telemetry can change again. A second life must be linked with
        `predecessorUnitId` **before** the predecessor is recycled — a terminal
        unit is itself refused as a `predecessorUnitId`, so there is no path
        back once it is recycled.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 255
          description: "Optional client idempotency key (≤255 characters, no control
            characters). Retrying this request with the same `Idempotency-Key`
            replays the ORIGINAL response — same status and body, plus an
            `idempotent-replayed: true` response header — instead of appending a
            duplicate reading. Scoped per (workspace, unit, key) and consulted
            within a 24-hour window; a malformed key returns **400**.
            Best-effort: the replay is recorded after the write commits, so in
            the rare window between commit and recording (or across an instance
            restart) a retry appends normally — the reading may then be recorded
            twice."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RecordBatteryUnitEventRequest"
            example:
              eventType: SOH_MEASUREMENT
              stateOfHealth: 96.8
              cycleCount: 140
              remainingCapacityAh: 48.2
              temperatureC: 24.5
              payload:
                measuredBy: BMS firmware 4.2.1
              recordedAt: 2026-06-12T09:41:00.000Z
      responses:
        "201":
          description: Event appended (and, when `status` was supplied and differed, the
            unit's status transitioned in the same transaction).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecordBatteryUnitEventResponse"
              example:
                success: true
                message: Dynamic data recorded
                event:
                  id: 7e5d3c1b-9a8f-4e6d-b2c4-1a0e9f8d7c6b
                  batteryUnitId: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
                  tenantId: 2f9a41c8-5e7b-4d3a-9c16-8b0d5e7a3f21
                  eventType: SOH_MEASUREMENT
                  stateOfHealth: 96.8
                  cycleCount: 140
                  remainingCapacityAh: 48.2
                  temperatureC: 24.5
                  payload:
                    measuredBy: BMS firmware 4.2.1
                  recordedAt: 2026-06-12T09:41:00.000Z
                  createdAt: 2026-06-12T09:41:05.000Z
        "400":
          description: 'Two shapes: (1) the standard error triple from handler validation
            — messages: `Request body must be a valid JSON object`; `eventType
            must be one of: SOH_MEASUREMENT, CHARGE_CYCLE, STATUS_CHANGE,
            NEGATIVE_EVENT, OTHER`; `<field> must be a number between <lo> and
            <hi>` (stateOfHealth/cycleCount/remainingCapacityAh/temperatureC);
            `status must be one of: IN_SERVICE, DECOMMISSIONED, RECALLED,
            REPURPOSED, REMANUFACTURED, REUSED, WASTE, RECYCLED`; `recordedAt is
            not a valid date`. (2) A syntactically malformed JSON body is
            rejected by the framework before the handler runs, returning a
            `{statusCode:400, error:"Bad Request", message}` body.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatteryUnitEventBadRequest"
              examples:
                validation:
                  summary: Handler validation error (standard triple)
                  value:
                    success: false
                    error: Bad Request
                    message: stateOfHealth must be a number between 0 and 100
                malformedJson:
                  summary: Syntactically invalid JSON body (framework default body)
                  value:
                    statusCode: 400
                    code: FST_ERR_CTP_INVALID_JSON_BODY
                    error: Bad Request
                    message: Body is not valid JSON but content-type is set to 'application/json'
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: 'The transaction failed. Handler-built body: `{success:false,
            error:"Internal Server Error", message:"Failed to record dynamic
            data"}` (error logged server-side, never echoed).'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Internal Server Error
                message: Failed to record dynamic data
    get:
      operationId: listBatteryUnitEvents
      tags:
        - Battery Units
      summary: List a battery unit's telemetry history (newest first, cursor-paginated)
      description: >-
        Returns one page of the unit's append-only dynamic-data history ordered
        by `recordedAt` DESC, ties broken by `id` DESC — so paging is
        deterministic. A page holds at most 500 events (`limit`, default 500);
        while older history remains the response carries a non-null `nextCursor`
        — pass it back as `cursor` to fetch the next (older) page, so the **full
        history is retrievable** however long it grows. The cursor is opaque; a
        malformed value returns **400**.


        **Permission:** `battery:read`. Operator-scoped credentials may only
        read units whose passport belongs to their Economic Operator (403).
        Events are returned as stored.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 500
          description: Page size (1–500). Out-of-range or non-integer values return **400**.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque page cursor — the `nextCursor` value from the previous page.
            Omit for the first (newest) page; a malformed value returns **400**.
      responses:
        "200":
          description: One page of the unit's telemetry history. `count` equals
            `events.length` (≤ `limit`); `serialNumber` echoes the unit's
            physical serial; `nextCursor` is non-null while older events remain.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatteryUnitEventListResponse"
              example:
                success: true
                count: 2
                serialNumber: BATT-2026-000451
                nextCursor: null
                events:
                  - id: 7e5d3c1b-9a8f-4e6d-b2c4-1a0e9f8d7c6b
                    batteryUnitId: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
                    tenantId: 2f9a41c8-5e7b-4d3a-9c16-8b0d5e7a3f21
                    eventType: SOH_MEASUREMENT
                    stateOfHealth: 96.8
                    cycleCount: 140
                    remainingCapacityAh: 48.2
                    temperatureC: 24.5
                    payload:
                      measuredBy: BMS firmware 4.2.1
                    recordedAt: 2026-06-12T09:41:00.000Z
                    createdAt: 2026-06-12T09:41:05.000Z
                  - id: 3a9c7e5d-1b2f-4c6a-8e0d-9f7b5a3c1d2e
                    batteryUnitId: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
                    tenantId: 2f9a41c8-5e7b-4d3a-9c16-8b0d5e7a3f21
                    eventType: CHARGE_CYCLE
                    stateOfHealth: null
                    cycleCount: 139
                    remainingCapacityAh: null
                    temperatureC: null
                    payload: null
                    recordedAt: 2026-06-11T16:20:00.000Z
                    createdAt: 2026-06-11T16:20:02.000Z
        "400":
          description: Malformed `cursor`, or `limit` outside 1–500.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/units/{id}/events/bulk:
    parameters:
      - name: id
        in: path
        required: true
        description: Battery unit UUID (tenant-scoped).
        schema:
          type: string
        example: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
    post:
      operationId: bulkRecordBatteryUnitEvents
      tags:
        - Battery Units
      summary: Append a batch of telemetry events to a battery unit
      description: >-
        Appends up to **500** append-only dynamic-data records to one unit in a
        single request — the batch venue for backfilling a fleet's telemetry.
        **Telemetry only:** a record carrying `status` is refused per-item; a
        status transition is a lifecycle decision and goes through `POST
        /api/v1/units/{id}/events` one event at a time.


        **Permission:** `battery:write`. Cookie-session clients must send
        `X-CSRF-Token`. Operator-scoped credentials may only write to units
        whose passport belongs to their Economic Operator (403). Write
        operations pass subscription gating (402) and optional tenant MFA
        enforcement (403). A terminal (`RECYCLED`) unit refuses the whole batch
        (400 `Terminal Unit Status`).


        **Per-record validation (collected as `[index]`-prefixed strings in
        `errors`, not a rejection of the whole batch):** the same field checks
        as the single-event endpoint — `eventType` required and valid, numeric
        ranges, Date-parseable `recordedAt` (defaults to server time when
        omitted) — plus the physics consistency checks, judged in chronological
        order across the batch and the unit's recorded history, so a reading
        that only conflicts with another batch member is caught too.


        **Partial success:** the response is **201 when at least one record was
        accepted**; skipped items are listed in `errors`. If *every* item failed
        you get **400** `Bulk Event Ingest Failed` with the same string array.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 255
          description: "Optional client idempotency key (≤255 characters, no control
            characters). Retrying this batch with the same `Idempotency-Key`
            replays the ORIGINAL result — same status and body, plus an
            `idempotent-replayed: true` response header — instead of
            re-inserting the batch. Scoped per (workspace, unit, key) and
            consulted within a 24-hour window; a malformed key returns **400**.
            Best-effort: the result is recorded after the batch commits, so in
            the rare window between commit and recording (or across an instance
            restart) a retry re-processes the batch normally and readings may
            then be recorded twice."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BulkBatteryUnitEventsRequest"
            example:
              events:
                - eventType: SOH_MEASUREMENT
                  stateOfHealth: 96.8
                  cycleCount: 140
                  recordedAt: 2026-06-12T09:41:00.000Z
                - eventType: CHARGE_CYCLE
                  cycleCount: 141
                  recordedAt: 2026-06-13T18:05:00.000Z
      responses:
        "201":
          description: At least one record was accepted. `count` equals `events.length`;
            skipped items are listed in `errors` in `[index]`-prefixed form.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkBatteryUnitEventsResponse"
              example:
                success: true
                message: Recorded 2 event(s)
                count: 2
                events:
                  - id: 7e5d3c1b-9a8f-4e6d-b2c4-1a0e9f8d7c6b
                    batteryUnitId: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
                    tenantId: 2f9a41c8-5e7b-4d3a-9c16-8b0d5e7a3f21
                    eventType: SOH_MEASUREMENT
                    stateOfHealth: 96.8
                    cycleCount: 140
                    remainingCapacityAh: 48.2
                    temperatureC: 24.5
                    payload:
                      measuredBy: BMS firmware 4.2.1
                    recordedAt: 2026-06-12T09:41:00.000Z
                    createdAt: 2026-06-12T09:41:05.000Z
                  - id: 5c1e9a7b-3d2f-4e8a-9c0b-7f6d4e2a1b3c
                    batteryUnitId: 9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a
                    tenantId: 2f9a41c8-5e7b-4d3a-9c16-8b0d5e7a3f21
                    eventType: CHARGE_CYCLE
                    stateOfHealth: null
                    cycleCount: 141
                    remainingCapacityAh: null
                    temperatureC: null
                    payload: null
                    recordedAt: 2026-06-13T18:05:00.000Z
                    createdAt: 2026-06-13T18:05:02.000Z
                errors: []
        "400":
          description: Invalid body envelope (missing/empty `events`, more than 500 items,
            malformed `Idempotency-Key`), a terminal unit (`Terminal Unit
            Status`), or every record failed (`Bulk Event Ingest Failed` with
            the per-record `errors`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/facilities:
    post:
      operationId: createFacility
      tags:
        - Facilities
      summary: Register a facility (GS1 GLN)
      description: >-
        Registers a manufacturing/processing facility as tenant-scoped master
        data, backing the Unique Facility Identifier (UFI, EN 18219). Passports
        reference facilities via `facilityId`.


        **Permission:** `facility:write`. Authenticate with a Bearer API key
        (`op_dpp_token_…`) or a session JWT; cookie-authenticated sessions must
        also send the `X-CSRF-Token` header (double-submit against the
        `opendpp_csrf` cookie) — Bearer clients are exempt. Write permissions
        are subscription-gated: a lapsed workspace subscription returns **402**.


        **GLN validation:** `gln` is trimmed, then must be exactly 13 digits
        with a valid GS1 modulo-10 check digit (same weighting algorithm as
        GTIN). The GLN is unique **platform-wide** (database unique constraint),
        so a duplicate returns **409** even if the existing facility belongs to
        another tenant.


        **Country:** `country` must match `^[A-Za-z]{2}$` after trimming and is
        stored uppercased.


        **Operator binding:** if `operatorId` is supplied (non-empty), that
        Economic Operator must be bound to your tenant workspace, otherwise
        **403**. An empty/whitespace `operatorId` is stored as `null`. Requests
        authenticated with an **operator-scoped API key** may only attach
        facilities to their own operator: a mismatched `operatorId` returns
        **403**, and when omitted the key's operator id is applied
        automatically.


        `activity`, `streetAddress`, `city` and `postalCode` are trimmed;
        empty/whitespace values are stored as `null`.


        **Public/privileged field split:** the public **JSON-LD** passport
        document exposes `id`, `gln`, `name`, `activity` and `country` of a
        linked facility; the public **AAS** export emits only the GLN, name and
        country (`manufacturingFacilityGln` / `manufacturingFacilityName` /
        `manufacturingFacilityCountry`, plus the GLN as a `urn:gs1:gln:` global
        asset reference) — the facility `id` and `activity` are never emitted in
        AAS. `streetAddress`, `city` and `postalCode` are owner-only in both
        formats. This endpoint returns the full row to you as the owner.


        Emits a `facility.created` audit event and an in-app notification.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FacilityCreateRequest"
            example:
              gln: "0950110153014"
              name: Munich Cell Assembly Plant
              activity: Cell assembly
              streetAddress: Werkstrasse 12
              city: Munich
              postalCode: "80331"
              country: DE
              operatorId: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b
      responses:
        "201":
          description: Facility registered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FacilityCreatedEnvelope"
              example:
                success: true
                message: Facility registered successfully
                facility:
                  id: 9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61
                  gln: "0950110153014"
                  name: Munich Cell Assembly Plant
                  activity: Cell assembly
                  streetAddress: Werkstrasse 12
                  city: Munich
                  postalCode: "80331"
                  country: DE
                  operatorId: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b
                  tenantId: 7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b
                  createdAt: 2026-06-12T09:41:00.000Z
                  updatedAt: 2026-06-12T09:41:00.000Z
        "400":
          description: 'Invalid request body. Checked in order: body must be a JSON
            object; `gln` must be a 13-digit GS1 GLN with a valid mod-10 check
            digit; `name` must be a non-empty string; `country` must be a
            2-letter ISO code. Note: a syntactically malformed JSON body is
            rejected earlier by the JSON parser with the default error shape
            (`{"statusCode": 400, "error": "Bad Request", "message": <parse
            error>}` — no `code` key), not this envelope; an empty body parses
            to `{}` and fails the `gln` check with this envelope.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                nonObjectBody:
                  value:
                    success: false
                    error: Bad Request
                    message: Request body must be a valid JSON object
                invalidGln:
                  value:
                    success: false
                    error: Bad Request
                    message: gln must be a valid GS1 Global Location Number (13 digits with a valid
                      modulo-10 check digit)
                invalidName:
                  value:
                    success: false
                    error: Bad Request
                    message: name must be a non-empty string
                invalidCountry:
                  value:
                    success: false
                    error: Bad Request
                    message: country must be a 2-letter ISO country code
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          description: "Forbidden. Route-specific causes: an operator-scoped API key
            supplied an `operatorId` other than its own (`Your access is
            restricted to Economic Operator: <id>`); or the supplied
            `operatorId` is not bound to your tenant workspace. Auth-layer
            causes also land here: insufficient permission (`facility:write`),
            missing/invalid CSRF token on a cookie session, cross-tenant
            subdomain mismatch, or MFA required when enforced."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                operatorScopeRestricted:
                  value:
                    success: false
                    error: Forbidden
                    message: "Your access is restricted to Economic Operator:
                      4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
                operatorNotBound:
                  value:
                    success: false
                    error: Forbidden
                    message: The Economic Operator with ID 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b is
                      not bound to your Tenant workspace.
                insufficientPermission:
                  value:
                    success: false
                    error: Forbidden
                    message: 'Insufficient permissions. Required: "facility:write".'
        "409":
          description: A facility with this GLN is already registered. The GLN unique
            constraint is platform-wide, so the conflict can be caused by a
            facility owned by another tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Conflict
                message: A facility with GLN 0950110153014 is already registered.
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: Internal error (standard envelope), with the message `Failed to
            register facility` for unexpected database errors; a failure inside
            the auth layer returns message `Authentication verification failed`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                createFailed:
                  summary: Unexpected database error in the create handler
                  value:
                    success: false
                    error: Internal Server Error
                    message: Failed to register facility
                authLayer:
                  summary: Failure inside the auth middleware
                  value:
                    success: false
                    error: Internal Server Error
                    message: Authentication verification failed
    get:
      operationId: listFacilities
      tags:
        - Facilities
      summary: List facilities in the tenant workspace
      description: >-
        Lists all facilities registered under your tenant workspace, sorted by
        `createdAt` descending. **Paginated** with `?page` (default 1) and
        `?limit` (default 100, max 200); `count` is this page's size,
        `total`/`totalPages` describe the full set. A non-numeric `page`/`limit`
        falls back to its default.


        **Permission:** `facility:read` (Bearer API key or session JWT/cookie).


        **Operator-scoped keys:** when authenticated with an API key scoped to
        an Economic Operator, the list contains only facilities whose
        `operatorId` equals the key's operator — facilities with no operator
        (`operatorId: null`) are **excluded** from the list (they remain
        readable individually via `GET /api/v1/facilities/{id}`).


        The full row is returned to the owner, including the privileged address
        fields (`streetAddress`, `city`, `postalCode`) that public passport
        documents never expose (owner-only in JSON-LD; never emitted in AAS).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: Facility list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FacilityListEnvelope"
              example:
                success: true
                count: 2
                facilities:
                  - id: 9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61
                    gln: "0950110153014"
                    name: Munich Cell Assembly Plant
                    activity: Cell assembly
                    streetAddress: Werkstrasse 12
                    city: Munich
                    postalCode: "80331"
                    country: DE
                    operatorId: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b
                    tenantId: 7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b
                    createdAt: 2026-06-12T09:41:00.000Z
                    updatedAt: 2026-06-12T09:41:00.000Z
                  - id: 3d8c1f27-6a4b-4e9d-a2c8-5b7e9f0a1c3d
                    gln: "0950110153021"
                    name: Rotterdam Recycling Hub
                    activity: Recycling
                    streetAddress: null
                    city: null
                    postalCode: null
                    country: NL
                    operatorId: null
                    tenantId: 7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b
                    createdAt: 2026-06-10T14:05:12.000Z
                    updatedAt: 2026-06-10T14:05:12.000Z
                page: 1
                limit: 100
                total: 2
                totalPages: 1
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
      parameters:
        - name: page
          in: query
          required: false
          description: 1-based page number (digits only; non-numeric falls back to 1).
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: limit
          in: query
          required: false
          description: Page size. Clamped to 1–200; non-numeric falls back to the default
            100.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 100
  /api/v1/facilities/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Facility id (UUID, as returned at registration). Non-existent or
          other-tenant ids return 404.
        schema:
          type: string
    get:
      operationId: getFacility
      tags:
        - Facilities
      summary: Get a single facility
      description: >-
        Fetches one facility by id, scoped to your tenant workspace.


        **Permission:** `facility:read`.


        **Operator-scoped keys:** if the facility belongs to a *different*
        Economic Operator than the key's scope, the response is **403**.
        Facilities with no operator (`operatorId: null`) **are** readable by
        operator-scoped keys here, even though they are excluded from the list
        endpoint.


        Returns the full row including the privileged address fields
        (`streetAddress`, `city`, `postalCode`) that public passport documents
        never expose. (Public exposure of a linked facility: the JSON-LD
        document shows `id`/`gln`/`name`/`activity`/`country`; the AAS export
        only the GLN, name and country.)


        **404 body:** standard envelope with message `Facility <id> not found
        under your Tenant workspace`.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: Facility found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FacilityEnvelope"
              example:
                success: true
                facility:
                  id: 9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61
                  gln: "0950110153014"
                  name: Munich Cell Assembly Plant
                  activity: Cell assembly
                  streetAddress: Werkstrasse 12
                  city: Munich
                  postalCode: "80331"
                  country: DE
                  operatorId: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b
                  tenantId: 7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b
                  createdAt: 2026-06-12T09:41:00.000Z
                  updatedAt: 2026-06-12T09:41:00.000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "Forbidden. Route-specific cause: the request used an
            operator-scoped API key and the facility belongs to a different
            Economic Operator. Auth-layer causes (insufficient `facility:read`
            permission, cross-tenant subdomain mismatch) also land here."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Forbidden
                message: "Your access is restricted to Economic Operator:
                  4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      operationId: updateFacility
      tags:
        - Facilities
      summary: Update facility master data (GLN is immutable)
      description: >-
        Partially updates a facility's master data. **The GLN itself is
        immutable** — it is the resolvable UFI identifier; a `gln` key in the
        body is silently ignored (as is `operatorId` — the operator binding
        cannot be changed here).


        **Permission:** `facility:write`. Cookie sessions must send
        `X-CSRF-Token`; write permissions are subscription-gated (**402** when
        lapsed).


        **Field semantics (all optional):**

        - `name` — applied only when a non-empty string; an empty/whitespace or
        non-string value is silently ignored (`name` can never be cleared).

        - `activity`, `streetAddress`, `city`, `postalCode` — applied whenever
        the key is *present* in the body: the value is stringified and trimmed;
        anything that trims to empty (`null`, `""`, or a whitespace-only string)
        **clears the field to null** — the same normalization as POST.

        - `country` — when present as a string it must match `^[A-Za-z]{2}$`
        (else **400**) and is stored uppercased; a non-string value is silently
        ignored.


        An empty body (or one with no recognized fields) is accepted: the
        response is **200** with the otherwise-unchanged row, though `updatedAt`
        is still bumped.


        **Operator-scoped keys:** updating a facility that belongs to a
        different Economic Operator returns **403**; facilities with
        `operatorId: null` are updatable.


        Emits a `facility.updated` audit event recording the changed fields.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FacilityUpdateRequest"
            example:
              name: Munich Cell Assembly Plant — Hall B
              activity: Final manufacturing
              streetAddress: Werkstrasse 14
              country: DE
      responses:
        "200":
          description: Updated facility (full row).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FacilityEnvelope"
              example:
                success: true
                facility:
                  id: 9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61
                  gln: "0950110153014"
                  name: Munich Cell Assembly Plant — Hall B
                  activity: Final manufacturing
                  streetAddress: Werkstrasse 14
                  city: Munich
                  postalCode: "80331"
                  country: DE
                  operatorId: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b
                  tenantId: 7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b
                  createdAt: 2026-06-12T09:41:00.000Z
                  updatedAt: 2026-06-12T10:15:30.000Z
        "400":
          description: '`country` was present as a string but is not a 2-letter ISO code.
            (This is the only body validation that errors; other invalid fields
            are silently ignored.) Note: a syntactically malformed JSON body is
            rejected earlier by the content-type parser with its **default**
            error shape (`{"statusCode": 400, "code": "FST_ERR_CTP_…", "error":
            "Bad Request", "message": …}`), not this envelope.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Bad Request
                message: country must be a 2-letter ISO country code
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          description: "Forbidden. Route-specific cause: the request used an
            operator-scoped API key and the facility belongs to a different
            Economic Operator. Auth-layer causes (insufficient `facility:write`
            permission, missing/invalid CSRF token on a cookie session,
            cross-tenant mismatch, MFA when enforced) also land here."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                operatorScopeRestricted:
                  value:
                    success: false
                    error: Forbidden
                    message: "Your access is restricted to Economic Operator:
                      4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
                insufficientPermission:
                  value:
                    success: false
                    error: Forbidden
                    message: 'Insufficient permissions. Required: "facility:write".'
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      operationId: deleteFacility
      tags:
        - Facilities
      summary: Delete a facility (passports are unlinked, never deleted)
      description: >-
        Removes the facility master-data row. **Passports are never deleted by
        this operation**: `Passport.facilityId` is a `SET NULL` foreign key, so
        any passports referencing the facility simply lose their UFI link
        (`facilityId` becomes `null`) and remain fully intact and publicly
        resolvable.


        **Permission:** `facility:write`. Cookie sessions must send
        `X-CSRF-Token`; write permissions are subscription-gated (**402** when
        lapsed).


        **Operator-scoped keys:** deleting a facility that belongs to a
        different Economic Operator returns **403**; facilities with
        `operatorId: null` are deletable.


        Emits a `facility.deleted` audit event and an in-app notification. **404
        body:** standard envelope with message `Facility <id> not found under
        your Tenant workspace`.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: Facility deleted. Minimal envelope — no `message`, no `facility`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FacilityDeletedEnvelope"
              example:
                success: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          description: "Forbidden. Route-specific cause: the request used an
            operator-scoped API key and the facility belongs to a different
            Economic Operator. Auth-layer causes (insufficient `facility:write`
            permission, missing/invalid CSRF token on a cookie session,
            cross-tenant mismatch, MFA when enforced) also land here."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Forbidden
                message: "Your access is restricted to Economic Operator:
                  4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/grants:
    get:
      operationId: listGrants
      tags:
        - Access Grants
      summary: List access grants and pending access requests
      description: >-
        Lists the workspace's access grants — capability-token grants for the
        Battery Regulation's restricted data tiers (Annex XIII(2)–(4)) —
        including undecided third-party access **requests** (`status: PENDING`,
        `issuerType: REQUEST`) submitted via the hosted request-access page.


        **Permission:** `grant:read`. **Rate limit:** your plan's per-key budget
        applies — **Growth** 120/min, **Scale** 600/min, **Enterprise**
        unlimited — with a ceiling of 3x that rate across all of the workspace's
        keys. The per-IP ceiling is not the binding limit for authenticated
        calls. Standard `x-ratelimit-*` headers; **429** carries `Retry-After`.


        Paginated with `?page` (default 1) and `?limit` (default 100, max 200),
        grouped by `status` ascending (alphabetical: `ACTIVE`, `DENIED`,
        `PENDING`, `REVOKED`) and newest-first within each group. `AUTHORITY`
        grants (platform-issued market-surveillance access) are listed for
        transparency but are not tenant-revocable (`revocable: false`). Raw
        capability tokens are never included — only issuance/approval responses
        contain them, once.


        **Pagination:** results are paged with `?page` (default 1) and `?limit`
        (default 100, max 200). The response now also carries `success`,
        `count`, `total` and `totalPages` alongside `grants`.
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: The workspace's grants and requests, paginated newest-first — a `{
            success, count, page, limit, total, totalPages, grants }` envelope.
            `limit` is clamped to its maximum and a non-numeric `limit` falls
            back to the default; there is no fixed 500-row cap.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantListResponse"
              example:
                grants:
                  - id: 9b2fa884-3c1d-4e8a-9f27-5b06c8d41a72
                    status: ACTIVE
                    kind: LEGITIMATE_INTEREST
                    granteeName: Dr. Elena Varga
                    granteeEmail: e.varga@inspection.example
                    organization: EU Battery Inspection Services
                    purpose: State-of-health verification for second-life suitability assessment
                      under Art. 77(9).
                    scopeType: UNIT
                    passportId: 4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f
                    batteryUnitId: 7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d
                    issuerType: TENANT
                    issuerEmail: admin@example.opendpp-node.eu
                    decidedAt: null
                    decidedBy: null
                    expiresAt: 2026-09-12T09:41:00.000Z
                    revokedAt: null
                    lastUsedAt: 2026-06-12T10:02:14.000Z
                    useCount: 3
                    createdAt: 2026-06-12T09:41:00.000Z
                    revocable: true
                  - id: c5d8e1f4-7a2b-4c9d-8e3f-6a1b2c3d4e5f
                    status: PENDING
                    kind: LEGITIMATE_INTEREST
                    granteeName: Marta Keller
                    granteeEmail: m.keller@recycler.example
                    organization: Circular Battery Recycling GmbH
                    purpose: Assessing remaining capacity before acquisition for repurposing (Art.
                      77(9)).
                    scopeType: UNIT
                    passportId: 4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f
                    batteryUnitId: 7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d
                    issuerType: REQUEST
                    issuerEmail: null
                    decidedAt: null
                    decidedBy: null
                    expiresAt: 2026-09-10T08:15:30.000Z
                    revokedAt: null
                    lastUsedAt: null
                    useCount: 0
                    createdAt: 2026-06-12T08:15:30.000Z
                    revocable: true
                count: 2
                page: 1
                limit: 100
                total: 2
                totalPages: 1
                success: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
      parameters:
        - name: page
          in: query
          required: false
          description: 1-based page number (digits only; non-numeric falls back to 1).
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: limit
          in: query
          required: false
          description: Page size. Clamped to 1–200; non-numeric falls back to the default
            100.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 100
    post:
      operationId: createGrant
      tags:
        - Access Grants
      summary: Issue a legitimate-interest access grant directly
      description: >-
        Directly issues an `ACTIVE` legitimate-interest access grant (no pending
        request involved) and mints its capability token. The raw token
        (`dpp_li_` + 32 hex characters) is returned **once** in this response;
        only its SHA-256 hash is stored. The grantee presents it to the public
        resolution endpoints as `Authorization: Bearer dpp_li_…` or
        `?grant=dpp_li_…` to unlock the restricted (tier-2 / per-unit) data of
        the granted scope.


        **Permission:** `grant:write` (write operations are subject to
        subscription gating, so 402 is possible). Cookie-session clients must
        send the `X-CSRF-Token` header; Bearer clients are exempt. On workspaces
        that enforce multi-factor authentication, user sessions that did not
        authenticate with a second factor receive 403 on writes (API-key clients
        are exempt). **Rate limit:** your plan's per-key budget applies —
        **Growth** 120/min, **Scale** 600/min, **Enterprise** unlimited — with a
        ceiling of 3x that rate across all of the workspace's keys. The per-IP
        ceiling is not the binding limit for authenticated calls. Standard
        `x-ratelimit-*` headers; **429** carries `Retry-After`.


        Scope semantics:

        - `UNIT` — `batteryUnitId` is required; the unit must belong to this
        workspace. The unit's parent `passportId` is recorded on the grant.

        - `PASSPORT` — `passportId` is required; the passport must belong to
        this workspace and must not be a `DRAFT` (drafts return 404).

        - `TENANT` — workspace-wide; no target id needed.


        `expiresAt` is required, must be in the future, and at most **366 days**
        out. This endpoint always mints `kind: LEGITIMATE_INTEREST` —
        `AUTHORITY` (`dpp_auth_…`) grants are platform-issued only and cannot be
        created here. The issuance is audited as `grant.issued`.


        String fields longer than their documented maximum are **silently
        truncated**, not rejected; unknown fields are ignored.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateGrantRequest"
            example:
              granteeName: Dr. Elena Varga
              granteeEmail: e.varga@inspection.example
              organization: EU Battery Inspection Services
              purpose: State-of-health verification for second-life suitability assessment
                under Art. 77(9).
              scopeType: UNIT
              batteryUnitId: 7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d
              expiresAt: 2026-09-12T09:41:00.000Z
      responses:
        "201":
          description: Grant issued. `token` is the raw capability token — shown only
            here, store it now.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantIssuedResponse"
              example:
                success: true
                grant:
                  id: 9b2fa884-3c1d-4e8a-9f27-5b06c8d41a72
                  status: ACTIVE
                  kind: LEGITIMATE_INTEREST
                  granteeName: Dr. Elena Varga
                  granteeEmail: e.varga@inspection.example
                  organization: EU Battery Inspection Services
                  purpose: State-of-health verification for second-life suitability assessment
                    under Art. 77(9).
                  scopeType: UNIT
                  passportId: 4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f
                  batteryUnitId: 7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d
                  issuerType: TENANT
                  issuerEmail: admin@example.opendpp-node.eu
                  decidedAt: null
                  decidedBy: null
                  expiresAt: 2026-09-12T09:41:00.000Z
                  revokedAt: null
                  lastUsedAt: null
                  useCount: 0
                  createdAt: 2026-06-12T09:41:00.000Z
                  revocable: true
                token: dpp_li_00000000000000000000000000000001
        "400":
          description: "Validation failure. Body omits the `success` field. `message` is
            one of: `granteeName is required`, `granteeEmail is invalid`,
            `scopeType must be UNIT, PASSPORT or TENANT`, `expiresAt is
            required`, `expiresAt must be an ISO-8601 date`, `expiresAt must be
            in the future`, `expiresAt must be within 366 days`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantRouteError"
              example:
                error: Bad Request
                message: expiresAt must be within 366 days
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: "For `scopeType` `UNIT`/`PASSPORT`: the target id does not exist in
            this workspace (cross-tenant targets and `DRAFT` passports are
            indistinguishable from missing ones). Omitting the target id
            required by the `scopeType` also returns this 404 — there is no
            separate 400 for a missing target id. Body omits the `success`
            field."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantRouteError"
              example:
                error: Not Found
                message: Scope target not found in this workspace
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/grants/{id}/approve:
    post:
      operationId: approveGrantRequest
      tags:
        - Access Grants
      summary: Approve a pending access request and mint its token
      description: >-
        Approves a `PENDING` third-party access request (submitted via the
        hosted request-access page). Approval mints the legitimate-interest
        capability token **at this moment** — pending requests carry no token —
        sets `status: ACTIVE`, records `decidedAt`/`decidedBy`, and replaces the
        request's provisional 90-day expiry with the `expiresAt` you supply
        (required; future; max **366 days** out).


        The raw token is returned **once** in this response. If the request has
        a `granteeEmail`, the grantee is additionally e-mailed an inspection
        link containing the token (`…/unit/{batteryUnitId}?grant=dpp_li_…` or
        `…/passport/{passportId}?grant=dpp_li_…`) — the only other place the raw
        token ever exists. The decision is audited as `grant.approved`.


        **Permission:** `grant:write` (subscription gating ⇒ 402 possible;
        cookie sessions need `X-CSRF-Token`; on workspaces enforcing
        multi-factor authentication, user sessions without a second factor get
        403 — API-key clients exempt). **Rate limit:** your plan's per-key
        budget applies — **Growth** 120/min, **Scale** 600/min, **Enterprise**
        unlimited — with a ceiling of 3x that rate across all of the workspace's
        keys. The per-IP ceiling is not the binding limit for authenticated
        calls. Standard `x-ratelimit-*` headers; **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The access-request (AccessGrant) id.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ApproveGrantRequest"
            example:
              expiresAt: 2026-09-12T09:41:00.000Z
      responses:
        "200":
          description: Request approved; the capability token is shown only here (and in
            the grantee e-mail).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantIssuedResponse"
              example:
                success: true
                grant:
                  id: c5d8e1f4-7a2b-4c9d-8e3f-6a1b2c3d4e5f
                  status: ACTIVE
                  kind: LEGITIMATE_INTEREST
                  granteeName: Marta Keller
                  granteeEmail: m.keller@recycler.example
                  organization: Circular Battery Recycling GmbH
                  purpose: Assessing remaining capacity before acquisition for repurposing (Art.
                    77(9)).
                  scopeType: UNIT
                  passportId: 4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f
                  batteryUnitId: 7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d
                  issuerType: REQUEST
                  issuerEmail: null
                  decidedAt: 2026-06-12T09:41:00.000Z
                  decidedBy: admin@example.opendpp-node.eu
                  expiresAt: 2026-09-12T09:41:00.000Z
                  revokedAt: null
                  lastUsedAt: null
                  useCount: 0
                  createdAt: 2026-06-12T08:15:30.000Z
                  revocable: true
                token: dpp_li_00000000000000000000000000000002
        "400":
          description: "Invalid `expiresAt`. Body omits the `success` field. `message` is
            one of: `expiresAt is required`, `expiresAt must be an ISO-8601
            date`, `expiresAt must be in the future`, `expiresAt must be within
            366 days`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantRouteError"
              example:
                error: Bad Request
                message: expiresAt is required
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No grant with this id exists in this workspace. Body omits the
            `success` field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantRouteError"
              example:
                error: Not Found
                message: Grant not found
        "409":
          description: The grant is not `PENDING` (already decided, active, or revoked).
            Body omits the `success` field; the current status is interpolated
            into the message.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantRouteError"
              example:
                error: Conflict
                message: "Only PENDING requests can be approved (status: ACTIVE)"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/grants/{id}/deny:
    post:
      operationId: denyGrantRequest
      tags:
        - Access Grants
      summary: Deny a pending access request
      description: >-
        Denies a `PENDING` third-party access request: sets `status: DENIED` and
        records `decidedAt`/`decidedBy`. No token is ever minted for a denied
        request, and no e-mail is sent to the requester. The decision is audited
        as `grant.denied`. The request body, if any, is ignored.


        **Permission:** `grant:write` (subscription gating ⇒ 402 possible;
        cookie sessions need `X-CSRF-Token`; on workspaces enforcing
        multi-factor authentication, user sessions without a second factor get
        403 — API-key clients exempt). **Rate limit:** your plan's per-key
        budget applies — **Growth** 120/min, **Scale** 600/min, **Enterprise**
        unlimited — with a ceiling of 3x that rate across all of the workspace's
        keys. The per-IP ceiling is not the binding limit for authenticated
        calls. Standard `x-ratelimit-*` headers; **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The access-request (AccessGrant) id.
          schema:
            type: string
      responses:
        "200":
          description: Request denied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantDecisionResponse"
              example:
                success: true
                grant:
                  id: c5d8e1f4-7a2b-4c9d-8e3f-6a1b2c3d4e5f
                  status: DENIED
                  kind: LEGITIMATE_INTEREST
                  granteeName: Marta Keller
                  granteeEmail: m.keller@recycler.example
                  organization: Circular Battery Recycling GmbH
                  purpose: Assessing remaining capacity before acquisition for repurposing (Art.
                    77(9)).
                  scopeType: UNIT
                  passportId: 4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f
                  batteryUnitId: 7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d
                  issuerType: REQUEST
                  issuerEmail: null
                  decidedAt: 2026-06-12T09:41:00.000Z
                  decidedBy: admin@example.opendpp-node.eu
                  expiresAt: 2026-09-10T08:15:30.000Z
                  revokedAt: null
                  lastUsedAt: null
                  useCount: 0
                  createdAt: 2026-06-12T08:15:30.000Z
                  revocable: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No grant with this id exists in this workspace. Body omits the
            `success` field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantRouteError"
              example:
                error: Not Found
                message: Grant not found
        "409":
          description: The grant is not `PENDING` (already decided, active, or revoked).
            Body omits the `success` field; the current status is interpolated
            into the message.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantRouteError"
              example:
                error: Conflict
                message: "Only PENDING requests can be denied (status: DENIED)"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/grants/{id}:
    delete:
      operationId: revokeGrant
      tags:
        - Access Grants
      summary: Revoke an access grant (soft revocation)
      description: >-
        Soft-revokes a grant: sets `status: REVOKED` and `revokedAt` (the row is
        retained for audit; the public resolvers reject the token from then on).
        Audited as `grant.revoked`.


        Behavioral caveats (no status precondition — only the kind is checked):

        - Works on a grant in **any** status: revoking a `PENDING` request
        withdraws it; revoking a `DENIED` grant flips it to `REVOKED`.

        - Re-revoking an already-`REVOKED` grant returns 200 again and preserves
        the original `revokedAt`.

        - `AUTHORITY` grants (`kind: AUTHORITY`, platform-issued
        market-surveillance access) are **not tenant-revocable** — 403. Battery
        Reg. Art. 77 market-surveillance access must not depend on manufacturer
        consent; platform admins manage those.


        **Permission:** `grant:write` (subscription gating ⇒ 402 possible;
        cookie sessions need `X-CSRF-Token`; on workspaces enforcing
        multi-factor authentication, user sessions without a second factor get
        403 — API-key clients exempt). **Rate limit:** your plan's per-key
        budget applies — **Growth** 120/min, **Scale** 600/min, **Enterprise**
        unlimited — with a ceiling of 3x that rate across all of the workspace's
        keys. The per-IP ceiling is not the binding limit for authenticated
        calls. Standard `x-ratelimit-*` headers; **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The grant (AccessGrant) id.
          schema:
            type: string
      responses:
        "200":
          description: "Grant revoked (idempotent: re-revoking keeps the original
            `revokedAt`)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantDecisionResponse"
              example:
                success: true
                grant:
                  id: 9b2fa884-3c1d-4e8a-9f27-5b06c8d41a72
                  status: REVOKED
                  kind: LEGITIMATE_INTEREST
                  granteeName: Dr. Elena Varga
                  granteeEmail: e.varga@inspection.example
                  organization: EU Battery Inspection Services
                  purpose: State-of-health verification for second-life suitability assessment
                    under Art. 77(9).
                  scopeType: UNIT
                  passportId: 4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f
                  batteryUnitId: 7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d
                  issuerType: TENANT
                  issuerEmail: admin@example.opendpp-node.eu
                  decidedAt: null
                  decidedBy: null
                  expiresAt: 2026-09-12T09:41:00.000Z
                  revokedAt: 2026-06-12T11:30:00.000Z
                  lastUsedAt: 2026-06-12T10:02:14.000Z
                  useCount: 3
                  createdAt: 2026-06-12T09:41:00.000Z
                  revocable: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          description: "Two distinct bodies share this status: (1) **route-level** — the
            grant is an `AUTHORITY` grant and cannot be revoked by the
            workspace; body is `{error, message}` **without** a `success` field
            (see example); (2) **middleware** — insufficient permission,
            missing/invalid CSRF token, cross-tenant access, or MFA required;
            standard envelope `{success: false, error, message}`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantRevokeForbidden"
              example:
                error: Forbidden
                message: "Authority grants are platform-managed: market-surveillance access
                  cannot be revoked by the workspace."
        "404":
          description: No grant with this id exists in this workspace. Body omits the
            `success` field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantRouteError"
              example:
                error: Not Found
                message: Grant not found
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/operators:
    get:
      operationId: listOperators
      tags:
        - Economic Operators
      summary: List economic operators bound to your workspace
      description: >-
        Returns the economic operators bound to your workspace, ordered by name.
        Active operators only unless `?archived=true` is passed (archived
        operators are off-boarded but their passports are retained and still
        publicly resolvable).


        Each entry is the same `OperatorRow` shape returned by `POST`/`PATCH
        /api/v1/operators` — use the `id` to attribute a passport (`operatorId`
        on `POST /api/v1/passports`) or to address `PATCH`/`DELETE`.


        **Permission:** `operator:read`. Requests authenticated with an
        **operator-scoped API key** see only their own operator.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: archived
          in: query
          required: false
          description: Set `true` to include archived (off-boarded) operators. Default
            returns active operators only.
          schema:
            type: string
            enum:
              - "true"
              - "false"
      responses:
        "200":
          description: The bound operators.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OperatorListResponse"
              example:
                success: true
                count: 1
                operators:
                  - id: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b
                    name: Demo Manufacturing UAB
                    regId: EU-DEFAULT-001
                    regIdScheme: EORI
                    role: MANUFACTURER
                    archivedAt: null
                    createdAt: 2026-06-12T09:41:00.000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      operationId: registerOperator
      tags:
        - Economic Operators
      summary: Register an economic operator and bind it to your workspace
      description: >-
        Registers an economic operator (manufacturer, importer, supplier, …) and
        binds it to your workspace.


        **Permission:** `operator:create`. Cookie-session clients must send the
        `X-CSRF-Token` header (double-submit); Bearer clients (API key / JWT)
        are exempt.


        **Deduplication (per workspace):** operators are scoped to your
        workspace — `regId` is unique *within* your workspace, not across the
        platform. If **your workspace** already has an operator with the
        submitted `regId`, that existing record is returned and the submitted
        `name`, `role` and `regIdScheme` are **ignored**. A `regId` already used
        by *another* workspace is irrelevant — you always get **your own**
        operator row (so one workspace can never bind to, rename, or archive
        another's operator). The call is idempotent: re-registering an
        already-bound operator succeeds with `201` again. The per-workspace
        match includes **archived** operators: if your workspace's operator for
        that `regId` is archived, the archived record is returned as-is
        (`archivedAt` non-null) with `201` — registration does not un-archive
        it; use `POST /api/v1/operators/{id}/restore` to reactivate it.


        **Registration-id integrity:** fabricated `EORI-MOCK…` ids are rejected
        on every path. When `regIdScheme` is `EORI`, `regId` must match
        `^[A-Z]{2}[A-Za-z0-9]{1,15}$` (2-letter ISO 3166 country prefix followed
        by up to 15 alphanumerics, e.g. `DE1234567890`). Validation is
        syntax-only by default. When the node operator enables the OPT-IN EORI
        existence check, a declared `EORI` `regId` is additionally checked for
        EXISTENCE against the EU Commission EOS validation service and, if not
        found, a NON-BLOCKING advisory is added to the 201 `warnings[]` — the
        operator is still registered (best-effort, fail-open: a network error,
        or a freshly-issued / GB EORI, never blocks registration). With the
        check off, `warnings` is `[]`.


        Side effects: an `operator.created` audit event and an in-app
        notification are recorded.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegisterOperatorRequest"
            example:
              name: Default EU Manufacturing Operator
              regId: EU-DEFAULT-001
              role: MANUFACTURER
      responses:
        "201":
          description: Operator registered (or an existing operator with the same `regId`
            was bound to your workspace). Returned in both cases — inspect the
            returned `operator` to see whether your submitted `name`/`role` were
            applied or a pre-existing record was reused. A pre-existing record
            may even be archived (`archivedAt` non-null); binding does not
            un-archive it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RegisterOperatorResponse"
              example:
                success: true
                message: Economic Operator supplier registered successfully
                operator:
                  id: 9b2fa884-7c1d-4e7a-9a64-2f8d3b5c6e01
                  name: Default EU Manufacturing Operator
                  regId: EU-DEFAULT-001
                  regIdScheme: null
                  role: MANUFACTURER
                  archivedAt: null
                  createdAt: 2026-06-12T09:41:00.000Z
        "400":
          description: 'Two distinct bodies. Missing `name`/`regId` returns the minimal
            envelope **without** an `error` key. A `regId`/`regIdScheme`
            validation failure (whitespace-only `regId`, fabricated `EORI-MOCK…`
            id, unknown scheme, or invalid EORI syntax) returns the standard
            envelope with `error: "Bad Request"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OperatorMinimalErrorResponse"
              examples:
                missingParameters:
                  summary: name or regId missing (no error key)
                  value:
                    success: false
                    message: "Missing supplier parameters: name and regId are required"
                whitespaceRegId:
                  summary: regId is whitespace-only (standard envelope)
                  value:
                    success: false
                    error: Bad Request
                    message: regId is required
                invalidRegId:
                  summary: regId validation failure (standard envelope)
                  value:
                    success: false
                    error: Bad Request
                    message: Fabricated registration ids (EORI-MOCK…) are not accepted — register
                      the operator's real registration id.
                invalidEoriSyntax:
                  summary: regIdScheme=EORI with bad syntax
                  value:
                    success: false
                    error: Bad Request
                    message: regId is not a syntactically valid EORI (expected a 2-letter country
                      code followed by up to 15 alphanumeric characters, e.g.
                      DE1234567890).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: Database/handler failure. Returns the standard envelope with the
            generic message "An unexpected error occurred."; the underlying
            error is logged server-side and never returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OperatorMinimalErrorResponse"
              example:
                success: false
                error: Internal Server Error
                message: An unexpected error occurred.
  /api/v1/operators/{id}:
    get:
      operationId: getOperator
      tags:
        - Economic Operators
      summary: Fetch a single bound economic operator
      description: >-
        Fetches one economic operator by UUID, scoped to your workspace (`404`
        if no operator with that id exists in your workspace).


        **Permission:** `operator:read`. An **operator-scoped API key** may only
        fetch its own operator (`403` otherwise).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Operator UUID (`EconomicOperator.id`). Must be bound to your
            workspace.
          schema:
            type: string
      responses:
        "200":
          description: The operator.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OperatorGetResponse"
              example:
                success: true
                operator:
                  id: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b
                  name: Demo Manufacturing UAB
                  regId: EU-DEFAULT-001
                  regIdScheme: EORI
                  role: MANUFACTURER
                  archivedAt: null
                  createdAt: 2026-06-12T09:41:00.000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
          description: 'No operator with that id exists in your workspace. Body: {
            success: false, error: "Not Found", message: "Operator <id> not
            found in your workspace." }.'
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
    patch:
      operationId: updateOperator
      tags:
        - Economic Operators
      summary: Update an operator's name or role (regId is immutable)
      description: >-
        Edits an operator bound to your workspace. Only `name` and `role` are
        editable; `regId` is the legal registry identifier and is
        **intentionally immutable** here (register the operator again under the
        correct id instead). Non-string or whitespace-only values are silently
        ignored; submitted values are trimmed. If no usable field is supplied
        (every field missing, non-string, or whitespace-only — including an
        empty object `{}` or an omitted body), the current operator row is
        returned unchanged with `200` and no audit event is written. The handler
        does not diff against current values: supplying a value identical to the
        current one still performs an update and writes an audit event.


        **Permission:** `operator:write`. Cookie-session clients must send
        `X-CSRF-Token`; Bearer clients are exempt.


        **Tenant-scoped:** operators are scoped to your workspace — `regId` is
        not globally unique. Registering a `regId` that another workspace also
        uses creates **your own** operator row; `name`/`role` edits never affect
        another workspace.


        When a change is applied, an `operator.updated` audit event is recorded.
        Unhandled database errors are normalized by the global error handler to
        the standard `{success: false, error, message}` envelope with a generic
        message (details are logged server-side).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Operator UUID (`EconomicOperator.id`). Must be bound to your
            workspace.
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateOperatorRequest"
            example:
              name: Default EU Manufacturing Operator B.V.
              role: IMPORTER
      responses:
        "200":
          description: The (possibly unchanged) operator row. If the body contained no
            usable field, the current row is echoed back.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpdateOperatorResponse"
              example:
                success: true
                operator:
                  id: 9b2fa884-7c1d-4e7a-9a64-2f8d3b5c6e01
                  name: Default EU Manufacturing Operator B.V.
                  regId: EU-DEFAULT-001
                  regIdScheme: null
                  role: IMPORTER
                  archivedAt: null
                  createdAt: 2026-06-12T09:41:00.000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/OperatorNotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: Server-side failure. Unexpected server error. Unhandled errors are
            normalized by the global error handler to the standard envelope with
            the generic message "An unexpected error occurred"; details are
            logged server-side, never returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                authLayerError:
                  summary: Authentication-layer failure (standard envelope)
                  value:
                    success: false
                    error: Internal Server Error
                    message: Authentication verification failed
    delete:
      operationId: deleteOperator
      tags:
        - Economic Operators
      summary: Remove an operator (archives if it has passports, else hard-deletes)
      description: >-
        Removes an operator, choosing automatically between two outcomes (ESPR
        passport-persistence compliance — an operator that still has passports
        must never be hard-deleted):


        - **Archive (soft delete)** — if the operator has one or more passports,
        it is archived instead of deleted: `archivedAt` is set on the operator
        and every active passport of the operator is archived with a
        `retentionUntil` deadline set to a platform-configured retention period
        from now (default 15 years). Archived passports remain **publicly
        resolvable** (the persistence duty) but are excluded from active
        management lists. Response: `{success: true, archived: true,
        archivedPassports: <n>}`. Fully reversible via `POST
        /api/v1/operators/{id}/restore`.

        - **Hard delete** — if the operator has no passports it is permanently
        deleted (tenant bindings cascade-delete; user/facility/API-key
        references are set to null). Response: `{success: true, archived:
        false}` — no `archivedPassports` field.

        - **Fallback** — if the hard delete fails on a residual foreign-key
        reference, the operator is archived instead and the response is
        `{success: true, archived: true}` **without** `archivedPassports`. If
        even the fallback archive fails, `409` is returned.


        **Permission:** `operator:write`. Cookie-session clients must send
        `X-CSRF-Token`; Bearer clients are exempt. The operator must be bound to
        your workspace (`404` otherwise).


        **Tenant-scoped:** this affects only **your** workspace's operator and
        passports — operators are not shared across workspaces.


        Side effects: an `operator.archived` or `operator.deleted` audit event
        plus an in-app notification — on the primary archive and hard-delete
        paths only; the foreign-key fallback archive writes **no** audit event
        or notification. Unhandled database errors are normalized by the global
        error handler to the standard `{success: false, error, message}`
        envelope with a generic message (details are logged server-side).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Operator UUID (`EconomicOperator.id`). Must be bound to your
            workspace.
          schema:
            type: string
      responses:
        "200":
          description: "Operator removed. `archived: true` = soft-deleted (passports
            retained and still publicly resolvable; restorable); `archived:
            false` = hard-deleted. `archivedPassports` is present only on the
            primary archive path — it is absent on hard deletes and on the
            foreign-key fallback archive."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeleteOperatorResponse"
              examples:
                archivedWithPassports:
                  summary: "Operator had passports: archived"
                  value:
                    success: true
                    archived: true
                    archivedPassports: 12
                hardDeleted:
                  summary: "Operator had no passports: hard-deleted"
                  value:
                    success: true
                    archived: false
                fallbackArchived:
                  summary: "Hard delete blocked by a residual reference: archived (no count)"
                  value:
                    success: true
                    archived: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/OperatorNotFound"
        "409":
          description: "The operator could neither be hard-deleted nor archived (both
            attempts failed). Note: minimal envelope without an `error` key."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OperatorMinimalError"
              example:
                success: false
                message: Operator could not be removed; it is still referenced.
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: Server-side failure. Unexpected server error. Unhandled errors are
            normalized by the global error handler to the standard envelope with
            the generic message "An unexpected error occurred"; details are
            logged server-side, never returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                authLayerError:
                  summary: Authentication-layer failure (standard envelope)
                  value:
                    success: false
                    error: Internal Server Error
                    message: Authentication verification failed
  /api/v1/operators/{id}/restore:
    post:
      operationId: restoreOperator
      tags:
        - Economic Operators
      summary: Restore an archived operator and its archived passports
      description: >-
        Un-archives an operator that was soft-deleted by `DELETE
        /api/v1/operators/{id}` and brings its archived passports back into the
        active catalogue: clears the operator's `archivedAt`, then clears
        `archivedAt` and `retentionUntil` on every archived passport of the
        operator **except** passports that were independently `DECOMMISSIONED`
        (those keep their own retention clock and stay archived).


        Safe to call on a non-archived operator — it simply restores any
        archived passports the operator may have (`restoredPassports` may be
        `0`). No request body.


        **Permission:** `operator:write`. Cookie-session clients must send
        `X-CSRF-Token`; Bearer clients are exempt. `404` if the operator is not
        bound to your workspace.


        Side effects: an `operator.restored` audit event and an in-app
        notification. Unhandled database errors are normalized by the global
        error handler to the standard `{success: false, error, message}`
        envelope with a generic message (details are logged server-side).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Operator UUID (`EconomicOperator.id`). Must be bound to your
            workspace.
          schema:
            type: string
      responses:
        "200":
          description: Operator un-archived. `restoredPassports` is the number of
            passports returned to the active catalogue (excludes independently
            DECOMMISSIONED passports).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RestoreOperatorResponse"
              example:
                success: true
                restoredPassports: 12
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/OperatorNotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: Server-side failure. Unexpected server error. Unhandled errors are
            normalized by the global error handler to the standard envelope with
            the generic message "An unexpected error occurred"; details are
            logged server-side, never returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                authLayerError:
                  summary: Authentication-layer failure (standard envelope)
                  value:
                    success: false
                    error: Internal Server Error
                    message: Authentication verification failed
  /api/v1/tenants/rotate-keys:
    post:
      operationId: rotateTenantKeys
      tags:
        - eIDAS Keys
      summary: Rotate the tenant's ECDSA signing key pair
      description: >-
        Generates a brand-new ECDSA **prime256v1 (P-256)** key pair for your
        workspace's advanced-seal signing and rotates it into the encrypted
        database vault, replacing the previous key. No request body is required;
        a valid JSON body, if sent, is ignored.


        What happens:

        - The new private key (PKCS#8) is encrypted with AES-256-GCM (per-entry
        HKDF-derived key; the tenant id is bound as GCM additional authenticated
        data) and upserted into the vault — the **previous private key is
        overwritten and unrecoverable**.

        - The tenant's published `eidasPublicKey` is updated to the new public
        key (SPKI PEM), which is also returned in the response.

        - A best-effort X.509 identity certificate is minted from the platform
        seal CA, binding the new key to the tenant's legal name (creator
        identification); a certificate-minting failure does **not** fail the
        rotation (the certificate fields simply stay null until backfilled).


        **Operational impact:** rotation does not invalidate existing seals.
        Each sealed passport embeds the signing public key and certificate chain
        at sealing time, so previously sealed passports keep verifying with
        their embedded key material. Passports sealed after rotation use the new
        key.


        **Permission:** `key:write`. Cookie-session clients must send
        `X-CSRF-Token`; Bearer clients are exempt.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: Key pair rotated. `publicKey` is the new public key as an SPKI PEM
            string (the private key never leaves the encrypted vault).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RotateTenantKeysResponse"
              example:
                success: true
                message: eIDAS Asymmetric Key Pair generated and rotated in secure DB custody
                  successfully
                publicKey: |
                  -----BEGIN PUBLIC KEY-----
                  MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb2zJ9bQ4mB1S0aD3kXq8YV5nW7Hc
                  PdLmA2tFx0RkNqUjEe6ZsK1vGyTwoCh3M4i5O8rJlD9fXaB0nSgQp7Y2uw==
                  -----END PUBLIC KEY-----
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: "Key generation, vault encryption, or database failure. The route
            handler emits the minimal envelope (`{success: false, message}`,
            **no** `error` key, message taken from the underlying error); a
            failure inside the authentication layer instead emits the standard
            three-field envelope."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OperatorMinimalErrorResponse"
              example:
                success: false
                message: tenantId is required for key provisioning
  /api/v1/passports:
    post:
      operationId: createPassport
      tags:
        - Passports
      summary: Create (ingest) a Digital Product Passport
      description: >-
        Creates a SKU/type-level Digital Product Passport.


        **Permission:** `passport:create` (Bearer `op_dpp_token_…` API key or
        session JWT; cookie sessions must also send the `X-CSRF-Token`
        double-submit header). Write operations are subject to subscription
        gating (**402**) and, where the workspace enforces it, MFA (**403**).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`. **Body limit: 1 MiB (1,048,576 bytes)** →
        **413** beyond that.


        **Validation.** Unless `draft: true`, `metadata` is validated against
        the ESPR category rules for `metadata.category` plus cross-field rules
        (e.g. `materialComposition` percentages must sum to 100 ±0.1,
        `originCountry` must be a real ISO 3166-1 alpha-2 code). For five
        categories (textiles, batteries, electronics, chemicals, construction)
        the authoritative per-category JSON Schema is served live at `GET
        /api/v1/schemas/{category}`; the other four (cosmetics, toys,
        iron-steel, aluminium) are validated by built-in server-side rules and
        `GET /api/v1/schemas/{category}` returns **404** for them. Failure
        returns the **400 Validation Failed** body with per-field `errors[]`
        (plus `warnings[]` when any exist — the key is omitted entirely when
        there are none). A passing payload may still produce non-blocking
        `warnings[]`, echoed in the 201 — including a **privacy-by-design
        advisory** when the metadata *looks* like it carries personal data (a
        clearly-personal field name such as `email`/`firstName`, or an
        email-shaped value; scanned one level deep, at most one such advisory).
        A DPP should carry PRODUCT data, not PII (ESPR FAQ Q16); this advisory
        never blocks the save. `friendlyMessage` texts are localized via
        `?lang=` or `Accept-Language` (default `en`); category-validity errors
        (`metadata.category` missing or unknown) carry no `friendlyMessage`.


        **Drafts.** `draft: true` skips ALL validation, stores the passport with
        `status: "DRAFT"` (not publicly resolvable), returns `message: "Draft
        passport saved"` with `warnings: []`, and does **not** emit a webhook.


        **Identifier handling.** `productId` may be a GTIN-14 (14 digits, GS1
        mod-10 check digit), a GRAI (14-digit numeric asset id + up to 16
        alphanumeric serial chars), or a free-form SKU. A 14-digit `productId`
        whose GS1 mod-10 check digit is invalid is rejected with **400** (a
        typo'd GTIN is never silently downgraded to a SKU); a non-numeric or
        non-14-digit `productId` is accepted as a non-GS1 SKU and carries a
        non-blocking `warnings[]` advisory that it resolves via `/passport/{id}`
        with no scannable GS1 QR. A valid GTIN-14 is auto-copied into
        `metadata.gtin` (a GRAI into `metadata.grai`) before storage. The server
        mints a UUID passport id and a GS1 Digital Link URI
        `https://opendpp-node.eu/{01|8003}/{productId}`.


        **Operator binding.** With `operatorId` omitted, the passport is
        attributed to the first economic operator bound to your workspace; if no
        operator is bound at all the request fails **400** (the API never
        fabricates an operator identity — register one via `POST
        /api/v1/operators`). An `operatorId` not bound to your workspace →
        **403**. Operator-scoped API keys force their own operator and **403**
        on mismatch. The `(productId, operatorId)` pair is unique → **409** on
        duplicates. An optional `facilityId` must reference a Facility in your
        workspace (**400** otherwise).


        **Webhook:** non-draft creation transactionally enqueues a
        `passport.ingested` event whose payload is the public redacted JSON-LD
        passport document (same masking as the 201 `passport` field). Drafts
        emit nothing.


        **Response caveats:** the 201 `passport` field is the **public,
        redacted** JSON-LD representation — even for the creator. The owner-only
        metadata key `facilityDetails` is replaced with the literal placeholder
        `"[REDACTED - Privileged Access Required]"` (it appears as the
        placeholder even when you did not supply it), and for `category:
        "batteries"` the restricted legitimate-interest keys
        `detailedPerformance`, `lifecycleAndInUse` and
        `circularityAndDisassembly` (Battery Reg. Annex XIII parts 2-4) are
        masked the same way when present. `enrichment` is stored outside the
        validated metadata and Merkle seal and never appears in the JSON-LD
        document. The 201 body's top-level fields are `success`, `message`,
        `passport`, `warnings`, and the `vcReady`/`vcReadyReason` UNTP
        Verifiable-Credential readiness signal.


        **Other 400 bodies:** non-validation failures (whitespace-only
        `productId`, no bound operator, unknown `facilityId`) reuse status 400
        with the plain `{"success": false, "error": "Bad Request", "message":
        …}` triple and **no** `errors`/`warnings` arrays. Requests rejected
        before the handler runs — request-body schema violations (e.g. missing
        `productId`) and malformed JSON — come back as just `{"error": "Bad
        Request", "message": …}`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 255
          description: "Optional client idempotency key (≤255 characters, no control
            characters). Retrying this request with the same `Idempotency-Key`
            replays the ORIGINAL response — same status and body, plus an
            `idempotent-replayed: true` response header — instead of creating a
            duplicate passport or returning **409**. Scoped per (workspace,
            endpoint, key) and consulted within a 24-hour window; a malformed
            key returns **400**. Best-effort: the replay is recorded after the
            write commits, so in the rare window between commit and recording
            (or across an instance restart) a retry falls back to normal
            processing — never a double write, but you may then see the normal
            **409** instead of a replay."
        - name: lang
          in: query
          required: false
          schema:
            type: string
          description: "Locale for localized `friendlyMessage` validation texts. One of:
            en, bg, hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv, lt, mt,
            pl, pt, ro, sk, sl, es, sv, no, is, uk, tr. Unknown values are
            ignored; falls back to `Accept-Language`, then `en`."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PassportCreateRequest"
            example:
              productId: "09501101530003"
              operatorId: 5c1e0f3a-7b2d-4c8e-a91f-2d3e4f5a6b7c
              metadata:
                category: iron-steel
                originCountry: DE
                materialComposition:
                  - material: Recycled steel
                    percentage: 62.5
                  - material: Virgin steel
                    percentage: 37.5
                facilityDetails:
                  - facilityName: Musterstahl Works Duisburg
                    location: Duisburg, DE
                    activity: Hot rolling
                regulatoryCompliance:
                  ceMarking: true
                  certificates:
                    - name: EN 10025-2 Mill Certificate
                      referenceNumber: MC-2026-00417
                      issuer: TUV Rheinland
                scrapMetalContentRatio: 62.5
                tensileStrengthClass: S355
                carbonEmissionIntensityPerTon: 1.42
              enrichment:
                tagline: Low-carbon structural steel
                images:
                  - url: https://cdn.example.com/steel-beam.jpg
                    caption: S355 beam
      responses:
        "201":
          description: Passport created (or draft saved). `passport` is the public
            redacted JSON-LD document; `warnings` is always present (empty array
            when none, and always empty for drafts).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportIngestCreated"
              example:
                success: true
                message: Digital Product Passport successfully validated and ingested
                passport:
                  "@context":
                    - https://opendpp-node.eu/contexts/dpp/v1
                    - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                      economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                      manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                      metadata: https://opendpp-node.eu/ns/dpp#metadata
                      digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                      signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                      status: https://opendpp-node.eu/ns/dpp#status
                      archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                      retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                      category: https://opendpp-node.eu/contexts/dpp/v1#category
                      originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                      materialComposition: https://opendpp-node.eu/contexts/dpp/v1#materialComposition
                      facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                      regulatoryCompliance: https://opendpp-node.eu/contexts/dpp/v1#regulatoryCompliance
                      scrapMetalContentRatio: https://opendpp-node.eu/contexts/dpp/v1#scrapMetalContentRatio
                      tensileStrengthClass: https://opendpp-node.eu/contexts/dpp/v1#tensileStrengthClass
                      carbonEmissionIntensityPerTon: https://opendpp-node.eu/contexts/dpp/v1#carbonEmissionIntensityPerTon
                      gtin: https://opendpp-node.eu/contexts/dpp/v1#gtin
                  "@type": DigitalProductPassport
                  "@id": https://opendpp-node.eu/01/09501101530003
                  id: 9b2fa884-1c7d-4a0e-9d3b-5f6a7c8e9012
                  productId: "09501101530003"
                  digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                  digitalSeal: null
                  signingPublicKey: null
                  status: ACTIVE
                  archivedAt: null
                  retentionUntil: null
                  proof: null
                  createdAt: 2026-06-12T09:41:00.000Z
                  updatedAt: 2026-06-12T09:41:00.000Z
                  economicOperator:
                    "@type": EconomicOperator
                    id: 5c1e0f3a-7b2d-4c8e-a91f-2d3e4f5a6b7c
                    name: Demo Manufacturing GmbH
                    regId: EU-DEFAULT-001
                    role: MANUFACTURER
                  manufacturingFacility: null
                  metadata:
                    category: iron-steel
                    originCountry: DE
                    materialComposition:
                      - material: Recycled steel
                        percentage: 62.5
                      - material: Virgin steel
                        percentage: 37.5
                    facilityDetails: "[REDACTED - Privileged Access Required]"
                    regulatoryCompliance:
                      ceMarking: true
                      certificates:
                        - name: EN 10025-2 Mill Certificate
                          referenceNumber: MC-2026-00417
                          issuer: TUV Rheinland
                    scrapMetalContentRatio: 62.5
                    tensileStrengthClass: S355
                    carbonEmissionIntensityPerTon: 1.42
                    gtin: "09501101530003"
                  category: iron-steel
                  originCountry: DE
                  materialComposition:
                    - material: Recycled steel
                      percentage: 62.5
                    - material: Virgin steel
                      percentage: 37.5
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                  regulatoryCompliance:
                    ceMarking: true
                    certificates:
                      - name: EN 10025-2 Mill Certificate
                        referenceNumber: MC-2026-00417
                        issuer: TUV Rheinland
                  scrapMetalContentRatio: 62.5
                  tensileStrengthClass: S355
                  carbonEmissionIntensityPerTon: 1.42
                  gtin: "09501101530003"
                warnings: []
        "400":
          description: "Three variants share this status: (1) **Validation Failed** — the
            metadata failed ESPR category / cross-field validation; carries
            per-field `errors[]` (and `warnings[]` only when at least one
            warning exists). (2) **Bad Request** triple — whitespace-only
            `productId`, a malformed GTIN-14 `productId` (14 digits failing the
            GS1 mod-10 check), no economic operator bound to the workspace, or
            unknown `facilityId`; `{success, error, message}` with no
            `errors`/`warnings`. (3) Pre-handler rejections — request-body
            schema violations (e.g. missing `productId`) and malformed JSON
            return only `{error, message}`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportCreateBadRequest"
              examples:
                validationFailed:
                  summary: ESPR category validation failure (no warnings → key omitted)
                  value:
                    success: false
                    error: Validation Failed
                    message: Dynamic metadata payload failed ESPR category schema validation
                    errors:
                      - path: tensileStrengthClass
                        message: tensileStrengthClass must be a non-empty string
                        friendlyMessage: "Tensile Strength Class: a non-empty string"
                badRequest:
                  summary: No economic operator bound to the workspace
                  value:
                    success: false
                    error: Bad Request
                    message: No economic operator is bound to this workspace. Register your operator
                      first via POST /api/v1/operators (or pass operatorId) —
                      every passport must be attributed to a real economic
                      operator.
                schemaRejected:
                  summary: Request-body schema rejection (only error + message returned)
                  value:
                    error: Bad Request
                    message: body must have required property 'productId'
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: A passport already exists for this `(productId, operatorId)` pair.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Conflict
                message: "A Product Passport already exists for productId: 09501101530003"
        "413":
          description: Body exceeds the 1 MiB (1,048,576-byte) body limit.
          content:
            application/json:
              schema:
                type: object
                required:
                  - statusCode
                  - error
                  - message
                properties:
                  statusCode:
                    type: integer
                    const: 413
                  code:
                    type: string
                    const: FST_ERR_CTP_BODY_TOO_LARGE
                  error:
                    type: string
                    const: Payload Too Large
                  message:
                    type: string
              example:
                statusCode: 413
                code: FST_ERR_CTP_BODY_TOO_LARGE
                error: Payload Too Large
                message: Request body is too large
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
    get:
      operationId: listPassports
      tags:
        - Passports
      summary: List passports in your workspace (paginated JSON-LD)
      description: >-
        Returns the **non-archived** passports of every economic operator bound
        to your workspace, newest first (`createdAt DESC`). Operator-scoped API
        keys only see passports of their bound operator.


        **Permission:** `passport:read` (read-only — no subscription/402 gate).


        **Filtering:** `category` and `originCountry` are exact-match filters on
        the top-level `metadata` keys of the same name. Known
        `metadata.category` values: `textiles`, `batteries`, `electronics`,
        `cosmetics`, `toys`, `iron-steel`, `aluminium`, `chemicals`,
        `construction`; `originCountry` is ISO 3166-1 alpha-2.


        **Pagination:** `page` (default 1) and `limit` (default 10) are numeric
        strings matching `^[0-9]+$` — any other value is rejected with the
        framework's default 400 validation body (see 400). Parsed values are
        clamped server-side to `page >= 1` and `1 <= limit <= 100`. There is
        **no `total` count**; page until you receive fewer than `limit` items.


        **Serialization caveats:**

        - The redaction tier of each item depends on the credential's **role**:
        only `BRAND_OPERATOR` credentials receive the unredacted owner-tier
        document. Every other role — including `TENANT_ADMIN` — receives the
        public tier: `facilityDetails` (and, for `batteries`,
        `detailedPerformance` / `lifecycleAndInUse` /
        `circularityAndDisassembly`) are masked to the literal string
        `"[REDACTED - Privileged Access Required]"`.

        - `economicOperator.role` is **absent** from list items and
        `manufacturingFacility` is always `null` here — fetch a single passport
        (`GET /api/v1/passports/{id}`) for the facility node and operator role.

        - The response passes through a declared response schema: top-level keys
        other than `success`, `page`, `limit`, `passports` are stripped.
        Passport items allow additional properties, so undeclared item keys
        (`status`, `archivedAt`, `retentionUntil`, `manufacturingFacility`, the
        flattened metadata keys) pass through intact — but two **declared** item
        keys are mangled by their subschemas: the `@context` term-map object
        (second array element) is always emptied to `{}`, and `proof` is emptied
        to `{}` on sealed items (`null` on unsealed) — `signatureValue`,
        `merkleRoot`, `redactedLeaves`, `x5c` and `rfc3161` are all stripped
        from list output. Fetch a single passport (`GET /api/v1/passports/{id}`)
        or the public resolver for the verifiable proof block.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/LimitParam"
        - name: category
          in: query
          required: false
          description: "Exact-match filter on `metadata.category`. Known values: textiles,
            batteries, electronics, cosmetics, toys, iron-steel, aluminium,
            chemicals, construction."
          schema:
            type: string
        - name: originCountry
          in: query
          required: false
          description: Exact-match filter on `metadata.originCountry` (ISO 3166-1 alpha-2,
            e.g. `PT`).
          schema:
            type: string
      responses:
        "200":
          description: "Paginated list of JSON-LD passport documents. No total count is
            returned. Note the list-specific mangling: the `@context` term map
            is emptied to `{}` and `proof` contents are stripped (see operation
            description)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportListResponse"
              example:
                success: true
                page: 1
                limit: 10
                passports:
                  - "@context":
                      - https://opendpp-node.eu/contexts/dpp/v1
                      - {}
                    "@type": DigitalProductPassport
                    "@id": https://opendpp-node.eu/01/09501101530003
                    id: 9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d
                    productId: "09501101530003"
                    digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                    digitalSeal: null
                    signingPublicKey: null
                    status: ACTIVE
                    archivedAt: null
                    retentionUntil: null
                    proof: null
                    createdAt: 2026-06-12T09:41:00.000Z
                    updatedAt: 2026-06-12T09:41:00.000Z
                    economicOperator:
                      "@type": EconomicOperator
                      id: f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f
                      name: Aurora Textiles GmbH
                      regId: EU-DEFAULT-001
                    manufacturingFacility: null
                    metadata:
                      category: textiles
                      originCountry: PT
                      size: M
                      facilityDetails: "[REDACTED - Privileged Access Required]"
                    category: textiles
                    originCountry: PT
                    size: M
                    facilityDetails: "[REDACTED - Privileged Access Required]"
        "400":
          description: "Route validation failure (framework default body — note
            `statusCode`/`code` keys, no `success` field): `page` or `limit` did
            not match `^[0-9]+$`."
          content:
            application/json:
              schema:
                type: object
                required:
                  - statusCode
                  - error
                  - message
                properties:
                  statusCode:
                    type: integer
                  code:
                    type: string
                  error:
                    type: string
                  message:
                    type: string
              example:
                statusCode: 400
                code: FST_ERR_VALIDATION
                error: Bad Request
                message: querystring/page must match pattern "^[0-9]+$"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
          description: "Insufficient permission (`passport:read` required) or cross-tenant
            access: the credential does not belong to the tenant-subdomain
            workspace being addressed."
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
          description: Query failure. Body is the standard `{success:false,
            error:"Internal Server Error", message}` envelope with the message
            "Failed to search passports". The underlying error is logged
            server-side and never returned.
  /api/v1/passports/validate-only:
    post:
      operationId: validatePassport
      tags:
        - Passports
      summary: Dry-run ESPR validation of passport metadata (nothing is stored)
      description: >-
        Runs the full ESPR category schema validation on a metadata payload
        **without persisting anything** — intended for pre-flight checks in
        integration pipelines.


        **Permission:** `passport:create` (Bearer API key or session JWT + CSRF
        for cookie sessions). Despite being read-only in effect, it is gated as
        a write permission, so subscription gating (**402**) applies.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`. **Body limit: 262,144 bytes (256 KiB)** →
        **413** beyond that.


        **Behavioral caveats:**

        - `operatorId` is accepted by the body schema but **ignored** by the
        handler.

        - The 200 body always carries `errors: []`; `warnings` is **omitted
        entirely** when there are none (it is not an empty array). The same
        omission applies to `warnings` on the 400 Validation Failed body.

        - `friendlyMessage` localization via `?lang=` / `Accept-Language` (28
        languages, default `en`); category-validity errors (`metadata.category`
        missing or unknown) carry no `friendlyMessage`.

        - Structural rejections of the request body (e.g. missing `productId`,
        non-object `metadata`) and malformed JSON return just `{"error": "Bad
        Request", "message": …}`; the structurally bad inputs that reach the
        handler are a whitespace-only `productId` and a malformed GTIN-14
        `productId` (14 digits failing the GS1 mod-10 check), each answered with
        the fuller `Bad Request` body shown below.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: lang
          in: query
          required: false
          schema:
            type: string
          description: Locale for localized `friendlyMessage` validation texts (en, bg,
            hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv, lt, mt, pl, pt,
            ro, sk, sl, es, sv, no, is, uk, tr). Unknown values are ignored;
            falls back to `Accept-Language`, then `en`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PassportValidateOnlyRequest"
            example:
              productId: "09501101530003"
              metadata:
                category: iron-steel
                originCountry: DE
                materialComposition:
                  - material: Recycled steel
                    percentage: 62.5
                  - material: Virgin steel
                    percentage: 37.5
                facilityDetails:
                  - facilityName: Musterstahl Works Duisburg
                    location: Duisburg, DE
                    activity: Hot rolling
                regulatoryCompliance:
                  ceMarking: true
                  certificates:
                    - name: EN 10025-2 Mill Certificate
                      referenceNumber: MC-2026-00417
                      issuer: TUV Rheinland
                scrapMetalContentRatio: 62.5
                tensileStrengthClass: S355
                carbonEmissionIntensityPerTon: 1.42
      responses:
        "200":
          description: Metadata is valid for its ESPR category. `errors` is always an
            empty array; `warnings` (non-blocking findings) is present only when
            there is at least one warning.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportValidateOnlyResult"
              example:
                success: true
                message: Passport metadata payload is valid against the ESPR category data
                  schema
                category: iron-steel
                errors: []
        "400":
          description: 'Validation failed, or the request body was structurally invalid.
            Three variants share this status: (1) ESPR validation failure
            (`error: "Validation Failed"`, with `errors[]` and — only when at
            least one exists — `warnings[]`); (2) whitespace-only `productId` OR
            a malformed GTIN-14 `productId` (14 digits failing the GS1 mod-10
            check) (`error: "Bad Request"`, `category: "unknown"`, `errors:
            []`); (3) request-body schema rejections and malformed JSON,
            returned as just `{error, message}`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportValidateOnlyError"
              examples:
                validationFailed:
                  summary: ESPR category validation failure
                  value:
                    success: false
                    error: Validation Failed
                    message: Dynamic metadata payload failed ESPR category schema validation
                    category: iron-steel
                    errors:
                      - path: tensileStrengthClass
                        message: tensileStrengthClass must be a non-empty string
                        friendlyMessage: "Tensile Strength Class: a non-empty string"
                badRequest:
                  summary: Whitespace-only productId
                  value:
                    success: false
                    error: Bad Request
                    message: productId must be a non-empty string
                    category: unknown
                    errors: []
                schemaRejected:
                  summary: Request-body schema rejection (only error + message returned)
                  value:
                    error: Bad Request
                    message: body must have required property 'productId'
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          description: Body exceeds the 262,144-byte (256 KiB) route body limit.
          content:
            application/json:
              schema:
                type: object
                required:
                  - statusCode
                  - error
                  - message
                properties:
                  statusCode:
                    type: integer
                    const: 413
                  code:
                    type: string
                    const: FST_ERR_CTP_BODY_TOO_LARGE
                  error:
                    type: string
                    const: Payload Too Large
                  message:
                    type: string
              example:
                statusCode: 413
                code: FST_ERR_CTP_BODY_TOO_LARGE
                error: Payload Too Large
                message: Request body is too large
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/passports/validate-only-public:
    post:
      operationId: validatePassportPublic
      tags:
        - Passports
      summary: Permission-free dry-run ESPR metadata validation (strictly rate-limited)
      description: >-
        Identical validation semantics to `POST
        /api/v1/passports/validate-only`, but requires **no specific
        permission** — any valid API key or Console session is accepted, so
        every plan including the free tier can call it. Nothing is persisted.


        **Authentication is required.** Until contract 1.12.0 this endpoint was
        reachable anonymously; it is not any more, because it runs the full
        validation engine and was usable as free unauthenticated compute. An
        anonymous call now returns **401**. The path keeps its `-public` segment
        for continuity — "public" here means *no permission and no tenant
        scope*, not *unauthenticated*.


        **Rate limit: 10 requests/min per IP** — a strict per-route limit that
        **replaces** the global ceiling for this endpoint (emits
        `x-ratelimit-limit` / `x-ratelimit-remaining` / `x-ratelimit-reset`
        headers and `retry-after` on 429). **Body limit: 65,536 bytes (64 KiB)**
        → **413** beyond that. Both caps remain as defence in depth against
        authenticated abuse. The credential is checked **before the body is
        parsed**, so an anonymous oversized body is rejected as **401**, not
        413.


        **Behavioral caveats:**

        - No tenant context: `operatorId` is accepted but ignored.

        - The 200 body always carries `errors: []`; `warnings` is omitted
        entirely when there are none (same omission on the 400 Validation Failed
        body).

        - Error/warning `friendlyMessage` localization via `?lang=` /
        `Accept-Language` (28 languages, default `en`); category-validity errors
        carry no `friendlyMessage`.

        - Structural rejections of the request body (e.g. missing `productId`)
        and malformed JSON return just `{"error": "Bad Request", "message": …}`;
        a whitespace-only `productId` or a malformed GTIN-14 `productId` (14
        digits failing the GS1 mod-10 check) gets the fuller `Bad Request` body
        shown below.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: lang
          in: query
          required: false
          schema:
            type: string
          description: Locale for localized `friendlyMessage` validation texts (en, bg,
            hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv, lt, mt, pl, pt,
            ro, sk, sl, es, sv, no, is, uk, tr). Unknown values are ignored;
            falls back to `Accept-Language`, then `en`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PassportValidateOnlyRequest"
            example:
              productId: "09501101530003"
              metadata:
                category: iron-steel
                originCountry: DE
                materialComposition:
                  - material: Recycled steel
                    percentage: 62.5
                  - material: Virgin steel
                    percentage: 37.5
                facilityDetails:
                  - facilityName: Musterstahl Works Duisburg
                    location: Duisburg, DE
                    activity: Hot rolling
                regulatoryCompliance:
                  ceMarking: true
                  certificates:
                    - name: EN 10025-2 Mill Certificate
                      referenceNumber: MC-2026-00417
                      issuer: TUV Rheinland
                scrapMetalContentRatio: 62.5
                tensileStrengthClass: S355
                carbonEmissionIntensityPerTon: 1.42
      responses:
        "200":
          description: Metadata is valid for its ESPR category. `errors` is always an
            empty array; `warnings` is present only when there is at least one
            non-blocking warning.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportValidateOnlyResult"
              example:
                success: true
                message: Passport metadata payload is valid against the ESPR category data
                  schema
                category: iron-steel
                errors: []
        "400":
          description: Validation failed or the body was structurally invalid — same three
            variants as the authenticated `validate-only` endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportValidateOnlyError"
              examples:
                validationFailed:
                  summary: ESPR category validation failure
                  value:
                    success: false
                    error: Validation Failed
                    message: Dynamic metadata payload failed ESPR category schema validation
                    category: textiles
                    errors:
                      - path: fiberComposition
                        message: fiberComposition must be an array for textiles
                        friendlyMessage: "Fiber Composition: an array for textiles"
                badRequest:
                  summary: Whitespace-only productId
                  value:
                    success: false
                    error: Bad Request
                    message: productId must be a non-empty string
                    category: unknown
                    errors: []
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          description: Body exceeds the 65,536-byte (64 KiB) route body limit. Only
            reachable once authenticated — an anonymous oversized body is
            rejected as 401 before the body is read.
          content:
            application/json:
              schema:
                type: object
                required:
                  - statusCode
                  - error
                  - message
                properties:
                  statusCode:
                    type: integer
                    const: 413
                  code:
                    type: string
                    const: FST_ERR_CTP_BODY_TOO_LARGE
                  error:
                    type: string
                    const: Payload Too Large
                  message:
                    type: string
              example:
                statusCode: 413
                code: FST_ERR_CTP_BODY_TOO_LARGE
                error: Payload Too Large
                message: Request body is too large
        "429":
          description: Rate limit exceeded. This endpoint is capped at **10 requests/min
            per IP** (a per-route limit that replaces the global ceiling).
            Inspect the `x-ratelimit-*` headers and retry after the indicated
            window.
          headers:
            x-ratelimit-limit:
              description: Request ceiling for the current window.
              schema:
                type: integer
            x-ratelimit-remaining:
              description: Requests remaining in the current window.
              schema:
                type: integer
            x-ratelimit-reset:
              description: Seconds until the window resets.
              schema:
                type: integer
            retry-after:
              description: Seconds to wait before retrying.
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                description: Default rate-limit error body.
                required:
                  - statusCode
                  - error
                  - message
                properties:
                  statusCode:
                    type: integer
                  code:
                    type: string
                  error:
                    type: string
                  message:
                    type: string
              example:
                statusCode: 429
                code: FST_ERR_RATE_LIMIT
                error: Too Many Requests
                message: Rate limit exceeded, retry in 1 minute
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/passports/bulk:
    post:
      operationId: bulkIngestPassports
      tags:
        - Passports
      summary: Bulk-ingest up to 200 passports with per-row error reporting
      description: >-
        Ingests up to **200** passports in one request with **partial-success
        semantics**: each row is validated and inserted independently; failed
        rows are skipped and reported as human-readable strings in `errors[]`.
        Returns **201** as long as at least one row was inserted (even with row
        errors); returns **400 Bulk Ingestion Failed** only when **every** row
        failed.


        **Permission:** `passport:create` (Bearer API key or session JWT + CSRF
        for cookie sessions; subscription gating → **402**).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`. **Body limit: 1 MiB (1,048,576 bytes)** →
        **413** beyond that; in practice the `maxItems: 200` envelope cap is the
        effective bound for typical rows. Envelope violations — empty array,
        more than 200 items, missing `passports` — are rejected before any row
        is processed, with the full default validation error body (`{statusCode,
        code, error, message}`).


        **Per-row behavior (differences from `POST /api/v1/passports`):**

        - No `draft` support: every inserted row is created with `status:
        "ACTIVE"`. No `enrichment` support.

        - A valid GTIN-14/GRAI `productId` is **not** auto-copied into
        `metadata.gtin`/`metadata.grai` (unlike single ingestion).

        - Operator resolution per row: explicit `operatorId` must be bound to
        your workspace; otherwise the workspace's first bound operator is used;
        operator-scoped API keys force their operator. Lookups are cached within
        the request.

        - Duplicate `(productId, operatorId)` rows, unknown facilities, and
        per-row DB failures become `errors[]` strings (prefixed `[SKU:
        <productId>]`), never a request-level failure.

        - Each successfully inserted row **transactionally enqueues a
        `passport.ingested` webhook event** (public redacted JSON-LD payload).

        - Row validation messages use the localized `friendlyMessage` where the
        engine provides one (`?lang=` / `Accept-Language`); category-validity
        errors fall back to the technical `path: message` form.


        Note the 400 `Bulk Ingestion Failed` body has **no `message` field**,
        and `errors` is an array of **strings** (not objects).
      security:
        - ApiKeyAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 255
          description: "Optional client idempotency key (≤255 characters, no control
            characters). Retrying a real (non-`dryRun`) import with the same
            `Idempotency-Key` replays the ORIGINAL result — same status and
            body, plus an `idempotent-replayed: true` response header — instead
            of re-inserting the batch. A `dryRun` preview is never captured or
            replayed. Scoped per (workspace, endpoint, key) and consulted within
            a 24-hour window; a malformed key returns **400**. Best-effort: the
            result is recorded after the batch commits, so in the rare window
            between commit and recording (or across an instance restart) a retry
            re-processes the batch normally (the `(productId, operator)` unique
            constraint still prevents duplicate rows)."
        - name: lang
          in: query
          required: false
          schema:
            type: string
          description: Locale for the localized validation text inside per-row `errors[]`
            strings (en, bg, hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv,
            lt, mt, pl, pt, ro, sk, sl, es, sv, no, is, uk, tr). Unknown values
            are ignored; falls back to `Accept-Language`, then `en`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PassportBulkRequest"
            example:
              passports:
                - productId: "09501101530003"
                  metadata:
                    category: iron-steel
                    originCountry: DE
                    materialComposition:
                      - material: Recycled steel
                        percentage: 62.5
                      - material: Virgin steel
                        percentage: 37.5
                    facilityDetails:
                      - facilityName: Musterstahl Works Duisburg
                        location: Duisburg, DE
                        activity: Hot rolling
                    regulatoryCompliance:
                      ceMarking: true
                      certificates:
                        - name: EN 10025-2 Mill Certificate
                          referenceNumber: MC-2026-00417
                          issuer: TUV Rheinland
                    scrapMetalContentRatio: 62.5
                    tensileStrengthClass: S355
                    carbonEmissionIntensityPerTon: 1.42
                - productId: "09501101530010"
                  metadata:
                    category: iron-steel
                    originCountry: DE
                    materialComposition:
                      - material: Recycled steel
                        percentage: 100
                    facilityDetails:
                      - facilityName: Musterstahl Works Duisburg
                        location: Duisburg, DE
                        activity: Cold rolling
                    regulatoryCompliance:
                      ceMarking: true
                      certificates:
                        - name: EN 10025-2 Mill Certificate
                          referenceNumber: MC-2026-00418
                          issuer: TUV Rheinland
                    scrapMetalContentRatio: 100
                    tensileStrengthClass: S275
                    carbonEmissionIntensityPerTon: 0.61
      responses:
        "201":
          description: Bulk run finished with at least one inserted row. `errors` is
            present only when at least one row failed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportBulkResult"
              example:
                success: true
                message: Bulk CSV ingestion finished. Registered 1 passports, skipped 1 rows
                  with errors.
                insertedCount: 1
                results:
                  - productId: "09501101530003"
                    digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                errors:
                  - "[SKU: 09501101530010] Duplicate productId already exists in
                    database for this operator"
        "400":
          description: "Either every row failed (`Bulk Ingestion Failed`, with string
            `errors[]` and no `message` field), or the request never reached row
            processing: envelope violations of the `passports` array bounds and
            malformed JSON both return the full default error body
            (`{statusCode, code?, error, message}` — nothing is stripped on this
            operation)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportBulkBadRequest"
              examples:
                allRowsFailed:
                  summary: Every row was rejected
                  value:
                    success: false
                    error: Bulk Ingestion Failed
                    errors:
                      - "[SKU: 09501101530003] Validation failed: category:
                        Category must be one of: textiles, batteries,
                        electronics, cosmetics, toys, iron-steel, aluminium,
                        chemicals, construction"
                      - Missing or invalid productId in spreadsheet row
                envelopeRejected:
                  summary: Envelope violates the request schema (e.g. more than 200 rows)
                  value:
                    statusCode: 400
                    code: FST_ERR_VALIDATION
                    error: Bad Request
                    message: body/passports must NOT have more than 200 items
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          description: Body exceeds the 1 MiB (1,048,576-byte) body limit.
          content:
            application/json:
              schema:
                type: object
                required:
                  - statusCode
                  - error
                  - message
                properties:
                  statusCode:
                    type: integer
                    const: 413
                  code:
                    type: string
                    const: FST_ERR_CTP_BODY_TOO_LARGE
                  error:
                    type: string
                    const: Payload Too Large
                  message:
                    type: string
              example:
                statusCode: 413
                code: FST_ERR_CTP_BODY_TOO_LARGE
                error: Payload Too Large
                message: Request body is too large
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/passports/aas/ingest:
    post:
      operationId: ingestPassportFromAas
      tags:
        - Passports
      summary: Ingest a passport from an AAS JSON Environment (seal-verified)
      description: >-
        Ingests (creates **or updates**) a Digital Product Passport from an
        Industry-4.0 **Asset Administration Shell (AAS) JSON Environment** — the
        same format produced by OpenDPP's own AAS export.


        **Permission:** `passport:create` (Bearer API key or session JWT + CSRF
        for cookie sessions; subscription gating → **402**).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`. **Body limit: 262,144 bytes (256 KiB)** →
        **413**.


        **Parsing.** The environment must contain a `submodels` array including
        a submodel with `idShort: "ComplianceMetadata"`, whose
        `submodelElements` are parsed back into the metadata object; missing it
        fails 400 (`Ingestion Failed`). `productId` is resolved from
        `metadata.gtin` || `metadata.grai` || `metadata.productId` || the first
        shell's `assetInformation.specificAssetIds` entry named `productId` —
        unresolvable → 400 `Bad Request`. The parsed metadata then passes the
        full ESPR category validation (400 `Validation Failed` with `errors[]`).


        **seal verification.** If the environment embeds an
        `eidasVerificationSeal` submodel (`digitalSealHash` /
        `cryptographicSignature` / `pemPublicKey` elements), the seal is
        verified against **your tenant's server-held signing public key** —
        never the key embedded in the request (self-signing is rejected by
        design). An embedded seal that fails verification → **400 `Signature
        Verification Failed`**; this includes the case where your workspace
        holds no matching key. `isSealed`/`signatureVerified` in the 201 echo
        the outcome (both `false` for unsealed documents).


        **Upsert semantics.** If a passport already exists for the resolved
        `(productId, operator)` pair: a **sealed** existing passport refuses
        re-ingestion (**403** — re-seal explicitly after changes); an unsealed
        one has its metadata, Merkle tree and seal fields **replaced**, still
        answering **201**. Operator resolution: operator-scoped API keys use
        their own operator and **403** when that operator is not bound to your
        workspace; otherwise the workspace's first bound operator is used; none
        bound → 400.


        **Caveats:**

        - **NO webhook is emitted** — unlike `POST /api/v1/passports` and
        `/bulk`, AAS ingestion never enqueues `passport.ingested` (or any other
        event).

        - The catch-all error path returns **400 `Ingestion Failed`** with the
        underlying parse/processing message — even for internal failures (this
        handler does not emit its own 500).

        - Validation `friendlyMessage` localization via `?lang=` /
        `Accept-Language`; category-validity errors carry no `friendlyMessage`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: lang
          in: query
          required: false
          schema:
            type: string
          description: Locale for localized `friendlyMessage` validation texts (en, bg,
            hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv, lt, mt, pl, pt,
            ro, sk, sl, es, sv, no, is, uk, tr). Unknown values are ignored;
            falls back to `Accept-Language`, then `en`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AasEnvironmentInput"
            examples:
              abbreviated:
                summary: Structurally illustrative (abbreviated) environment
                description: "Shows the envelope structure only. A real `ComplianceMetadata`
                  submodel must carry the FULL field set its ESPR category
                  requires (for `iron-steel`: `materialComposition`,
                  `facilityDetails`, `regulatoryCompliance`,
                  `scrapMetalContentRatio`, `tensileStrengthClass`,
                  `carbonEmissionIntensityPerTon` in addition to
                  `category`/`originCountry`) — this abbreviated example would
                  fail ingestion with 400 `Validation Failed`. The reliable
                  source of a valid environment is OpenDPP's own AAS export of
                  an existing passport."
                value:
                  assetAdministrationShells:
                    - id: urn:opendpp:aas:9b2fa884-1c7d-4a0e-9d3b-5f6a7c8e9012
                      idShort: AAS_09501101530003
                      assetInformation:
                        assetKind: Instance
                        specificAssetIds:
                          - name: productId
                            value: "09501101530003"
                      submodels: []
                  submodels:
                    - id: urn:opendpp:submodel:compliance:09501101530003
                      idShort: ComplianceMetadata
                      submodelElements:
                        - idShort: category
                          modelType: Property
                          valueType: xs:string
                          value: iron-steel
                        - idShort: originCountry
                          modelType: Property
                          valueType: xs:string
                          value: DE
                        - idShort: gtin
                          modelType: Property
                          valueType: xs:string
                          value: "09501101530003"
      responses:
        "201":
          description: Passport created or (if it existed unsealed) updated from the AAS
            environment. Returned for both create and update. No webhook event
            is emitted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AasIngestCreated"
              example:
                success: true
                message: Digital Product Passport successfully ingested from AAS
                passportId: 9b2fa884-1c7d-4a0e-9d3b-5f6a7c8e9012
                productId: "09501101530003"
                isSealed: false
                signatureVerified: false
        "400":
          description: "Four variants share this status: `Bad Request` (non-object body,
            unresolvable productId, no bound operator), `Signature Verification
            Failed` (embedded seal invalid/altered or no matching tenant key),
            `Validation Failed` (ESPR — carries `errors[]`, and `warnings[]`
            when present), and `Ingestion Failed` (catch-all parse/processing
            error with the underlying message)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AasIngestBadRequest"
              examples:
                badRequest:
                  summary: productId unresolvable from the AAS metadata
                  value:
                    success: false
                    error: Bad Request
                    message: Could not resolve productId (GTIN/GRAI) from AAS metadata.
                signatureVerificationFailed:
                  summary: Embedded seal failed verification
                  value:
                    success: false
                    error: Signature Verification Failed
                    message: eIDAS Electronic Seal signature is invalid or altered.
                validationFailed:
                  summary: Parsed metadata failed ESPR validation
                  value:
                    success: false
                    error: Validation Failed
                    message: Dynamic metadata parsed from AAS failed compliance validation
                    errors:
                      - path: materialComposition
                        message: materialComposition must be an array
                        friendlyMessage: materialComposition must be a valid array of material
                          percentage shares
                ingestionFailed:
                  summary: Catch-all parse/processing failure
                  value:
                    success: false
                    error: Ingestion Failed
                    message: Missing 'ComplianceMetadata' submodel in AAS Environment.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          description: Body exceeds the 262,144-byte (256 KiB) route body limit.
          content:
            application/json:
              schema:
                type: object
                required:
                  - statusCode
                  - error
                  - message
                properties:
                  statusCode:
                    type: integer
                    const: 413
                  code:
                    type: string
                    const: FST_ERR_CTP_BODY_TOO_LARGE
                  error:
                    type: string
                    const: Payload Too Large
                  message:
                    type: string
              example:
                statusCode: 413
                code: FST_ERR_CTP_BODY_TOO_LARGE
                error: Payload Too Large
                message: Request body is too large
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/passports/vc-readiness:
    get:
      operationId: passportVcReadinessReport
      tags:
        - Passports
      summary: Catalog-wide UNTP Verifiable-Credential readiness report
      description: >-
        A read-only, tenant-scoped report of which SKUs in your catalog can /
        can't emit a UNTP **Verifiable Credential**, and why — so you can fix a
        whole catalog before relying on VCs, instead of probing passports one at
        a time. It **aggregates the same per-passport `vcReady` signal**
        returned on every ingest response: a passport is VC-ready only when it
        links a manufacturing `Facility` with a country of production.


        **Permission:** `passport:read` (read-only — no subscription/402 gate).
        Operator scoping + the non-archived filter match the passports list.


        **Shape:** each `results[]` row is `{ id, productId, vcReady, blockers[]
        }` — `blockers[]` reuses the SAME actionable reason the single-passport
        signal exposes (empty when ready). The top-level `ready` / `notReady`
        rollup is **catalog-wide** (counts every non-archived passport), while
        `results` is **paginated** — `page` (default 1) + `limit` (default 100,
        max 200), with `total` / `totalPages`. NOTE: because the rollup is
        catalog-wide but `results` is one page, `ready` is generally NOT the
        count of `vcReady:true` rows on the current page — page through all
        `totalPages` to enumerate every SKU.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: The paginated VC-readiness report with a catalog-wide
            ready/notReady rollup.
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - page
                  - limit
                  - total
                  - totalPages
                  - ready
                  - notReady
                  - results
                properties:
                  success:
                    type: boolean
                  page:
                    type: integer
                  limit:
                    type: integer
                  total:
                    type: integer
                    description: Total non-archived passports in the catalog (the rollup base, not
                      the page size).
                  totalPages:
                    type: integer
                  ready:
                    type: integer
                    description: Catalog-wide count of VC-ready SKUs (a facility with a country of
                      production).
                  notReady:
                    type: integer
                    description: Catalog-wide count of not-yet-ready SKUs (= `total - ready`).
                  results:
                    type: array
                    items:
                      type: object
                      required:
                        - id
                        - productId
                        - vcReady
                        - blockers
                      properties:
                        id:
                          type: string
                          description: The passport UUID.
                        productId:
                          type: string
                        vcReady:
                          type: boolean
                        blockers:
                          type: array
                          items:
                            type: string
                          description: Actionable reasons this SKU can't emit a VC (same text as the
                            single-passport signal); empty when `vcReady` is
                            true.
              example:
                success: true
                page: 1
                limit: 100
                total: 3
                totalPages: 1
                ready: 2
                notReady: 1
                results:
                  - id: 9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d
                    productId: "09501101530003"
                    vcReady: true
                    blockers: []
                  - id: 1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f
                    productId: SKU-NO-FACILITY
                    vcReady: false
                    blockers:
                      - Link a manufacturing facility with a country of
                        production to enable Verifiable Credential (UNTP)
                        representations. The passport still publishes and
                        resolves as AAS / JSON-LD / HTML.
  /api/v1/passports/{id}:
    get:
      operationId: getPassport
      tags:
        - Passports
      summary: Fetch a single passport (content-negotiated JSON-LD / AAS / HTML)
      description: >-
        Owner-side alias of the public resolver. Accepts either the passport
        **UUID** or its caller-supplied **`productId`** (GTIN-14 / GRAI / SKU),
        scoped to operators bound to your workspace. After the scoped lookup the
        request is **re-dispatched internally to `GET /passport/{uuid}`**,
        forwarding all request headers, and the inner response (status, content
        type, body) is returned as-is.


        **Permission:** `passport:read` (read-only — no subscription/402 gate).


        **Content negotiation** (substring match on `Accept`):
        `application/aas+json` → role-filtered AAS environment;
        `application/vc+jwt` → enveloping UNTP Verifiable Credential;
        `application/vc+ld+json` → the same credential with an embedded
        `ecdsa-jcs-2019` W3C Data Integrity proof; `application/dc+sd-jwt`
        (legacy `vc+sd-jwt` accepted) → SD-JWT-VC selective disclosure (these
        three return `406 Not Acceptable` when the passport has no manufacturing
        facility with a country of production); `text/html` → SSR passport page;
        anything else (including `application/json`, `*/*`, or no header) →
        JSON-LD with `Content-Type: application/ld+json` (the default). The VC
        and SD-JWT representations are forwarded verbatim from `GET
        /passport/{id}` — see that operation for the full credential semantics.


        **Access-tier caveat (privilege is resolved from the *forwarded*
        headers, not the already-authenticated context):** only **database API
        keys** (`Authorization: Bearer op_dpp_token_…`) of the owning or
        operator-bound tenant are recognized as owner by the inner resolver.
        Those callers get the **owner-tier** document: `facilityDetails` and
        battery restricted keys unmasked, `manufacturingFacility` includes
        `streetAddress`/`city`/`postalCode`, and DRAFT passports are visible.
        Callers authenticated with a **JWT session** (login cookie or bearer
        JWT) receive the **public-redacted** tier instead, and DRAFT passports
        answer 404 with the forwarded public body (no `success` field).


        Every successful resolution records an anonymized-IP access audit entry.


        **Rate limits:** your plan's per-key budget applies — **Growth**
        120/min, **Scale** 600/min, **Enterprise** unlimited — with a ceiling of
        3x that rate across all of the workspace's keys, under a per-IP ceiling;
        `x-ratelimit-*` headers and a `Retry-After` on **429**. **Plus** the
        forwarded public resolver's own limiter (30 req/min/IP, no headers) —
        both 429 shapes are possible (see 429).
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Passport UUID **or** caller-supplied `productId` (GTIN-14 / GRAI /
            SKU). UUID is tried first, then `productId`.
          schema:
            type: string
            minLength: 1
      responses:
        "200":
          description: The resolved passport. Representation depends on the `Accept`
            header; redaction tier depends on the forwarded credential (see
            description). The `@id`/`digitalLinkUri` is the stored GS1 Digital
            Link URI `{base}/01/{productId}/21/{passportUuid}` (AI `8003`
            instead of `01` for GRAI ids; AI-21 carries the passport UUID at SKU
            level).
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/PublicPassportJsonLd"
              example:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                    economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                    manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                    metadata: https://opendpp-node.eu/ns/dpp#metadata
                    digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                    signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                    status: https://opendpp-node.eu/ns/dpp#status
                    archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                    retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                    category: https://opendpp-node.eu/contexts/dpp/v1#category
                    originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                    size: https://opendpp-node.eu/contexts/dpp/v1#size
                    facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                "@type": DigitalProductPassport
                "@id": https://opendpp-node.eu/01/09501101530003
                id: 9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d
                productId: "09501101530003"
                digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                digitalSeal: null
                signingPublicKey: null
                status: ACTIVE
                archivedAt: null
                retentionUntil: null
                proof: null
                createdAt: 2026-06-12T09:41:00.000Z
                updatedAt: 2026-06-12T09:41:00.000Z
                economicOperator:
                  "@type": EconomicOperator
                  id: f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f
                  name: Aurora Textiles GmbH
                  regId: EU-DEFAULT-001
                  role: Manufacturer
                manufacturingFacility:
                  "@type": Facility
                  id: 5b21cf12-7d24-4a8e-9a3c-2f1f4f4f9d10
                  gln: "0950110153000"
                  name: Aurora Spinning Mill
                  activity: Spinning
                  country: PT
                  streetAddress: Rua das Flores 12
                  city: Porto
                  postalCode: 4050-262
                metadata:
                  category: textiles
                  originCountry: PT
                  size: M
                  facilityDetails:
                    - facilityName: Aurora Spinning Mill
                      location: Porto, PT
                      activity: Spinning
                category: textiles
                originCountry: PT
                size: M
                facilityDetails:
                  - facilityName: Aurora Spinning Mill
                    location: Porto, PT
                    activity: Spinning
            application/aas+json:
              schema:
                $ref: "#/components/schemas/PassportAasEnvironment"
              example:
                assetAdministrationShells:
                  - id: urn:opendpp:aas:9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d
                    idShort: AAS_09501101530003
                    assetInformation:
                      assetKind: Instance
                      globalAssetId: urn:opendpp:asset:f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f:09501101530003
                      specificAssetIds:
                        - name: productId
                          value: "09501101530003"
                          externalSubjectId:
                            type: ExternalReference
                            keys:
                              - type: GlobalReference
                                value: urn:gs1:gln:0950110153000
                        - name: manufacturingFacilityGln
                          value: "0950110153000"
                    submodels:
                      - type: ModelReference
                        keys:
                          - type: Submodel
                            value: urn:opendpp:submodel:general:9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d
                      - type: ModelReference
                        keys:
                          - type: Submodel
                            value: urn:opendpp:submodel:compliance
                submodels:
                  - id: urn:opendpp:submodel:general:9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d
                    idShort: GeneralProductInformation
                    semanticId:
                      keys:
                        - type: Submodel
                          value: urn:opendpp:submodel-spec:general:1.0.0
                    submodelElements:
                      - idShort: passportId
                        modelType: Property
                        valueType: xs:string
                        value: 9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d
                      - idShort: createdAt
                        modelType: Property
                        valueType: xs:dateTime
                        value: 2026-06-12T09:41:00.000Z
                      - idShort: manufacturingFacilityGln
                        modelType: Property
                        valueType: xs:string
                        value: "0950110153000"
                      - idShort: manufacturingFacilityName
                        modelType: Property
                        valueType: xs:string
                        value: Aurora Spinning Mill
                      - idShort: manufacturingFacilityCountry
                        modelType: Property
                        valueType: xs:string
                        value: PT
                  - id: urn:opendpp:submodel:compliance
                    idShort: ComplianceMetadata
                    submodelElements:
                      - idShort: category
                        modelType: Property
                        valueType: xs:string
                        value: textiles
                      - idShort: originCountry
                        modelType: Property
                        valueType: xs:string
                        value: PT
                      - idShort: size
                        modelType: Property
                        valueType: xs:string
                        value: M
                conceptDescriptions: []
            text/html:
              schema:
                type: string
                description: Server-rendered passport page (returned when `Accept` contains
                  `text/html`).
            application/vc+jwt:
              schema:
                type: string
                description: "Forwarded enveloping UNTP DigitalProductPassport credential
                  (compact JWS, ES256), identical to `GET /passport/{id}` with
                  `Accept: application/vc+jwt`."
            application/vc+ld+json:
              schema:
                type: object
                description: "Forwarded UNTP DigitalProductPassport credential with an embedded
                  W3C Data Integrity proof (`ecdsa-jcs-2019`), identical to `GET
                  /passport/{id}` with `Accept: application/vc+ld+json`."
            application/dc+sd-jwt:
              schema:
                type: string
                description: "Forwarded SD-JWT-VC (selective disclosure) of the same credential,
                  identical to `GET /passport/{id}` with `Accept:
                  application/dc+sd-jwt` (legacy `vc+sd-jwt` accepted)."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
          description: "Insufficient permission (`passport:read`), cross-tenant subdomain
            mismatch, or an operator-scoped API key addressing another
            operator's passport (message: `Your access is restricted to Economic
            Operator: <operatorId>`)."
        "404":
          description: 'Two distinct bodies. (1) Standard: the id/productId matched
            nothing under your workspace — `{success:false, error:"Not Found",
            message:"Passport with ID or Product ID <id> not found under your
            Tenant workspace"}`. (2) Forwarded from the public resolver (note:
            **no `success` field**): the passport is a DRAFT and the forwarded
            credential was not recognized as owner (e.g. JWT session); with
            `Accept: text/html` this case returns an HTML not-found page
            instead.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportGetNotFound"
              examples:
                notInWorkspace:
                  summary: Not found under your workspace (standard envelope)
                  value:
                    success: false
                    error: Not Found
                    message: Passport with ID or Product ID 09501101530003 not found under your
                      Tenant workspace
                draftNotOwnerForwarded:
                  summary: DRAFT passport, credential not recognized by the inner resolver
                    (forwarded body)
                  value:
                    error: Not Found
                    message: "No Digital Product Passport found matching identifier:
                      9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d"
            text/html:
              schema:
                type: string
                description: "Forwarded SSR not-found page (DRAFT + unrecognized credential +
                  `Accept: text/html`)."
        "406":
          $ref: "#/components/responses/NotAcceptable"
        "429":
          description: "Two possible sources. (1) Global limiter (100/min/IP): default
            rate-limit body (`statusCode`/`error`/`message` — no `code` field)
            with `x-ratelimit-limit`/`x-ratelimit-remaining`/`x-ratelimit-reset`
            + `retry-after` headers. (2) Forwarded from the inner public
            resolver's limiter (30/min/IP): two-field body, **no rate-limit
            headers**."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportGetTooManyRequests"
              examples:
                globalLimiter:
                  summary: Global plugin limiter
                  value:
                    statusCode: 429
                    error: Too Many Requests
                    message: Rate limit exceeded, retry in 1 minute
                forwardedPublicLimiter:
                  summary: Forwarded public-resolver limiter
                  value:
                    error: Too Many Requests
                    message: Rate limit exceeded. Public passport resolutions are limited to 30
                      requests per minute.
        "500":
          description: "Unexpected failure. A failure inside the scoped lookup is not
            wrapped by the route and returns the framework's **default** error
            body (`statusCode`/`error`/`message`, no `success` field); an
            authentication-layer failure returns the standard envelope with
            `message: \"Authentication verification failed\"`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                authVerificationFailed:
                  summary: Authentication-layer failure (standard envelope)
                  value:
                    success: false
                    error: Internal Server Error
                    message: Authentication verification failed
    put:
      operationId: updatePassport
      tags:
        - Passports
      summary: Update passport metadata (versioned to history)
      description: >-
        Replaces the passport's `metadata` (the Merkle root and leaf hashes are
        recomputed) and snapshots the **previous** metadata into the passport's
        version history (version = count + 1, `changedBy` = user email or
        `api-key:<id>`, `changeReason` defaults to `"API Update"`).


        **Permission:** `passport:update` (write — subscription gating applies,
        see 402). Cookie sessions must send `X-CSRF-Token` (double-submit);
        Bearer/API-key clients are exempt.


        **Lookup:** by passport **UUID only** — `productId` aliasing is NOT
        supported on this endpoint. The passport must belong to an operator
        bound to your workspace.


        **Draft semantics (`draft` flag):**

        - `draft: true` **skips ESPR validation entirely** and forces `status:
        "DRAFT"` — note this also demotes an already-published
        (ACTIVE/RECALLED/DECOMMISSIONED) passport back to DRAFT.

        - `draft` absent/false: `metadata` is validated against the ESPR
        category rules (400 on failure — see below). If the passport was a DRAFT
        it is **published**: status becomes `ACTIVE`, a `passport.ingested`
        webhook is enqueued transactionally (public-redacted JSON-LD payload)
        and an in-app notification is created best-effort afterwards. Editing an
        already-published (live) passport leaves its status untouched and
        enqueues a `passport.updated` webhook instead (same public-redacted
        JSON-LD payload).


        **Validation divergence:** the 400 validation body here contains
        `errors` but — unlike `POST /api/v1/passports` — **never a `warnings`
        array**. `friendlyMessage` is localized via the `lang` query parameter
        or `Accept-Language` (28 languages, default `en`; unsupported values
        silently fall back).


        **Sealed passports are immutable in place:** if `digitalSeal` is set the
        update is refused with **403** (message: "This passport is sealed and
        cannot be edited in place — editing would invalidate the eIDAS advanced
        electronic seal. Re-seal explicitly after any change.").


        **Facility:** omit `facilityId` to leave it unchanged; pass `null` or
        `""` to detach; pass a facility UUID owned by your tenant to attach (400
        if not found in your workspace).


        **Enrichment:** include the `enrichment` key (even as `null`/`{}`) to
        overwrite the presentational marketing block; omit it to leave it
        unchanged. Values are sanitized server-side (truncated/sliced, http(s)
        URLs only), never rejected.


        **Response caveat:** the returned `passport` document is serialized at
        the **public** redaction tier — `facilityDetails` (and battery
        restricted keys) appear as `"[REDACTED - Privileged Access Required]"`
        even though you are the owner.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Passport UUID. `productId` aliasing is NOT supported here.
          schema:
            type: string
        - name: lang
          in: query
          required: false
          description: Locale for `friendlyMessage` localization in validation errors.
            Falls back to `Accept-Language`, then `en`. Unsupported values are
            ignored (no error).
          schema:
            type: string
            enum:
              - en
              - bg
              - hr
              - cs
              - da
              - nl
              - et
              - fi
              - fr
              - de
              - el
              - hu
              - ga
              - it
              - lv
              - lt
              - mt
              - pl
              - pt
              - ro
              - sk
              - sl
              - es
              - sv
              - no
              - is
              - uk
              - tr
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PassportUpdateRequest"
            example:
              metadata:
                category: textiles
                originCountry: PT
                materialComposition:
                  - material: Organic cotton
                    percentage: 80
                  - material: Recycled polyester
                    percentage: 20
                fiberComposition:
                  - fiber: cotton
                    percentage: 80
                  - fiber: polyester
                    percentage: 20
                careInstructions: Machine wash cold, line dry
                size: M
                facilityDetails:
                  - facilityName: Aurora Spinning Mill
                    location: Porto, PT
                    activity: Spinning
                    eudrPlots:
                      - plotId: PLOT-001
                        polygonType: point
                        coordinates:
                          - lat: 41.1579
                            lng: -8.6291
                    traceabilityDocs:
                      - documentName: GOTS scope certificate
                        documentHash: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
                        documentUrl: https://docs.aurora-textiles.example/gots.pdf
                regulatoryCompliance:
                  ceMarking: true
                  certificates:
                    - name: GOTS
                      referenceNumber: GOTS-2026-0042
                      issuer: Control Union
                      validUntil: 2027-05-31
              changeReason: Updated fiber composition after supplier audit
              facilityId: 5b21cf12-7d24-4a8e-9a3c-2f1f4f4f9d10
      responses:
        "200":
          description: Updated (or published) passport. `message` is `"Draft published"`
            on a first publish, otherwise `"Digital Product Passport
            successfully updated and history versioned"`. The `passport`
            document is public-tier redacted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportUpdateResponse"
              example:
                success: true
                message: Digital Product Passport successfully updated and history versioned
                passport:
                  "@context":
                    - https://opendpp-node.eu/contexts/dpp/v1
                    - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                      economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                      manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                      metadata: https://opendpp-node.eu/ns/dpp#metadata
                      digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                      signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                      status: https://opendpp-node.eu/ns/dpp#status
                      archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                      retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                      category: https://opendpp-node.eu/contexts/dpp/v1#category
                      originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                      materialComposition: https://opendpp-node.eu/contexts/dpp/v1#materialComposition
                      fiberComposition: https://opendpp-node.eu/contexts/dpp/v1#fiberComposition
                      careInstructions: https://opendpp-node.eu/contexts/dpp/v1#careInstructions
                      size: https://opendpp-node.eu/contexts/dpp/v1#size
                      facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                      regulatoryCompliance: https://opendpp-node.eu/contexts/dpp/v1#regulatoryCompliance
                  "@type": DigitalProductPassport
                  "@id": https://opendpp-node.eu/01/09501101530003
                  id: 9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d
                  productId: "09501101530003"
                  digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                  digitalSeal: null
                  signingPublicKey: null
                  status: ACTIVE
                  archivedAt: null
                  retentionUntil: null
                  proof: null
                  createdAt: 2026-06-12T09:41:00.000Z
                  updatedAt: 2026-06-12T10:15:00.000Z
                  economicOperator:
                    "@type": EconomicOperator
                    id: f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f
                    name: Aurora Textiles GmbH
                    regId: EU-DEFAULT-001
                    role: Manufacturer
                  manufacturingFacility:
                    "@type": Facility
                    id: 5b21cf12-7d24-4a8e-9a3c-2f1f4f4f9d10
                    gln: "0950110153000"
                    name: Aurora Spinning Mill
                    activity: Spinning
                    country: PT
                  metadata:
                    category: textiles
                    originCountry: PT
                    materialComposition:
                      - material: Organic cotton
                        percentage: 80
                      - material: Recycled polyester
                        percentage: 20
                    fiberComposition:
                      - fiber: cotton
                        percentage: 80
                      - fiber: polyester
                        percentage: 20
                    careInstructions: Machine wash cold, line dry
                    size: M
                    facilityDetails: "[REDACTED - Privileged Access Required]"
                    regulatoryCompliance:
                      ceMarking: true
                      certificates:
                        - name: GOTS
                          referenceNumber: GOTS-2026-0042
                          issuer: Control Union
                          validUntil: 2027-05-31
                  category: textiles
                  originCountry: PT
                  materialComposition:
                    - material: Organic cotton
                      percentage: 80
                    - material: Recycled polyester
                      percentage: 20
                  fiberComposition:
                    - fiber: cotton
                      percentage: 80
                    - fiber: polyester
                      percentage: 20
                  careInstructions: Machine wash cold, line dry
                  size: M
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                  regulatoryCompliance:
                    ceMarking: true
                    certificates:
                      - name: GOTS
                        referenceNumber: GOTS-2026-0042
                        issuer: Control Union
                        validUntil: 2027-05-31
        "400":
          description: Either a plain Bad Request — body is not a JSON object; `metadata`
            missing/not an object; `facilityId` not found in your workspace
            (`Facility <facilityId> not found in your Tenant workspace`) — or an
            ESPR validation failure. **Divergence:** unlike `POST
            /api/v1/passports`, the validation body has NO `warnings` array.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportUpdateBadRequest"
              examples:
                badRequest:
                  summary: metadata missing or not an object
                  value:
                    success: false
                    error: Bad Request
                    message: metadata payload must be a valid object
                validationFailed:
                  summary: ESPR category validation failed (no warnings key)
                  value:
                    success: false
                    error: Validation Failed
                    message: Dynamic metadata payload failed ESPR category schema validation
                    errors:
                      - path: fiberComposition
                        message: The sum of fiberComposition percentages must equal exactly 100% (got
                          95.00%)
                        friendlyMessage: "Fiber Composition: the listed fiber percentages must add up to
                          100%."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
          description: 'Insufficient permission (`passport:update`), missing/invalid CSRF
            token (cookie sessions), cross-tenant subdomain mismatch,
            operator-scoped key restriction (`Your access is restricted to
            Economic Operator: <operatorId>`), **or the passport is sealed** —
            sealed passports cannot be edited in place (message: "This passport
            is sealed and cannot be edited in place — editing would invalidate
            the eIDAS advanced electronic seal. Re-seal explicitly after any
            change.").'
        "404":
          $ref: "#/components/responses/NotFound"
          description: "No passport with this UUID under an operator bound to your
            workspace. Message: `Passport with ID <id> not found under your
            Tenant workspace`."
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: History snapshot or transactional update failure returns the
            standard envelope with the message "Failed to update passport".
            Unexpected server error. Unhandled errors are normalized by the
            global error handler to the standard envelope with the generic
            message "An unexpected error occurred"; details are logged
            server-side, never returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                standardEnvelope:
                  summary: Update/history failure (standard envelope)
                  value:
                    success: false
                    error: Internal Server Error
                    message: Failed to update passport
    delete:
      operationId: deleteDraftPassport
      tags:
        - Passports
      summary: Permanently delete a DRAFT passport
      description: >-
        Hard-deletes a passport **only while it is a DRAFT** (never published,
        not publicly resolvable, no retention duty). Children (history, access
        logs, battery units) cascade on delete.


        Published passports (ACTIVE/RECALLED/DECOMMISSIONED) are refused with
        **409** — they must be decommissioned/archived through the status
        lifecycle (`PUT /api/v1/passports/{id}/status`) to satisfy the ESPR
        persistence duty.


        **Permission:** `passport:update` (write — subscription gating applies,
        see 402). Cookie sessions must send `X-CSRF-Token`; Bearer/API-key
        clients are exempt.


        **Lookup:** by passport **UUID only** (no `productId` aliasing) and only
        within the passport's **owning tenant** — an operator-binding alone is
        not sufficient, unlike PUT.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Passport UUID. `productId` aliasing is NOT supported here.
          schema:
            type: string
      responses:
        "200":
          description: Draft deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - message
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    const: Draft passport deleted.
              example:
                success: true
                message: Draft passport deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
          description: "Insufficient permission (`passport:update`), missing/invalid CSRF
            token (cookie sessions), cross-tenant subdomain mismatch, or
            operator-scoped key restriction (`Your access is restricted to
            Economic Operator: <operatorId>`)."
        "404":
          $ref: "#/components/responses/NotFound"
          description: "No passport with this UUID owned by your tenant. Message:
            `Passport not found in your workspace.`"
        "409":
          description: The passport is not a DRAFT — published passports cannot be
            hard-deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Conflict
                message: Only draft passports can be deleted. A published passport must be
                  decommissioned/archived to satisfy the persistence duty (ESPR
                  Art. 9(2)).
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: "Unexpected failure. A failure in the lookup or delete itself is
            not wrapped by the route and returns the framework's **default**
            error body (`statusCode`/`error`/`message`, no `success` field); an
            authentication-layer failure returns the standard envelope with
            `message: \"Authentication verification failed\"`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                authVerificationFailed:
                  summary: Authentication-layer failure (standard envelope)
                  value:
                    success: false
                    error: Internal Server Error
                    message: Authentication verification failed
  /api/v1/passports/{id}/seal:
    post:
      operationId: sealPassport
      tags:
        - Passports
      summary: Apply the tenant's advanced electronic seal
      description: >-
        Signs the passport's Merkle root (SHA-256 tree over the key-sorted
        top-level `metadata` entries) with the tenant's vault-held **ECDSA P-256
        (prime256v1)** private key, producing an **advanced** electronic seal
        (this is a local cryptographic seal — NOT a Commission/EU-registry
        registration, and NOT a qualified seal). The base64 signature is stored
        as `digitalSeal` together with the signing public key (PEM), the X.509
        chain binding the key to the tenant's legal identity (surfaced as
        `proof.x5c`, leaf first, base64 DER), and — **best-effort, opt-in** — an
        RFC 3161 trusted timestamp over SHA-256(merkleRoot) (`proof.rfc3161`; a
        TSA outage or missing configuration never blocks sealing, the field is
        simply absent).


        A `passport.sealed` webhook is enqueued transactionally with the update
        (payload: the public-redacted JSON-LD document including the full
        `proof` block).


        **Permission:** `passport:seal` (write — subscription gating applies,
        see 402). Cookie sessions must send `X-CSRF-Token`; Bearer/API-key
        clients are exempt.


        **Lookup:** passport **UUID or `productId`** (UUID tried first),
        restricted to the passport's **owning tenant**.


        **Behavioral caveats:**

        - The route does **not** modify the passport's `status` — despite the
        success message's "and published" wording, a DRAFT stays a DRAFT after
        sealing. Publish via `PUT /api/v1/passports/{id}` (validated save)
        instead.

        - Re-sealing an already-sealed passport is allowed and **overwrites**
        the previous seal/timestamp.

        - Once sealed, in-place metadata edits are refused (403 on `PUT
        /api/v1/passports/{id}`).

        - Requires the tenant's signing key pair to exist — otherwise 400.

        - The returned `passport` document is serialized at the **public**
        redaction tier (masked keys keep their true leaf hashes in
        `proof.redactedLeaves`, so the seal stays offline-verifiable after
        redaction).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Passport UUID **or** caller-supplied `productId` (GTIN-14 / GRAI /
            SKU). UUID is tried first.
          schema:
            type: string
            minLength: 1
      responses:
        "200":
          description: Passport sealed. `digitalSeal` is the base64 ECDSA-P256-SHA256
            signature over the Merkle root; the same value appears as
            `passport.proof.signatureValue`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportSealResponse"
              example:
                success: true
                message: Passport sealed with the tenant's eIDAS advanced electronic seal and
                  published.
                digitalSeal: MEUCIQDOJ9uZ9b1H0u4G7m2X8nF3kqL5wTzVbY1c2d3e4f5g6AIgKxN8mPqRsTuVwXyZ0a1b2c3d4e5f6g7h8i9j0kAbCdE=
                signingPublicKey: |
                  -----BEGIN PUBLIC KEY-----
                  MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1u2v3w4x5y6z7A8B9C0D1E2F3G4H
                  5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3a4b5c6d7e8f9g0h1i2j3k4l5m6n
                  -----END PUBLIC KEY-----
                passport:
                  "@context":
                    - https://opendpp-node.eu/contexts/dpp/v1
                    - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                      economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                      manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                      metadata: https://opendpp-node.eu/ns/dpp#metadata
                      digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                      signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                      status: https://opendpp-node.eu/ns/dpp#status
                      archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                      retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                      category: https://opendpp-node.eu/contexts/dpp/v1#category
                      originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                      size: https://opendpp-node.eu/contexts/dpp/v1#size
                      facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                  "@type": DigitalProductPassport
                  "@id": https://opendpp-node.eu/01/09501101530003
                  id: 9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d
                  productId: "09501101530003"
                  digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                  digitalSeal: MEUCIQDOJ9uZ9b1H0u4G7m2X8nF3kqL5wTzVbY1c2d3e4f5g6AIgKxN8mPqRsTuVwXyZ0a1b2c3d4e5f6g7h8i9j0kAbCdE=
                  signingPublicKey: |
                    -----BEGIN PUBLIC KEY-----
                    MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1u2v3w4x5y6z7A8B9C0D1E2F3G4H
                    5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3a4b5c6d7e8f9g0h1i2j3k4l5m6n
                    -----END PUBLIC KEY-----
                  status: ACTIVE
                  archivedAt: null
                  retentionUntil: null
                  proof:
                    "@type":
                      - MerkleTreeAttestationProof
                    type: MerkleTreeAttestationProof
                    signatureAlgorithm: ECDSA-P256-SHA256-over-MerkleRoot
                    created: 2026-06-12T09:45:12.000Z
                    proofPurpose: assertionMethod
                    verificationMethod: https://opendpp-node.eu/passport/9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d#key-1
                    signatureValue: MEUCIQDOJ9uZ9b1H0u4G7m2X8nF3kqL5wTzVbY1c2d3e4f5g6AIgKxN8mPqRsTuVwXyZ0a1b2c3d4e5f6g7h8i9j0kAbCdE=
                    publicKeyPem: |
                      -----BEGIN PUBLIC KEY-----
                      MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1u2v3w4x5y6z7A8B9C0D1E2F3G4H
                      5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3a4b5c6d7e8f9g0h1i2j3k4l5m6n
                      -----END PUBLIC KEY-----
                    x5c:
                      - MIIBszCCAVqgAwIBAgIUEjRWeJq83vEjRWeJq83vEjRWeJo
                      - MIIBqzCCAVGgAwIBAgIUZYxw9PqrstuvZYxw9Pqrstuvxyz
                    rfc3161:
                      genTime: 2026-06-12T09:45:13.000Z
                      token: MIIKExampleBase64DerEncodedRfc3161TimeStampRespToken
                    merkleRoot: 7f83b1657ff1fc53b92dc18148a1d65dff20fdcf2e57d8a1f6cbeab6db1ec9f3
                    redactedLeaves:
                      facilityDetails: 3c9909afec25354d551dae21590bb26e38d53f2173b8d3dc3eee4c047e7ab1c1
                  createdAt: 2026-06-12T09:41:00.000Z
                  updatedAt: 2026-06-12T09:45:12.000Z
                  economicOperator:
                    "@type": EconomicOperator
                    id: f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f
                    name: Aurora Textiles GmbH
                    regId: EU-DEFAULT-001
                    role: Manufacturer
                  manufacturingFacility: null
                  metadata:
                    category: textiles
                    originCountry: PT
                    size: M
                    facilityDetails: "[REDACTED - Privileged Access Required]"
                  category: textiles
                  originCountry: PT
                  size: M
                  facilityDetails: "[REDACTED - Privileged Access Required]"
        "400":
          description: Missing identifier, or the tenant has no signing key pair configured.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Bad Request
                message: eIDAS cryptographic keys are not configured for this Tenant. Please
                  generate or rotate keys in your console first.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
          description: "Insufficient permission (`passport:seal`), missing/invalid CSRF
            token (cookie sessions), cross-tenant subdomain mismatch, or
            operator-scoped key restriction (`Your access is restricted to
            Economic Operator: <operatorId>`)."
        "404":
          $ref: "#/components/responses/NotFound"
          description: "No passport with this UUID/productId owned by your tenant.
            Message: `No Digital Product Passport found matching identifier:
            <id> for this tenant`."
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
          description: 'Three variants: key-material lookup failure ("Database lookup
            failed while retrieving cryptographic credentials."), signing
            failure ("Failed to generate the eIDAS digital seal. Please contact
            support." — the raw signing/decryption error is never leaked), or
            transactional update failure ("Failed to synchronize and sign
            passport payload"). The underlying error is logged server-side and
            never returned.'
  /api/v1/passports/{id}/status:
    put:
      operationId: updatePassportStatus
      tags:
        - Passports
      summary: Transition passport lifecycle status (recall / decommission / reactivate)
      description: >-
        Transitions a **published** passport between live lifecycle states. The
        request body carries only `status` (any other keys are ignored — there
        is no `reason` field; the history entry's change reason is
        auto-generated as `Status changed: <from> → <to>`).


        **Permission:** `passport:update` (write — subscription gating applies,
        see 402). Cookie sessions must send `X-CSRF-Token`; Bearer/API-key
        clients are exempt.


        **Lookup:** passport **UUID or `productId`** (UUID tried first), scoped
        to operators bound to your workspace.


        **Effects:**

        - `DECOMMISSIONED` — sets `retentionUntil = now + the configured
        retention period` (default 15 years), starting the minimum-availability
        retention clock. The passport stays publicly resolvable.

        - `ACTIVE` (reactivation) — clears `retentionUntil` **and**
        `archivedAt`.

        - `RECALLED` — marks the product recalled.

        - The status change, the version-history entry (who/when/what) and the
        webhook enqueue are **transactional**; an in-app notification is created
        **best-effort after the transaction commits** (a notification failure
        never affects the response).


        **Webhooks:** `RECALLED` enqueues `passport.recalled`; any other
        transition (`DECOMMISSIONED`, reactivate-to-`ACTIVE`) enqueues
        `passport.status_updated` — note that `passport.status_updated` is
        **not** an explicitly subscribable event filter, so only wildcard
        (`"*"`) webhook subscriptions receive it. Payloads are the
        public-redacted JSON-LD document.


        **Caveats:** DRAFT passports are refused with 409 (publish first via a
        validated `PUT /api/v1/passports/{id}`). Sealed passports CAN change
        status — `status` is stored alongside the document, not inside the
        sealed metadata Merkle tree. The returned `passport` document is
        serialized at the **public** redaction tier.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Passport UUID **or** caller-supplied `productId` (GTIN-14 / GRAI /
            SKU). UUID is tried first.
          schema:
            type: string
            minLength: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PassportStatusUpdateRequest"
            example:
              status: DECOMMISSIONED
      responses:
        "200":
          description: Status updated. `status` echoes the new lifecycle state; `passport`
            is the public-tier JSON-LD document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassportStatusUpdateResponse"
              example:
                success: true
                message: Passport status successfully updated to DECOMMISSIONED
                status: DECOMMISSIONED
                passport:
                  "@context":
                    - https://opendpp-node.eu/contexts/dpp/v1
                    - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                      economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                      manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                      metadata: https://opendpp-node.eu/ns/dpp#metadata
                      digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                      signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                      status: https://opendpp-node.eu/ns/dpp#status
                      archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                      retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                      category: https://opendpp-node.eu/contexts/dpp/v1#category
                      originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                      size: https://opendpp-node.eu/contexts/dpp/v1#size
                      facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                  "@type": DigitalProductPassport
                  "@id": https://opendpp-node.eu/01/09501101530003
                  id: 9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d
                  productId: "09501101530003"
                  digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                  digitalSeal: null
                  signingPublicKey: null
                  status: DECOMMISSIONED
                  archivedAt: null
                  retentionUntil: 2041-06-12T10:02:00.000Z
                  proof: null
                  createdAt: 2026-06-12T09:41:00.000Z
                  updatedAt: 2026-06-12T10:02:00.000Z
                  economicOperator:
                    "@type": EconomicOperator
                    id: f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f
                    name: Aurora Textiles GmbH
                    regId: EU-DEFAULT-001
                    role: Manufacturer
                  manufacturingFacility: null
                  metadata:
                    category: textiles
                    originCountry: PT
                    size: M
                    facilityDetails: "[REDACTED - Privileged Access Required]"
                  category: textiles
                  originCountry: PT
                  size: M
                  facilityDetails: "[REDACTED - Privileged Access Required]"
        "400":
          description: "`status` missing or not one of the allowed values."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Bad Request
                message: "status must be one of: ACTIVE, RECALLED, DECOMMISSIONED"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
          description: "Insufficient permission (`passport:update`), missing/invalid CSRF
            token (cookie sessions), cross-tenant subdomain mismatch, or
            operator-scoped key restriction (`Your access is restricted to
            Economic Operator: <operatorId>`)."
        "404":
          $ref: "#/components/responses/NotFound"
          description: "No passport with this UUID/productId under an operator bound to
            your workspace. Message: `Passport not found`."
        "409":
          description: The passport is a DRAFT — drafts cannot transition to a live status
            here.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Conflict
                message: This passport is a draft. Open it and use Save & publish to validate
                  and publish it before changing its status.
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: Unexpected server error. Unhandled errors are normalized by the
            global error handler to the standard envelope with the generic
            message "An unexpected error occurred"; details are logged
            server-side, never returned.
          content:
            application/json:
              schema:
                type: object
                description: Standard envelope; `message` is omitted when the internal error
                  carried no text.
                required:
                  - success
                  - error
                properties:
                  success:
                    type: boolean
                    const: false
                  error:
                    type: string
                  message:
                    type: string
              examples:
                standardEnvelope:
                  summary: Transactional failure (standard envelope; message optional)
                  value:
                    success: false
                    error: Internal Server Error
                    message: Transaction failed
  /passport/{id}:
    get:
      operationId: resolvePublicPassport
      tags:
        - Public Resolution
      summary: Resolve a passport by UUID (JSON-LD / AAS / HTML)
      description: >-
        Public, content-negotiated resolution of a Digital Product Passport by
        its server-assigned UUID. Lookup is by primary key only —
        GTIN/GRAI/serial lookups go through the GS1 Digital Link gateway (`GET
        /01/{gtin14}`, `GET /8003/{grai}`).


        **Content negotiation** — the representation is chosen by RFC 7231
        §5.3.2 `Accept` q-value negotiation (highest q wins; ties broken by
        media-range specificity, then the client's stated order):
        `application/aas+json` (or bare `aas+json`) → role-filtered Asset
        Administration Shell environment; `application/vc+jwt` (or bare
        `vc+jwt`) → a signed UNTP DigitalProductPassport credential (public
        tier; `406 Not Acceptable` when the passport has no manufacturing
        facility with a country of production); `application/vc+ld+json` (or
        bare `vc+ld+json`) → the same credential with an embedded W3C Data
        Integrity proof (`ecdsa-jcs-2019`), same `406` condition;
        `application/dc+sd-jwt` (or the legacy `vc+sd-jwt`) → the same
        credential as an SD-JWT-VC for cryptographic selective disclosure (a
        holder presents a subset of `credentialSubject` claims), same `406`
        condition; `text/html` → server-rendered passport page. An absent
        `Accept`, or one matching only `*/*`, yields the canonical default
        JSON-LD (`application/ld+json`); an unsupported type is ignored. Because
        q-values and client order are honoured, `Accept: text/html,
        application/vc+jwt` selects HTML (the client's first preference), NOT
        `vc+jwt`. `Vary: Accept` is always set on the 200.


        **Access tiers** — no permission string (public endpoint). Credentials
        are *optional* and never produce 401/402/403 here; an invalid or foreign
        credential silently degrades to the public tier:

        - **Public** (anonymous): restricted metadata keys (for category
        `batteries`: `detailedPerformance`, `lifecycleAndInUse`,
        `circularityAndDisassembly` — masked only when present) and the
        owner-only key `facilityDetails` (present-as-placeholder in every
        non-owner response, even when the underlying metadata never contained
        it) carry the literal placeholder `[REDACTED - Privileged Access
        Required]`. Each masked key that exists in the sealed metadata keeps its
        true Merkle leaf hash in `proof.redactedLeaves`, so the seal stays
        offline-verifiable after redaction; a placeholder-valued key with no
        `redactedLeaves` entry was never in the sealed metadata and must be
        excluded when rebuilding the root.

        - **Legitimate interest / authority**: a capability grant token —
        `dpp_li_…` (tenant-issued) or `dpp_auth_…` (platform-issued, not
        tenant-revocable) — sent as `Authorization: Bearer <token>` or
        `?grant=<token>`, with TENANT or PASSPORT scope covering this passport,
        unlocks the restricted tier-2 keys. `facilityDetails`, the facility
        street address and DRAFT passports stay hidden. Grant-unlocked responses
        add `Cache-Control: private, no-store` and `Referrer-Policy:
        no-referrer`.

        - **Owner**: a tenant **API key** (`op_dpp_token_…`, shown once at
        creation) belonging to the owning tenant or to a tenant bound to the
        passport's economic operator — sent as a Bearer token. Only API keys are
        matched on the public resolvers: a Console JWT login session does
        **not** unlock owner tier (it silently resolves as public). Owners see
        everything, including DRAFT passports, owner-only metadata keys and the
        facility street address
        (`manufacturingFacility.streetAddress`/`city`/`postalCode`). In the AAS
        representation the owner credential's API-key role drives element
        filtering; a grant maps to the `legitimate_interest` filter tier,
        anonymous to `public`.


        DRAFT passports are hidden from everyone but the owner (404 with a body
        identical to a true miss). Every resolution is recorded in the
        passport's access audit log with an anonymized IP.


        **Rate limit:** 30 requests/min/IP; its 429 body is the two-field public
        error shape (no `success` field). This limiter adds no headers of its
        own — the `x-ratelimit-*` headers still present on responses (including
        these 429s) belong to the global platform limit (100 req/min/IP, 600/min
        for known crawler user agents), which applies on top.
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The passport's server-assigned UUID (returned as `id` on creation
            and embedded as AI-21 in the SKU-level Digital Link URI).
        - name: grant
          in: query
          required: false
          schema:
            type: string
            pattern: ^dpp_(li|auth)_[A-Za-z0-9]{20,64}$
          description: "Capability grant token (`dpp_li_…` legitimate-interest,
            `dpp_auth_…` authority) — the inspection-link path for QR-scanning
            inspectors who cannot set headers. Equivalent to sending the token
            as `Authorization: Bearer`. Tokens minted by the platform are the
            prefix followed by 32 hex characters, but the server matches any
            prefixed token against its stored hashes (the demo workspace's
            sample tokens use a different suffix), so the pattern here is
            deliberately loose. Treat as a secret: responses unlocked this way
            carry `Cache-Control: private, no-store` + `Referrer-Policy:
            no-referrer`, and the server log redacts the parameter."
      responses:
        "200":
          description: "The passport in the negotiated representation. `Vary: Accept`
            always set; `Cache-Control: private, no-store` and `Referrer-Policy:
            no-referrer` added only when a grant token unlocked the response."
          headers:
            Vary:
              schema:
                type: string
              description: Always `Accept` — the same URL serves HTML, JSON-LD and AAS.
            Cache-Control:
              schema:
                type: string
              description: "`private, no-store` — present only when access was unlocked by a
                grant token."
            Referrer-Policy:
              schema:
                type: string
              description: "`no-referrer` — present only when access was unlocked by a grant
                token."
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/PublicPassportJsonLd"
              example:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                    economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                    manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                    metadata: https://opendpp-node.eu/ns/dpp#metadata
                    digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                    signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                    status: https://opendpp-node.eu/ns/dpp#status
                    archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                    retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                    category: https://opendpp-node.eu/contexts/dpp/v1#category
                    gtin: https://opendpp-node.eu/contexts/dpp/v1#gtin
                    batteryCategory: https://opendpp-node.eu/contexts/dpp/v1#batteryCategory
                    chemistry: https://opendpp-node.eu/contexts/dpp/v1#chemistry
                    originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                    detailedPerformance: https://opendpp-node.eu/contexts/dpp/v1#detailedPerformance
                    facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                "@type": DigitalProductPassport
                "@id": https://opendpp-node.eu/01/09501101530003
                id: 9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                productId: "09501101530003"
                digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                digitalSeal: MEUCIQDl0n7K0wG7B1k9p2v4S0cQ9X4j8M5n6P7q8R9s0T1uAIgK2L3m4N5o6P7q8R9s0T1u2V3w4X5y6Z7a8B9c0D1e2F4=
                signingPublicKey: |
                  -----BEGIN PUBLIC KEY-----
                  MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7sB3kFq2nXp9wLm4Rv8tYc1dZh6j
                  Ke0aGu5iSx3oNl2bPw9rT7vCmD4fHg8qWy1zEjU6kA0sIxLpO5tMnBvR3Q==
                  -----END PUBLIC KEY-----
                status: ACTIVE
                archivedAt: null
                retentionUntil: null
                proof:
                  "@type":
                    - MerkleTreeAttestationProof
                  type: MerkleTreeAttestationProof
                  signatureAlgorithm: ECDSA-P256-SHA256-over-MerkleRoot
                  created: 2026-06-12T09:41:00.000Z
                  proofPurpose: assertionMethod
                  verificationMethod: https://opendpp-node.eu/passport/9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b#key-1
                  signatureValue: MEUCIQDl0n7K0wG7B1k9p2v4S0cQ9X4j8M5n6P7q8R9s0T1uAIgK2L3m4N5o6P7q8R9s0T1u2V3w4X5y6Z7a8B9c0D1e2F4=
                  publicKeyPem: |
                    -----BEGIN PUBLIC KEY-----
                    MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7sB3kFq2nXp9wLm4Rv8tYc1dZh6j
                    Ke0aGu5iSx3oNl2bPw9rT7vCmD4fHg8qWy1zEjU6kA0sIxLpO5tMnBvR3Q==
                    -----END PUBLIC KEY-----
                  x5c:
                    - MIIB2TCCAX6gAwIBAgIUTGVhZkNlcnRFeGFtcGxlRGF0YTAKBggqhkjOPQQDAjAgMR4wHAYDVQQDDBVPcGVuRFBQIFNlYWwgQ0EgKERlbW8p
                    - MIIBszCCAVmgAwIBAgIUQ2FDZXJ0RXhhbXBsZURhdGFCYXNlNjQwCgYIKoZIzj0EAwIwIDEeMBwGA1UEAwwVT3BlbkRQUCBTZWFsIENBIChEZW1vKQ
                  rfc3161:
                    genTime: 2026-06-12T09:41:02.000Z
                    token: MIIKExampleBase64DerEncodedRfc3161TimeStampRespToken
                  merkleRoot: 8c4f9d2e6a1b7c3f5e0d8a4b2c6f1e9d7a3b5c8f0e2d4a6b9c1f3e5d7a0b2c4f
                  redactedLeaves:
                    detailedPerformance: f3a19c7e5b2d8f4a6c0e1d9b7a3f5c2e8d4b6a0c9f1e3d5b7a2c4f6e8d0b1a3c
                    facilityDetails: 0b7c4f2e9d1a6b3c8f5e0d7a4b1c6f3e9d2a5b8c0f7e4d1a6b3c9f5e2d8a0b4c
                createdAt: 2026-06-01T08:00:00.000Z
                updatedAt: 2026-06-12T09:41:00.000Z
                economicOperator:
                  "@type": EconomicOperator
                  id: 4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40
                  name: Voltaic Cells Manufacturing GmbH
                  regId: EU-DEFAULT-001
                  role: MANUFACTURER
                manufacturingFacility:
                  "@type": Facility
                  id: 7c1e5a2d-9b4f-4c8e-a3d6-2f8b0e4c9a71
                  gln: "0950110153007"
                  name: Voltaic Gigafactory Brandenburg
                  activity: cell assembly
                  country: DE
                metadata:
                  category: batteries
                  gtin: "09501101530003"
                  batteryCategory: ev
                  chemistry: NMC 811
                  originCountry: DE
                  detailedPerformance: "[REDACTED - Privileged Access Required]"
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                category: batteries
                gtin: "09501101530003"
                batteryCategory: ev
                chemistry: NMC 811
                originCountry: DE
                detailedPerformance: "[REDACTED - Privileged Access Required]"
                facilityDetails: "[REDACTED - Privileged Access Required]"
            application/aas+json:
              schema:
                $ref: "#/components/schemas/AasEnvironment"
              example:
                assetAdministrationShells:
                  - id: urn:opendpp:aas:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                    idShort: AAS_09501101530003
                    assetInformation:
                      assetKind: Instance
                      globalAssetId: urn:opendpp:asset:4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40:09501101530003
                submodels:
                  - id: urn:opendpp:submodel:general:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                    idShort: GeneralProductInformation
                  - id: urn:opendpp:submodel:compliance
                    idShort: ComplianceMetadata
                  - id: urn:opendpp:submodel:security-seal:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                    idShort: eidasVerificationSeal
                conceptDescriptions: []
            application/vc+jwt:
              schema:
                type: string
                description: A UNTP DigitalProductPassport credential as an enveloping JOSE
                  proof — a compact JWS with protected header `{alg:ES256,
                  typ:vc+jwt, kid:<issuerDid>#key-0}`. PUBLIC tier only
                  (privileged metadata never enters the VC). The issuer is the
                  tenant's `did:web`; resolve `GET /tenants/{tenantId}/did.json`
                  for the verification key. Revocation is the W3C Bitstring
                  Status List referenced by the credential's `credentialStatus`
                  (served at `GET /tenants/{tenantId}/status/revocation`).
                  Returned only when the passport has a manufacturing facility
                  with a country of production — otherwise `406 Not Acceptable`.
              example: eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCIsImtpZCI6ImRpZDp3ZWI6...
            application/vc+ld+json:
              schema:
                type: object
                description: The same UNTP DigitalProductPassport credential with an EMBEDDED
                  W3C Data Integrity proof (`proof.type` `DataIntegrityProof`,
                  `proof.cryptosuite` `ecdsa-jcs-2019`) — RFC 8785 JCS
                  canonicalization, multibase base58btc `proof.proofValue`,
                  signed by the tenant's `did:web` key
                  (`proof.verificationMethod` = `<issuerDid>#key-<n>`; resolve
                  `GET /tenants/{tenantId}/did.json`). PUBLIC tier; revocation
                  via `credentialStatus` (Bitstring Status List). Same `406 Not
                  Acceptable` when the passport has no manufacturing facility
                  with a country of production. The enveloping
                  `application/vc+jwt` is the primary, UNTP-mandated
                  representation; this is the optional embedded-proof
                  alternative for Data-Integrity verifiers.
            application/dc+sd-jwt:
              schema:
                type: string
                description: The same UNTP DigitalProductPassport credential as an **SD-JWT-VC**
                  (IETF SD-JWT-VC) — cryptographic selective disclosure. The
                  issuer serves the FULL SD-JWT, `<JWS>~<disclosure>~…` with
                  protected header `{alg:ES256, typ:dc+sd-jwt,
                  kid:<issuerDid>#key-<n>}` plus the SD-JWT-VC claims `iss` (the
                  issuer `did:web`) and `vct`
                  (`https://opendpp-node.eu/vct/digital-product-passport`). A
                  HOLDER may then present any SUBSET of the disclosable
                  `credentialSubject` claims (`materialProvenance` /
                  `performanceClaim` / `characteristics` / `producedAtFacility`
                  / `countryOfProduction`) by dropping disclosures, and it still
                  verifies against the issuer signature — withheld claims
                  survive only as opaque `_sd` digests. Structural identity
                  (`id`/`type`/`productCategory`/`vct`/`iss`) is always in
                  clear; revocation (`credentialStatus`, Bitstring Status List)
                  is never disclosable. PUBLIC tier; the issuer is the tenant's
                  `did:web` (resolve `GET /tenants/{tenantId}/did.json`). Same
                  `406 Not Acceptable` when the passport has no manufacturing
                  facility with a country of production. The legacy
                  `application/vc+sd-jwt` media type is also accepted on the
                  request `Accept` header.
              example: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCJ9.eyJpc3MiOiJkaWQ6d2ViOi4uLiIsInZjdCI6Li4ufQ.sig~WyJzYWx0MCIsImNvdW50cnlPZlByb2R1Y3Rpb24iLHsiY291bnRyeUNvZGUiOiJERSJ9XQ~
            text/html:
              schema:
                type: string
                description: Server-rendered passport page (owner credentials see the full owner
                  view).
        "400":
          description: Passport identifier missing. (Defensive guard — not reachable
            through normal routing, since the path parameter is required.) Body
            omits the `success` field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Bad Request
                message: Passport identifier must be provided
        "404":
          description: "No passport with that UUID — or the passport is a DRAFT and the
            caller is not owner-tier (identical body, deliberate). `Accept:
            text/html` gets an SSR not-found page instead of JSON. Body omits
            the `success` field. (This route does not set `Vary` on the 404.)
            Separately, a request on an unknown tenant workspace host receives a
            platform-level JSON 404 (`No tenant company found for subdomain: …`)
            before resolution runs — that host check applies to every documented
            path except `/health`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Not Found
                message: "No Digital Product Passport found matching identifier:
                  9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b"
            text/html:
              schema:
                type: string
                description: SSR not-found page.
        "406":
          $ref: "#/components/responses/NotAcceptable"
        "429":
          $ref: "#/components/responses/PublicRateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /01/{gtin14}:
    get:
      operationId: resolveGs1Gtin
      tags:
        - Public Resolution
      summary: GS1 Digital Link resolution by GTIN-14 (AI 01)
      description: >-
        Unified GS1 Digital Link gateway, GTIN branch. The GTIN-14 is matched
        against `metadata.gtin`, `metadata.grai`, or the passport's `productId`.
        On tenant workspaces (`https://{tenant}.opendpp-node.eu`) the lookup is
        scoped to that tenant — an unknown subdomain returns 404. Without a
        tenant scope, a GTIN matching more than one passport is rejected with
        400 (ambiguous); disambiguate via a brand subdomain (the `?subdomain=`
        query override is honoured in non-production environments only).


        Content negotiation (RFC 7231 §5.3.2 `Accept` q-value negotiation;
        JSON-LD default / `application/aas+json` / `application/vc+jwt` /
        `application/vc+ld+json` / `application/dc+sd-jwt` / `text/html`, `Vary:
        Accept` always set), access tiers (public / `dpp_li_…`·`dpp_auth_…`
        grant via Bearer or `?grant=` / owner = a tenant **API key** sent as a
        Bearer token — Console JWT login sessions do **not** unlock owner tier),
        DRAFT hiding, access-audit logging (anonymized IP), and grant response
        headers (`Cache-Control: private, no-store`, `Referrer-Policy:
        no-referrer`) are identical to `GET /passport/{id}` — see that operation
        for the full tier semantics. No permission string (public endpoint);
        invalid credentials silently degrade to the public tier, never 401/403.


        The gateway also accepts additional GS1 AI key/value path pairs after
        the GTIN; the only one acted on is AI 21 (serial) — documented
        separately as `GET /01/{gtin14}/21/{serial}`. (The underlying route is
        `GET /{ai}/*`; AI prefixes other than `01` and `8003` get a 400.) This
        resolver handles only the **UNCOMPRESSED** GS1 Digital Link grammar; a
        **compressed** Digital Link (its AI data encoded as a base64url blob) is
        detected and rejected with a clear 400 that points to the uncompressed
        form.


        **Rate limit:** 30 requests/min/IP; two-field 429 body without
        `success`. The limiter adds no headers of its own — `x-ratelimit-*`
        headers on responses come from the global platform limit (100
        req/min/IP), which applies on top.
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: gtin14
          in: path
          required: true
          schema:
            type: string
            pattern: ^[0-9]{14}$
          description: "GTIN-14: exactly 14 digits with a valid GS1 modulo-10 check digit
            (the check digit is validated server-side — the pattern alone is not
            sufficient)."
        - name: grant
          in: query
          required: false
          schema:
            type: string
            pattern: ^dpp_(li|auth)_[A-Za-z0-9]{20,64}$
          description: "Capability grant token (`dpp_li_…` / `dpp_auth_…`); equivalent to
            `Authorization: Bearer`. Minted tokens are the prefix + 32 hex
            characters, but the server matches any prefixed token against stored
            hashes, so the pattern is deliberately loose. Treat as a secret —
            grant-unlocked responses are `private, no-store` and the parameter
            is redacted from logs."
      responses:
        "200":
          description: The matched passport in the negotiated representation (same
            envelope as `GET /passport/{id}`).
          headers:
            Vary:
              schema:
                type: string
              description: Always `Accept`.
            Cache-Control:
              schema:
                type: string
              description: "`private, no-store` — only when a grant token unlocked the
                response."
            Referrer-Policy:
              schema:
                type: string
              description: "`no-referrer` — only when a grant token unlocked the response."
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/PublicPassportJsonLd"
              example:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                    economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                    manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                    metadata: https://opendpp-node.eu/ns/dpp#metadata
                    digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                    signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                    status: https://opendpp-node.eu/ns/dpp#status
                    archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                    retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                    category: https://opendpp-node.eu/contexts/dpp/v1#category
                    gtin: https://opendpp-node.eu/contexts/dpp/v1#gtin
                    chemistry: https://opendpp-node.eu/contexts/dpp/v1#chemistry
                    originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                    detailedPerformance: https://opendpp-node.eu/contexts/dpp/v1#detailedPerformance
                    facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                "@type": DigitalProductPassport
                "@id": https://opendpp-node.eu/01/09501101530003
                id: 9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                productId: "09501101530003"
                digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                digitalSeal: null
                signingPublicKey: null
                status: ACTIVE
                archivedAt: null
                retentionUntil: null
                proof: null
                createdAt: 2026-06-01T08:00:00.000Z
                updatedAt: 2026-06-12T09:41:00.000Z
                economicOperator:
                  "@type": EconomicOperator
                  id: 4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40
                  name: Voltaic Cells Manufacturing GmbH
                  regId: EU-DEFAULT-001
                  role: MANUFACTURER
                manufacturingFacility:
                  "@type": Facility
                  id: 7c1e5a2d-9b4f-4c8e-a3d6-2f8b0e4c9a71
                  gln: "0950110153007"
                  name: Voltaic Gigafactory Brandenburg
                  activity: cell assembly
                  country: DE
                metadata:
                  category: batteries
                  gtin: "09501101530003"
                  chemistry: NMC 811
                  originCountry: DE
                  detailedPerformance: "[REDACTED - Privileged Access Required]"
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                category: batteries
                gtin: "09501101530003"
                chemistry: NMC 811
                originCountry: DE
                detailedPerformance: "[REDACTED - Privileged Access Required]"
                facilityDetails: "[REDACTED - Privileged Access Required]"
            application/aas+json:
              schema:
                $ref: "#/components/schemas/AasEnvironment"
              example:
                assetAdministrationShells:
                  - id: urn:opendpp:aas:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                    idShort: AAS_09501101530003
                    assetInformation:
                      assetKind: Instance
                      globalAssetId: urn:opendpp:asset:4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40:09501101530003
                submodels:
                  - id: urn:opendpp:submodel:general:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                    idShort: GeneralProductInformation
                  - id: urn:opendpp:submodel:compliance
                    idShort: ComplianceMetadata
                  - id: urn:opendpp:submodel:security-seal:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                    idShort: eidasVerificationSeal
                conceptDescriptions: []
            application/vc+jwt:
              schema:
                type: string
                description: A UNTP DigitalProductPassport credential as an enveloping JOSE
                  proof — a compact JWS with protected header `{alg:ES256,
                  typ:vc+jwt, kid:<issuerDid>#key-0}`. PUBLIC tier only
                  (privileged metadata never enters the VC). The issuer is the
                  tenant's `did:web`; resolve `GET /tenants/{tenantId}/did.json`
                  for the verification key. Revocation is the W3C Bitstring
                  Status List referenced by the credential's `credentialStatus`
                  (served at `GET /tenants/{tenantId}/status/revocation`).
                  Returned only when the passport has a manufacturing facility
                  with a country of production — otherwise `406 Not Acceptable`.
              example: eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCIsImtpZCI6ImRpZDp3ZWI6...
            application/vc+ld+json:
              schema:
                type: object
                description: The same UNTP DigitalProductPassport credential with an EMBEDDED
                  W3C Data Integrity proof (`proof.type` `DataIntegrityProof`,
                  `proof.cryptosuite` `ecdsa-jcs-2019`) — RFC 8785 JCS
                  canonicalization, multibase base58btc `proof.proofValue`,
                  signed by the tenant's `did:web` key
                  (`proof.verificationMethod` = `<issuerDid>#key-<n>`; resolve
                  `GET /tenants/{tenantId}/did.json`). PUBLIC tier; revocation
                  via `credentialStatus` (Bitstring Status List). Same `406 Not
                  Acceptable` when the passport has no manufacturing facility
                  with a country of production. The enveloping
                  `application/vc+jwt` is the primary, UNTP-mandated
                  representation; this is the optional embedded-proof
                  alternative for Data-Integrity verifiers.
            application/dc+sd-jwt:
              schema:
                type: string
                description: The same UNTP DigitalProductPassport credential as an **SD-JWT-VC**
                  (IETF SD-JWT-VC) — cryptographic selective disclosure. The
                  issuer serves the FULL SD-JWT, `<JWS>~<disclosure>~…` with
                  protected header `{alg:ES256, typ:dc+sd-jwt,
                  kid:<issuerDid>#key-<n>}` plus the SD-JWT-VC claims `iss` (the
                  issuer `did:web`) and `vct`
                  (`https://opendpp-node.eu/vct/digital-product-passport`). A
                  HOLDER may then present any SUBSET of the disclosable
                  `credentialSubject` claims (`materialProvenance` /
                  `performanceClaim` / `characteristics` / `producedAtFacility`
                  / `countryOfProduction`) by dropping disclosures, and it still
                  verifies against the issuer signature — withheld claims
                  survive only as opaque `_sd` digests. Structural identity
                  (`id`/`type`/`productCategory`/`vct`/`iss`) is always in
                  clear; revocation (`credentialStatus`, Bitstring Status List)
                  is never disclosable. PUBLIC tier; the issuer is the tenant's
                  `did:web` (resolve `GET /tenants/{tenantId}/did.json`). Same
                  `406 Not Acceptable` when the passport has no manufacturing
                  facility with a country of production. The legacy
                  `application/vc+sd-jwt` media type is also accepted on the
                  request `Accept` header.
              example: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCJ9.eyJpc3MiOiJkaWQ6d2ViOi4uLiIsInZjdCI6Li4ufQ.sig~WyJzYWx0MCIsImNvdW50cnlPZlByb2R1Y3Rpb24iLHsiY291bnRyeUNvZGUiOiJERSJ9XQ~
            text/html:
              schema:
                type: string
                description: Server-rendered passport page.
        "400":
          description: Invalid GTIN-14 (must be 14 digits with a valid modulo-10 check
            digit) or — when no tenant scope is in play and no AI-21 serial was
            given — an ambiguous lookup matching multiple passports. Bodies omit
            the `success` field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalidGtin:
                  summary: Check-digit / format failure
                  value:
                    error: Bad Request
                    message: 'Invalid GS1 GTIN format. Standard GS1 Digital Link resolution under AI
                      01 requires a numeric-only GTIN of exactly 14 digits with
                      a valid modulo-10 check digit. Got: "09501101530009"'
                ambiguous:
                  summary: Multiple passports match (no tenant scope)
                  value:
                    error: Bad Request
                    message: GS1 Digital Link lookup on /01/:productId is ambiguous. Multiple
                      passports match this identifier. A brand-specific
                      subdomain (e.g., brand.opendpp.eu) or a ?subdomain=...
                      query parameter is required.
        "404":
          description: "No passport matches the identifier (content-negotiated: HTML page
            for `Accept: text/html`, JSON otherwise; `Vary: Accept` set), a
            DRAFT match was hidden from a non-owner caller, or the request
            arrived on an unknown tenant subdomain (JSON only). Bodies omit the
            `success` field."
          headers:
            Vary:
              schema:
                type: string
              description: "`Accept` — set on the negotiated not-found variant."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                noMatch:
                  summary: Nothing matches the GTIN
                  value:
                    error: Not Found
                    message: "No Digital Product Passport found matching identifier: 09501101530003"
                draftHidden:
                  summary: DRAFT hidden from non-owner
                  value:
                    error: Not Found
                    message: No Digital Product Passport found matching that identifier
                unknownSubdomain:
                  summary: Unknown tenant subdomain
                  value:
                    error: Not Found
                    message: "No tenant company found for subdomain: acme"
            text/html:
              schema:
                type: string
                description: SSR not-found page.
        "406":
          $ref: "#/components/responses/NotAcceptable"
        "429":
          $ref: "#/components/responses/PublicRateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /01/{gtin14}/21/{serial}:
    get:
      operationId: resolveGs1GtinSerial
      tags:
        - Public Resolution
      summary: GS1 Digital Link serialised-item redirect (AI 01 + AI 21)
      description: >-
        GS1 Digital Link resolution of an *individual serialised item*. This
        path never returns a document directly — on success it issues a `302`
        redirect (the query string, including `?grant=`, is preserved on the
        `Location` URL):


        1. If the GTIN resolves to a SKU/type passport that has a serialised
        battery unit whose `serialNumber` equals the AI-21 value → `302` to
        `/unit/{unitId}` (per-unit view).

        2. Otherwise (legacy fallback) the AI-21 value is matched against the
        passport UUID, `metadata.serialNumber`, or `metadata["21"]`; if a
        passport matches → `302` to `/passport/{passportId}`.

        3. Otherwise → `404` (content-negotiated).


        The ambiguity check of the bare-GTIN branch is skipped when an AI-21
        serial is present. The redirect handler itself never evaluates
        credentials — access tiers (owner / grant / public) apply at the
        redirect target; carry the grant in `?grant=` (preserved across the
        redirect) or re-send the `Authorization` header to the target. On tenant
        subdomains the lookup is scoped to that tenant (unknown subdomain → 404,
        JSON only).


        No permission string (public endpoint). **Rate limit:** 30
        requests/min/IP (two-field 429 body without `success`). The limiter adds
        no headers of its own — `x-ratelimit-*` headers come from the global
        platform limit, which applies on top — and the redirect target counts as
        a second request against both.
      security: []
      parameters:
        - name: gtin14
          in: path
          required: true
          schema:
            type: string
            pattern: ^[0-9]{14}$
          description: "GTIN-14: exactly 14 digits with a valid GS1 modulo-10 check digit
            (validated server-side)."
        - name: serial
          in: path
          required: true
          schema:
            type: string
          description: GS1 AI-21 serial. For serialised battery units this is the unit's
            physical serial (units are created matching
            `^[A-Za-z0-9._-]{1,20}$`); the legacy fallback also matches a
            passport UUID or the passport's `metadata.serialNumber` /
            `metadata["21"]` value. Percent-encode reserved characters; the
            segment is URL-decoded before matching.
        - name: grant
          in: query
          required: false
          schema:
            type: string
            pattern: ^dpp_(li|auth)_[A-Za-z0-9]{20,64}$
          description: Capability grant token. Not evaluated by this redirect handler — it
            is preserved on the `Location` URL and takes effect at the redirect
            target.
      responses:
        "302":
          description: Redirect to the resolved resource. `Location` is `/unit/{unitId}`
            (serialised unit found) or `/passport/{passportId}` (legacy serial
            fallback), with the original query string appended. No body.
          headers:
            Location:
              schema:
                type: string
              description: Relative redirect target, e.g.
                `/unit/5d8e2c41-7b9a-4e3f-8c2d-6a1f0b9e4d72?grant=dpp_li_...` or
                `/passport/9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b`.
        "400":
          description: Invalid GTIN-14 (format / modulo-10 check digit). Body omits the
            `success` field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Bad Request
                message: 'Invalid GS1 GTIN format. Standard GS1 Digital Link resolution under AI
                  01 requires a numeric-only GTIN of exactly 14 digits with a
                  valid modulo-10 check digit. Got: "09501101530009"'
        "404":
          description: "Neither a serialised unit nor a fallback passport matches
            (content-negotiated HTML/JSON, `Vary: Accept`), or unknown tenant
            subdomain (JSON only). Bodies omit the `success` field."
          headers:
            Vary:
              schema:
                type: string
              description: "`Accept` — set on the negotiated not-found variant."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Not Found
                message: "No Digital Product Passport found matching identifier: 09501101530003"
            text/html:
              schema:
                type: string
                description: SSR not-found page.
        "429":
          $ref: "#/components/responses/PublicRateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /8003/{grai}:
    get:
      operationId: resolveGs1Grai
      tags:
        - Public Resolution
      summary: GS1 Digital Link resolution by GRAI (AI 8003)
      description: >-
        Unified GS1 Digital Link gateway, GRAI branch (Global Returnable Asset
        Identifier). The GRAI is matched against `metadata.gtin`,
        `metadata.grai`, or the passport's `productId`. Everything else —
        content negotiation (JSON-LD default / `application/aas+json` /
        `application/vc+jwt` / `application/vc+ld+json` /
        `application/dc+sd-jwt` / `text/html`, `Vary: Accept`), access tiers
        (public / grant `dpp_li_…`·`dpp_auth_…` via Bearer or `?grant=` / owner
        = tenant API key as Bearer, never a Console JWT session), DRAFT hiding,
        tenant-subdomain scoping, the no-tenant-scope ambiguity 400,
        access-audit logging, grant response headers, and the 30 req/min/IP rate
        limit (two-field 429 body without `success`; the limiter adds no headers
        of its own — `x-ratelimit-*` headers come from the global 100 req/min/IP
        limit, which applies on top) — is identical to `GET /01/{gtin14}`; see
        that operation and `GET /passport/{id}` for full semantics.


        An additional `/21/{serial}` AI pair after the GRAI behaves exactly like
        `GET /01/{gtin14}/21/{serial}` (302 redirect to `/unit/{id}` or
        `/passport/{id}`). No permission string (public endpoint).
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: grai
          in: path
          required: true
          schema:
            type: string
            pattern: ^[0-9]{14}[A-Za-z0-9]{0,16}$
            minLength: 14
            maxLength: 30
          description: "GRAI: a 14-digit numeric asset identifier with a valid GS1
            modulo-10 check digit (validated server-side), followed by an
            optional alphanumeric serial component of up to 16 characters (total
            length 14-30)."
        - name: grant
          in: query
          required: false
          schema:
            type: string
            pattern: ^dpp_(li|auth)_[A-Za-z0-9]{20,64}$
          description: "Capability grant token (`dpp_li_…` / `dpp_auth_…`); equivalent to
            `Authorization: Bearer`. Minted tokens are the prefix + 32 hex
            characters; the server matches any prefixed token against stored
            hashes. Treat as a secret."
      responses:
        "200":
          description: The matched passport in the negotiated representation (same
            envelope as `GET /passport/{id}`).
          headers:
            Vary:
              schema:
                type: string
              description: Always `Accept`.
            Cache-Control:
              schema:
                type: string
              description: "`private, no-store` — only when a grant token unlocked the
                response."
            Referrer-Policy:
              schema:
                type: string
              description: "`no-referrer` — only when a grant token unlocked the response."
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/PublicPassportJsonLd"
              example:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                    economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                    manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                    metadata: https://opendpp-node.eu/ns/dpp#metadata
                    digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                    signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                    status: https://opendpp-node.eu/ns/dpp#status
                    archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                    retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                    category: https://opendpp-node.eu/contexts/dpp/v1#category
                    grai: https://opendpp-node.eu/contexts/dpp/v1#grai
                    originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                    facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                "@type": DigitalProductPassport
                "@id": https://opendpp-node.eu/8003/09501101530003CRATE001/21/3c7e1f52-8d4a-4b9e-a6c0-5f2d8e1b7a93
                id: 3c7e1f52-8d4a-4b9e-a6c0-5f2d8e1b7a93
                productId: 09501101530003CRATE001
                digitalLinkUri: https://opendpp-node.eu/8003/09501101530003CRATE001/21/3c7e1f52-8d4a-4b9e-a6c0-5f2d8e1b7a93
                digitalSeal: null
                signingPublicKey: null
                status: ACTIVE
                archivedAt: null
                retentionUntil: null
                proof: null
                createdAt: 2026-06-01T08:00:00.000Z
                updatedAt: 2026-06-12T09:41:00.000Z
                economicOperator:
                  "@type": EconomicOperator
                  id: 4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40
                  name: Voltaic Cells Manufacturing GmbH
                  regId: EU-DEFAULT-001
                  role: MANUFACTURER
                manufacturingFacility: null
                metadata:
                  category: iron-steel
                  grai: 09501101530003CRATE001
                  originCountry: DE
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                category: iron-steel
                grai: 09501101530003CRATE001
                originCountry: DE
                facilityDetails: "[REDACTED - Privileged Access Required]"
            application/aas+json:
              schema:
                $ref: "#/components/schemas/AasEnvironment"
              example:
                assetAdministrationShells:
                  - id: urn:opendpp:aas:3c7e1f52-8d4a-4b9e-a6c0-5f2d8e1b7a93
                    idShort: AAS_09501101530003CRATE001
                    assetInformation:
                      assetKind: Instance
                      globalAssetId: urn:opendpp:asset:4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40:09501101530003CRATE001
                submodels:
                  - id: urn:opendpp:submodel:general:3c7e1f52-8d4a-4b9e-a6c0-5f2d8e1b7a93
                    idShort: GeneralProductInformation
                  - id: urn:opendpp:submodel:compliance
                    idShort: ComplianceMetadata
                conceptDescriptions: []
            application/vc+jwt:
              schema:
                type: string
                description: A UNTP DigitalProductPassport credential as an enveloping JOSE
                  proof — a compact JWS with protected header `{alg:ES256,
                  typ:vc+jwt, kid:<issuerDid>#key-0}`. PUBLIC tier only
                  (privileged metadata never enters the VC). The issuer is the
                  tenant's `did:web`; resolve `GET /tenants/{tenantId}/did.json`
                  for the verification key. Revocation is the W3C Bitstring
                  Status List referenced by the credential's `credentialStatus`
                  (served at `GET /tenants/{tenantId}/status/revocation`).
                  Returned only when the passport has a manufacturing facility
                  with a country of production — otherwise `406 Not Acceptable`.
              example: eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCIsImtpZCI6ImRpZDp3ZWI6...
            application/vc+ld+json:
              schema:
                type: object
                description: The same UNTP DigitalProductPassport credential with an EMBEDDED
                  W3C Data Integrity proof (`proof.type` `DataIntegrityProof`,
                  `proof.cryptosuite` `ecdsa-jcs-2019`) — RFC 8785 JCS
                  canonicalization, multibase base58btc `proof.proofValue`,
                  signed by the tenant's `did:web` key
                  (`proof.verificationMethod` = `<issuerDid>#key-<n>`; resolve
                  `GET /tenants/{tenantId}/did.json`). PUBLIC tier; revocation
                  via `credentialStatus` (Bitstring Status List). Same `406 Not
                  Acceptable` when the passport has no manufacturing facility
                  with a country of production. The enveloping
                  `application/vc+jwt` is the primary, UNTP-mandated
                  representation; this is the optional embedded-proof
                  alternative for Data-Integrity verifiers.
            application/dc+sd-jwt:
              schema:
                type: string
                description: The same UNTP DigitalProductPassport credential as an **SD-JWT-VC**
                  (IETF SD-JWT-VC) — cryptographic selective disclosure. The
                  issuer serves the FULL SD-JWT, `<JWS>~<disclosure>~…` with
                  protected header `{alg:ES256, typ:dc+sd-jwt,
                  kid:<issuerDid>#key-<n>}` plus the SD-JWT-VC claims `iss` (the
                  issuer `did:web`) and `vct`
                  (`https://opendpp-node.eu/vct/digital-product-passport`). A
                  HOLDER may then present any SUBSET of the disclosable
                  `credentialSubject` claims (`materialProvenance` /
                  `performanceClaim` / `characteristics` / `producedAtFacility`
                  / `countryOfProduction`) by dropping disclosures, and it still
                  verifies against the issuer signature — withheld claims
                  survive only as opaque `_sd` digests. Structural identity
                  (`id`/`type`/`productCategory`/`vct`/`iss`) is always in
                  clear; revocation (`credentialStatus`, Bitstring Status List)
                  is never disclosable. PUBLIC tier; the issuer is the tenant's
                  `did:web` (resolve `GET /tenants/{tenantId}/did.json`). Same
                  `406 Not Acceptable` when the passport has no manufacturing
                  facility with a country of production. The legacy
                  `application/vc+sd-jwt` media type is also accepted on the
                  request `Accept` header.
              example: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCJ9.eyJpc3MiOiJkaWQ6d2ViOi4uLiIsInZjdCI6Li4ufQ.sig~WyJzYWx0MCIsImNvdW50cnlPZlByb2R1Y3Rpb24iLHsiY291bnRyeUNvZGUiOiJERSJ9XQ~
            text/html:
              schema:
                type: string
                description: Server-rendered passport page.
        "400":
          description: Invalid GRAI (format / check digit) or — without tenant scope and
            AI-21 serial — an ambiguous lookup. Bodies omit the `success` field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalidGrai:
                  summary: Format / check-digit failure
                  value:
                    error: Bad Request
                    message: 'Invalid GS1 GRAI format. Standard GS1 Digital Link resolution under AI
                      8003 requires a 14-digit asset identifier (with a valid
                      modulo-10 check digit) followed by an optional
                      alphanumeric serial component of up to 16 characters. Got:
                      "0950110153000"'
                ambiguous:
                  summary: Multiple passports match (no tenant scope)
                  value:
                    error: Bad Request
                    message: GS1 Digital Link lookup on /8003/:productId is ambiguous. Multiple
                      passports match this identifier. A brand-specific
                      subdomain (e.g., brand.opendpp.eu) or a ?subdomain=...
                      query parameter is required.
        "404":
          description: "No passport matches (content-negotiated HTML/JSON, `Vary:
            Accept`), DRAFT hidden from non-owner, or unknown tenant subdomain
            (JSON only). Bodies omit the `success` field."
          headers:
            Vary:
              schema:
                type: string
              description: "`Accept` — set on the negotiated not-found variant."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Not Found
                message: "No Digital Product Passport found matching identifier:
                  09501101530003CRATE001"
            text/html:
              schema:
                type: string
                description: SSR not-found page.
        "406":
          $ref: "#/components/responses/NotAcceptable"
        "429":
          $ref: "#/components/responses/PublicRateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /unit/{id}:
    get:
      operationId: resolvePublicBatteryUnit
      tags:
        - Public Resolution
      summary: Resolve an individual serialised battery unit
      description: >-
        Public, content-negotiated view of one individual serialised unit
        (battery) by its unit UUID, including the embedded SKU/type passport
        (`ofModel`, masked by the same tier rules as `GET /passport/{id}`).


        **Content negotiation:** `Accept` containing `application/vc+jwt` (or
        bare `vc+jwt`) → a signed PER-UNIT (item-granularity) UNTP
        DigitalProductPassport credential (public tier; `406 Not Acceptable`
        when the unit's type passport has no manufacturing facility with a
        country of production); `application/vc+ld+json` (or bare `vc+ld+json`)
        → the same per-unit credential with an embedded `ecdsa-jcs-2019` Data
        Integrity proof, same `406`; `text/html` → server-rendered unit page;
        everything else → JSON-LD (`application/ld+json`). No AAS representation
        on this route. `Vary: Accept` always set on the 200. The `410` tombstone
        check (below) precedes content negotiation, so a recycled/ceased unit
        never yields a `vc+jwt` or `vc+ld+json`.


        **Per-unit telemetry is never public** (Annex XIII(2)-(4)): anonymous
        responses omit `currentState`/`dynamicData` and instead carry a
        `restrictedData` notice with a `/request-access` pointer. An owner
        credential — a tenant **API key** (`op_dpp_token_…`) of the owning or
        operator-bound tenant, sent as a Bearer token (a Console JWT login
        session does **not** unlock owner tier) — or a valid grant token
        (`dpp_li_…`/`dpp_auth_…` as Bearer or `?grant=`; TENANT, PASSPORT or
        UNIT scope) unlocks `currentState` and `dynamicData` — up to the 500
        most recent events, newest first. Invalid credentials silently degrade
        to the public tier (never 401/402/403). Grant-unlocked responses add
        `Cache-Control: private, no-store` + `Referrer-Policy: no-referrer`. No
        permission string (public endpoint).


        **Tombstone:** once the unit's status is `RECYCLED` (or `ceasedAt` is
        set) this URL answers `410 Gone` with a minimal tombstone for everyone —
        grants and owner credentials do NOT override it (the owning tenant
        retains internal access via `GET /api/v1/units/{id}`).


        Every resolution is access-audit-logged with an anonymized IP. **Rate
        limit:** 30 requests/min/IP (two-field 429 body without `success`). The
        limiter adds no headers of its own — `x-ratelimit-*` headers come from
        the global platform limit, which applies on top.
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: The battery unit's server-assigned UUID (AI-21 serial resolution
            via `GET /01/{gtin14}/21/{serial}` redirects here).
        - name: grant
          in: query
          required: false
          schema:
            type: string
            pattern: ^dpp_(li|auth)_[A-Za-z0-9]{20,64}$
          description: "Capability grant token (`dpp_li_…` / `dpp_auth_…`); equivalent to
            `Authorization: Bearer`. Minted tokens are the prefix + 32 hex
            characters; the server matches any prefixed token against stored
            hashes. Treat as a secret."
      responses:
        "200":
          description: The unit document in the negotiated representation. Telemetry keys
            (`currentState`, `dynamicData`) only for owner/grant tiers;
            `restrictedData` notice for the anonymous public.
          headers:
            Vary:
              schema:
                type: string
              description: Always `Accept`.
            Cache-Control:
              schema:
                type: string
              description: "`private, no-store` — only when a grant token unlocked the
                response."
            Referrer-Policy:
              schema:
                type: string
              description: "`no-referrer` — only when a grant token unlocked the response."
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/PublicBatteryUnitJsonLd"
              example:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - BatteryUnit: https://opendpp-node.eu/ns/dpp#BatteryUnit
                    serialNumber: https://opendpp-node.eu/ns/dpp#serialNumber
                    ofModel: https://opendpp-node.eu/ns/dpp#ofModel
                    currentState: https://opendpp-node.eu/ns/dpp#currentState
                    dynamicData: https://opendpp-node.eu/ns/dpp#dynamicData
                    stateOfHealth: https://opendpp-node.eu/ns/dpp#stateOfHealth
                    cycleCount: https://opendpp-node.eu/ns/dpp#cycleCount
                    restrictedData: https://opendpp-node.eu/ns/dpp#restrictedData
                    repurposedFrom: https://opendpp-node.eu/ns/dpp#repurposedFrom
                    successorUnits: https://opendpp-node.eu/ns/dpp#successorUnits
                "@type": BatteryUnit
                "@id": https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451
                id: 5d8e2c41-7b9a-4e3f-8c2d-6a1f0b9e4d72
                serialNumber: BATT-2026-000451
                digitalLinkUri: https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451
                status: IN_SERVICE
                manufacturedAt: 2026-01-15T08:00:00.000Z
                repurposedFrom: null
                successorUnits: []
                ofModel:
                  "@context":
                    - https://opendpp-node.eu/contexts/dpp/v1
                    - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                      economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                      manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                      metadata: https://opendpp-node.eu/ns/dpp#metadata
                      digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                      signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                      status: https://opendpp-node.eu/ns/dpp#status
                      archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                      retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                      category: https://opendpp-node.eu/contexts/dpp/v1#category
                      gtin: https://opendpp-node.eu/contexts/dpp/v1#gtin
                      originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                      detailedPerformance: https://opendpp-node.eu/contexts/dpp/v1#detailedPerformance
                      facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                  "@type": DigitalProductPassport
                  "@id": https://opendpp-node.eu/01/09501101530003
                  id: 9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                  productId: "09501101530003"
                  digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                  digitalSeal: null
                  signingPublicKey: null
                  status: ACTIVE
                  archivedAt: null
                  retentionUntil: null
                  proof: null
                  createdAt: 2026-06-01T08:00:00.000Z
                  updatedAt: 2026-06-12T09:41:00.000Z
                  economicOperator:
                    "@type": EconomicOperator
                    id: 4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40
                    name: Voltaic Cells Manufacturing GmbH
                    regId: EU-DEFAULT-001
                    role: MANUFACTURER
                  manufacturingFacility:
                    "@type": Facility
                    id: 7c1e5a2d-9b4f-4c8e-a3d6-2f8b0e4c9a71
                    gln: "0950110153007"
                    name: Voltaic Gigafactory Brandenburg
                    activity: cell assembly
                    country: DE
                  metadata:
                    category: batteries
                    gtin: "09501101530003"
                    originCountry: DE
                    detailedPerformance: "[REDACTED - Privileged Access Required]"
                    facilityDetails: "[REDACTED - Privileged Access Required]"
                  category: batteries
                  gtin: "09501101530003"
                  originCountry: DE
                  detailedPerformance: "[REDACTED - Privileged Access Required]"
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                restrictedData:
                  reason: LEGITIMATE_INTEREST_REQUIRED
                  reference: Regulation (EU) 2023/1542, Annex XIII(2)-(4)
                  description: Per-unit dynamic data (state of health, cycle counts, negative
                    events, temperature) is accessible only to persons with a
                    legitimate interest and to authorities.
                  howToRequest: /request-access?unit=5d8e2c41-7b9a-4e3f-8c2d-6a1f0b9e4d72
                createdAt: 2026-01-15T09:00:00.000Z
                updatedAt: 2026-06-12T09:41:00.000Z
            text/html:
              schema:
                type: string
                description: Server-rendered unit page (telemetry sections only for owner/grant
                  tiers).
            application/vc+jwt:
              schema:
                type: string
                description: A per-UNIT (item-granularity) UNTP DigitalProductPassport
                  credential as an enveloping JOSE proof — a compact JWS with
                  protected header `{alg:ES256, typ:vc+jwt,
                  kid:<issuerDid>#key-<n>}`. `credentialSubject.idGranularity`
                  is `"item"`, with the real GS1 AI-21 serial as `itemNumber`
                  and a link back to the SKU/type passport credential. PUBLIC
                  tier only (per-unit telemetry never enters the VC). The issuer
                  is the tenant's `did:web` (resolve `GET
                  /tenants/{tenantId}/did.json`); revocation is the W3C
                  Bitstring Status List referenced by `credentialStatus` (`GET
                  /tenants/{tenantId}/status/revocation`) — the unit has its own
                  bit, distinct from the type passport's. Returned only when the
                  unit's type passport has a manufacturing facility with a
                  country of production — otherwise `406 Not Acceptable`. A
                  RECYCLED/ceased unit answers `410 Gone` (the tombstone
                  precedes this branch), never a credential.
            application/vc+ld+json:
              schema:
                type: object
                description: The per-UNIT credential (item granularity) with an EMBEDDED W3C
                  Data Integrity proof (`proof.cryptosuite` `ecdsa-jcs-2019`,
                  RFC 8785 JCS, multibase base58btc `proofValue`) instead of the
                  enveloping JWS — `did:web`-signed, with the same
                  `idGranularity:"item"` / GS1 AI-21 `itemNumber` /
                  type-passport link / per-unit Bitstring Status List bit, and
                  the same `406` (no facility/country) and `410` (recycled)
                  conditions as the `vc+jwt` form.
            application/dc+sd-jwt:
              schema:
                type: string
                description: 'The per-UNIT credential (item granularity) as an **SD-JWT-VC** for
                  cryptographic selective disclosure (`<JWS>~<disclosure>~…`,
                  `typ: dc+sd-jwt`; legacy `vc+sd-jwt` accepted) — a holder may
                  present a subset of the disclosable `credentialSubject` claims
                  while the issuer signature stays intact. Same
                  `idGranularity:"item"` / GS1 AI-21 `itemNumber` /
                  type-passport link / per-unit Bitstring Status List bit, and
                  the same `406` (no facility/country) and `410` (recycled)
                  conditions as the `vc+jwt` form (parity with `GET
                  /passport/{id}`).'
        "404":
          description: "No unit with that id (a malformed UUID also resolves to this 404).
            Content-negotiated: HTML page for `Accept: text/html`, JSON
            otherwise; `Vary: Accept` set. Body omits the `success` field. A
            request on an unknown tenant workspace host receives the
            platform-level JSON 404 (`No tenant company found for subdomain: …`)
            before this handler runs."
          headers:
            Vary:
              schema:
                type: string
              description: "`Accept`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Not Found
                message: "No serialised unit found matching identifier:
                  5d8e2c41-7b9a-4e3f-8c2d-6a1f0b9e4d72"
            text/html:
              schema:
                type: string
                description: SSR not-found page.
        "406":
          $ref: "#/components/responses/NotAcceptable"
        "410":
          description: "Gone — the unit was RECYCLED (or `ceasedAt` is set): the battery
            passport has ceased to exist. A minimal tombstone is returned to
            everyone; grants and owner credentials do not override it.
            Content-negotiated: HTML tombstone for `Accept: text/html`, JSON-LD
            otherwise."
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/BatteryUnitTombstoneJsonLd"
              example:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - BatteryUnit: https://opendpp-node.eu/ns/dpp#BatteryUnit
                    serialNumber: https://opendpp-node.eu/ns/dpp#serialNumber
                    ceasedAt: https://opendpp-node.eu/ns/dpp#ceasedAt
                "@type": BatteryUnit
                "@id": https://opendpp-node.eu/01/09501101530003/21/BATT-2024-000118
                id: 1f4c8a92-6e3d-4b7a-9d5c-8e2a0f6b3c14
                serialNumber: BATT-2024-000118
                status: RECYCLED
                ceasedAt: 2026-03-01T10:00:00.000Z
                notice: This battery has been recycled. Its battery passport has ceased to exist
                  (Regulation (EU) 2023/1542, Art. 77(8)).
                ofModelUrl: /passport/9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
            text/html:
              schema:
                type: string
                description: SSR tombstone page.
        "429":
          $ref: "#/components/responses/PublicRateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /.well-known/opendpp-seal-ca.pem:
    get:
      operationId: getSealCaCertificate
      tags:
        - eIDAS Keys
      summary: Download the platform seal-CA certificate (PEM)
      description: >-
        Downloads the platform seal-CA certificate as PEM. Third parties pin
        this CA to validate the `x5c` certificate chains embedded in
        sealed-passport `proof` blocks — the chain's leaf certificate binds a
        tenant's signing key to its legal identity (the advanced-seal creator
        identification; the seal is an *advanced*, not qualified, electronic
        seal). The certificate is provisioned server-side on first use.


        No authentication, no permission (public endpoint). Successful responses
        carry `Cache-Control: public, max-age=3600`. Like every documented path
        except `/health`, a request on an unknown tenant workspace host receives
        a platform-level JSON 404 before this handler runs.


        **Rate limit:** 30 requests/min/IP — note that this route's 429 body
        carries ONLY `{"error": "Too Many Requests"}` (no `message`, no
        `success`), unlike the other public resolvers. The global platform limit
        (100 req/min/IP) applies on top: a global-limit 429 carries the
        platform's default `{statusCode, error, message}` body instead, and the
        global limiter's `x-ratelimit-*` headers appear on every response from
        this route. Returns `503` if the seal CA cannot be provisioned or
        loaded.
      security: []
      responses:
        "200":
          description: The CA certificate, PEM-encoded.
          headers:
            Cache-Control:
              schema:
                type: string
              description: "`public, max-age=3600`."
          content:
            application/x-pem-file:
              schema:
                type: string
                description: X.509 CA certificate in PEM encoding.
              example: |
                -----BEGIN CERTIFICATE-----
                MIIBszCCAVmgAwIBAgIUQ2FDZXJ0RXhhbXBsZURhdGFCYXNlNjQwCgYIKoZIzj0E
                AwIwIDEeMBwGA1UEAwwVT3BlbkRQUCBTZWFsIENBIChEZW1vKQ==
                -----END CERTIFICATE-----
        "429":
          description: Rate limited (30/min/IP). This route's 429 body has ONLY the
            `error` field — no `message`, no `success`. (Any `x-ratelimit-*`
            headers on the response belong to the global platform limiter.)
          content:
            application/json:
              schema:
                type: object
                required:
                  - error
                properties:
                  error:
                    type: string
                    const: Too Many Requests
              example:
                error: Too Many Requests
        "503":
          description: Seal CA not available (provisioning/load failure). Body omits the
            `success` field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Service Unavailable
                message: Seal CA not available
  /api/v1/schemas/{category}:
    get:
      operationId: getSectorSchema
      tags:
        - Schemas & Vocabulary
      summary: Get the ESPR metadata schema for a product category
      description: >-
        Returns the machine-readable ESPR `metadata` schema for a product
        category.


        **Default representation** (any `Accept` NOT containing
        `application/ld+json`): the category's JSON Schema **draft-07**
        document, served as `application/schema+json`, with each known field
        annotated server-side with a plain-English `description` (the
        annotations are AJV-ignored — validation behavior is unchanged). **With
        `Accept: application/ld+json`:** a small JSON-LD `@context` for the
        category vocabulary instead. Note: the route does not set `Vary:
        Accept`.


        The category path segment is lower-cased before lookup. Machine-readable
        schemas exist for **5** of the 9 ESPR categories: `textiles`,
        `batteries`, `electronics`, `chemicals`, `construction`. The remaining 4
        categories accepted by passport metadata validation (`cosmetics`,
        `toys`, `iron-steel`, `aluminium`) are validated by built-in server
        rules and currently return `404` from this endpoint.


        No authentication, no permission (public endpoint). No custom rate
        limiter — only the global platform limit applies (100 req/min/IP,
        standard `x-ratelimit-*` headers). Like every documented path except
        `/health`, a request on an unknown tenant workspace host receives a
        platform-level JSON 404 (`No tenant company found for subdomain: …`, no
        `success` field) before this handler runs.
      security: []
      parameters:
        - name: category
          in: path
          required: true
          schema:
            type: string
            enum:
              - textiles
              - batteries
              - electronics
              - chemicals
              - construction
              - cosmetics
              - toys
              - iron-steel
              - aluminium
          description: ESPR product category (case-insensitive). Only `textiles`,
            `batteries`, `electronics`, `chemicals` and `construction` have
            published JSON Schemas; `cosmetics`, `toys`, `iron-steel` and
            `aluminium` return 404 here (their validation rules are built into
            the server).
      responses:
        "200":
          description: "The category schema (default) or its JSON-LD vocabulary context
            (`Accept: application/ld+json`)."
          content:
            application/schema+json:
              schema:
                $ref: "#/components/schemas/SectorJsonSchemaDocument"
              example:
                $schema: http://json-schema.org/draft-07/schema#
                title: Chemicals compliance schema
                type: object
                required:
                  - category
                  - materialComposition
                  - originCountry
                  - facilityDetails
                  - regulatoryCompliance
                  - hazardClassification
                  - safetyDatasheetUrl
                  - presenceOfSVHC
                properties:
                  category:
                    type: string
                    enum:
                      - chemicals
                    description: "Product sector for this passport: one of textiles, batteries,
                      electronics, chemicals or construction. Must match the
                      chosen schema."
                  materialComposition:
                    type: array
                    items:
                      type: object
                      required:
                        - material
                        - percentage
                      properties:
                        material:
                          type: string
                          minLength: 1
                          description: Name of one material in the product, e.g. 'cotton', 'aluminium',
                            'PET'. Free text, at least one character.
                        percentage:
                          type: number
                          minimum: 0.01
                          maximum: 100
                          description: Share of this item as a percentage (0.01-100). Material/fibre
                            percentages should add up to 100 for the whole
                            product.
                    description: List the materials in the product, each with its name and weight
                      percentage; required under ESPR for transparency and
                      recycling.
                  originCountry:
                    type: string
                    pattern: ^[A-Z]{2}$
                    description: Country where the product was made or assembled. Two-letter ISO
                      3166 code in capitals, e.g. DE, FR, CN.
                  facilityDetails:
                    type: array
                    items:
                      type: object
                      required:
                        - facilityName
                        - location
                        - activity
                      properties:
                        facilityName:
                          type: string
                          minLength: 1
                          description: Name of the supply-chain facility or company at this site, e.g.
                            'Acme Weaving Mill'.
                        location:
                          type: string
                          minLength: 1
                          description: Where the facility is, e.g. city and country or full address; used
                            for supply-chain traceability.
                        activity:
                          type: string
                          minLength: 1
                          description: What this facility does in the chain, e.g. 'spinning', 'assembly',
                            'final manufacturing'.
                        eori:
                          type: string
                          description: Optional EU EORI number identifying the operator for customs, e.g.
                            DE123456789012345. Leave blank if none.
                        eudrPlots:
                          type: array
                          description: Land plots where deforestation-risk commodities were produced, with
                            geolocation; required by EUDR to prove
                            deforestation-free sourcing.
                    description: List of sites in the supply chain (factory, processor, etc.), each
                      with name, location and activity; supports traceability
                      duties.
                  regulatoryCompliance:
                    type: object
                    required:
                      - ceMarking
                      - certificates
                    properties:
                      ceMarking:
                        type: boolean
                        description: "True/false: does the product carry CE marking declaring conformity
                          with applicable EU product law?"
                      certificates:
                        type: array
                        items:
                          type: object
                          required:
                            - name
                            - referenceNumber
                            - issuer
                          properties:
                            name:
                              type: string
                              minLength: 1
                              description: Name of the certificate or standard, e.g. 'OEKO-TEX Standard 100'
                                or 'ISO 9001'.
                            referenceNumber:
                              type: string
                              minLength: 1
                              description: The certificate's official number or ID so it can be looked up with
                                the issuer.
                            issuer:
                              type: string
                              minLength: 1
                              description: Organisation that issued the certificate, e.g. the notified body or
                                testing lab.
                            validUntil:
                              type: string
                              description: Expiry date of the certificate (e.g. 2026-12-31). Leave blank if it
                                does not expire.
                        description: List of certificates or approvals held for the product, each with a
                          name, reference number and issuer.
                    description: "Block confirming the product meets EU rules: CE marking status and
                      any certificates held."
                  hazardClassification:
                    type: array
                    items:
                      type: string
                      minLength: 1
                    description: List of CLP/GHS hazard classes or H-statements for the chemical,
                      e.g. 'Flammable liquid', 'H315'.
                  safetyDatasheetUrl:
                    type: string
                    format: uri
                    description: Web link (https) to the chemical's Safety Data Sheet (SDS),
                      required under REACH/CLP.
                  presenceOfSVHC:
                    type: boolean
                    description: "True/false: does the product contain Substances of Very High
                      Concern above the REACH notification threshold?"
            application/ld+json:
              schema:
                $ref: "#/components/schemas/SectorVocabularyContext"
              example:
                "@context":
                  "@vocab": https://w3id.org/opendpp/schemas/chemicals#
                  id: "@id"
                  type: "@type"
                  category: https://w3id.org/opendpp#category
                  materialComposition: https://w3id.org/opendpp#materialComposition
                  originCountry: https://w3id.org/opendpp#originCountry
                  facilityDetails: https://w3id.org/opendpp#facilityDetails
                  regulatoryCompliance: https://opendpp.org/compliance#regulatoryCompliance
        "404":
          $ref: "#/components/responses/NotFound"
          description: 'Unknown category, or one of the 4 schema-less ESPR categories
            (`cosmetics`, `toys`, `iron-steel`, `aluminium`). Body: `{"success":
            false, "error": "Not Found", "message": "Schema not found for
            category: <category>"}`.'
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /contexts/dpp/v1:
    get:
      operationId: getDppJsonLdContext
      tags:
        - Schemas & Vocabulary
      summary: Canonical resolvable JSON-LD context for passport & unit documents
      description: >-
        Serves the stable, resolvable W3C JSON-LD `@context`
        (`application/ld+json`) that **every** public passport and battery-unit
        JSON-LD document references in its `@context` array — this is the
        context to dereference when expanding OpenDPP JSON-LD. It declares
        `@vocab: https://opendpp-node.eu/ns/dpp#` (so even dynamic metadata keys
        expand under the OpenDPP namespace and are never silently dropped by a
        strict JSON-LD processor) plus explicit term mappings for the core
        DPP/unit vocabulary (`DigitalProductPassport`, `BatteryUnit`,
        `economicOperator`, `manufacturingFacility`, `metadata`, `digitalSeal`,
        `signingPublicKey`, `proof`, `status`, `MerkleTreeAttestationProof`).
        Cacheable (`Cache-Control: public, max-age=86400`).


        (The separate `/context/v1` serves an older, fixed term-only list and is
        **not** the context emitted documents point to.)


        No authentication, no permission (public endpoint). No custom rate
        limiter — only the global platform limit applies (100 req/min/IP,
        standard `x-ratelimit-*` headers).
      security: []
      responses:
        "200":
          description: The canonical `@vocab`-based JSON-LD context document (fixed
            content).
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/DppVocabContextDocument"
              example:
                "@context":
                  "@version": 1.1
                  "@vocab": https://opendpp-node.eu/ns/dpp#
                  dpp: https://opendpp-node.eu/ns/dpp#
                  DigitalProductPassport: dpp:DigitalProductPassport
                  BatteryUnit: dpp:BatteryUnit
                  MerkleTreeAttestationProof: dpp:MerkleTreeAttestationProof
                  economicOperator: dpp:economicOperator
                  manufacturingFacility: dpp:manufacturingFacility
                  metadata: dpp:metadata
                  digitalSeal: dpp:digitalSeal
                  signingPublicKey: dpp:signingPublicKey
                  proof: dpp:proof
                  status: dpp:status
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /context/v1:
    get:
      operationId: getJsonLdContext
      tags:
        - Schemas & Vocabulary
      summary: W3C JSON-LD context document for passport terms (secondary, fixed term
        list)
      description: >-
        Serves a static W3C JSON-LD `@context` document (`application/ld+json`)
        for the core Digital Product Passport term vocabulary: maps the DPP
        terms to `https://opendpp-node.eu/ns/dpp#…` IRIs and
        `createdAt`/`updatedAt` to schema.org `dateCreated`/`dateModified`. This
        is a **secondary** fixed term list — the context that public
        passport/unit JSON-LD documents actually reference is the `@vocab`-based
        one at `GET /contexts/dpp/v1`; dereference that when expanding OpenDPP
        JSON-LD.


        No authentication, no permission (public endpoint). No custom rate
        limiter — only the global platform limit applies (100 req/min/IP,
        standard `x-ratelimit-*` headers). Like every documented path except
        `/health`, a request on an unknown tenant workspace host receives a
        platform-level JSON 404 before this handler runs.
      security: []
      responses:
        "200":
          description: The JSON-LD context document (fixed content).
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/DppJsonLdContextDocument"
              example:
                "@context":
                  DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                  economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                  metadata: https://opendpp-node.eu/ns/dpp#metadata
                  digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                  signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                  proof: https://opendpp-node.eu/ns/dpp#proof
                  createdAt: https://schema.org/dateCreated
                  updatedAt: https://schema.org/dateModified
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /health:
    get:
      operationId: getHealth
      tags:
        - Service
      summary: Service health check
      description: Liveness probe. Always returns 200 with the service identity, the
        current server time (ISO 8601 UTC with milliseconds), and the running
        build identity (`apiVersion`/`commit`/`builtAt` — the same fields as
        `GET /api/v1/version`). No authentication, no permission. **Exempt from
        the platform rate limit** so a monitor or uptime check can poll it
        freely — it carries no `x-ratelimit-*` headers. This is the one path
        exempt from tenant-subdomain resolution — it answers 200 on any host.
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthStatus"
              example:
                status: OK
                service: OpenDPP B2B Enterprise Engine
                timestamp: 2026-06-12T09:41:00.000Z
                apiVersion: 1.0.0
                commit: a7a96d0
                builtAt: 2026-06-12T09:30:00Z
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/version:
    get:
      operationId: getApiVersion
      tags:
        - Service
      summary: Running API contract version & build identity
      description: Returns the SemVer of the public API contract currently served
        (`apiVersion`), plus the source build identity (`commit`, `builtAt`).
        The contract's MAJOR equals the `/api/v1` URL major; a breaking change
        ships as a new `/api/v1`-style major (`/api/v2`), never as an edit to
        this contract — so a stable `apiVersion` major is a safe thing for an
        integration or a generated SDK to pin to. `commit`/`builtAt` read
        `"unknown"` when a build did not inject them. No authentication, no
        permission; subject only to the global platform rate limit (100
        req/min/IP anonymous, higher for authenticated callers; standard
        `x-ratelimit-*` headers on responses).
      security: []
      responses:
        "200":
          description: The running API contract version and build identity.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceVersion"
              example:
                apiVersion: 1.0.0
                commit: a7a96d0
                builtAt: 2026-06-12T09:30:00Z
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/gs1/gtin:
    post:
      operationId: mintGtinCheckDigit
      tags:
        - Public Resolution
      summary: Compute a GTIN check digit from a company prefix + item reference
      description: >-
        The actionable counterpart to the non-GS1 ingest advisory: given the
        **GS1 company prefix your organisation legally owns** plus an item
        reference, OpenDPP computes the GS1 **mod-10 check digit** and returns
        the resulting 14-digit GTIN + a Digital Link preview. Set the GTIN as a
        passport `productId` to get a scannable GS1 Digital Link.


        **It ONLY completes the check digit** — it never allocates a GS1 company
        prefix or asserts ownership (a real GTIN requires a prefix licensed to
        you by GS1). `gs1CompanyPrefix` is REQUIRED; a request with none is
        refused (**400**). `gs1CompanyPrefix + itemRef` must be exactly **13
        digits** (the check digit forms the 14th) and both must be digit
        strings, else **400**.


        Public + stateless (pure arithmetic; no tenant data). No authentication
        required.


        **Rate limit (changed in 1.12.0):** an **anonymous** caller gets **2
        requests/min per IP** — a per-route cap that replaces the global
        ceiling. Send an `Authorization` header (any valid API key) and the
        normal ladder applies instead: the global authenticated ceiling, with
        your per-key tier bucket underneath it. Standard `x-ratelimit-*`
        headers; **429** with `retry-after` when exceeded.
      security:
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - gs1CompanyPrefix
              properties:
                gs1CompanyPrefix:
                  type: string
                  description: The GS1 company prefix your organisation legally owns (digits only,
                    typically 7-10 digits).
                itemRef:
                  type: string
                  description: The item reference (digits only) that, appended to the prefix,
                    makes exactly 13 digits.
            example:
              gs1CompanyPrefix: "0950110153"
              itemRef: "000"
      responses:
        "200":
          description: The computed GTIN + Digital Link preview.
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - gtin
                  - gs1CompanyPrefix
                  - itemRef
                  - checkDigit
                  - digitalLink
                  - note
                properties:
                  success:
                    type: boolean
                  gtin:
                    type: string
                    description: The 14-digit GTIN (`prefix + itemRef + check digit`).
                  gs1CompanyPrefix:
                    type: string
                  itemRef:
                    type: string
                  checkDigit:
                    type: string
                    description: The computed GS1 mod-10 check digit (the 14th digit).
                  digitalLink:
                    type: string
                    format: uri
                    description: A Digital Link preview `https://opendpp-node.eu/01/{gtin}` —
                      resolvable once a passport uses this GTIN as its
                      `productId`.
                  note:
                    type: string
                    description: "The ownership caveat: OpenDPP computes only the check digit and
                      never allocates a prefix or asserts ownership."
              example:
                success: true
                gtin: "09501101530003"
                gs1CompanyPrefix: "0950110153"
                itemRef: "000"
                checkDigit: "3"
                digitalLink: https://opendpp-node.eu/01/09501101530003
                note: OpenDPP computed the GS1 mod-10 check digit for the 13-digit body you
                  supplied. Use this GTIN ONLY if your organisation legally owns
                  this GS1 company prefix — OpenDPP does not allocate prefixes
                  or assert ownership. Set it as the passport productId to get a
                  scannable GS1 Digital Link.
        "400":
          description: '`gs1CompanyPrefix` missing (we never fabricate a GTIN), non-digit
            input, or `prefix + itemRef` not exactly 13 digits. Body:
            `{success:false, error:"Bad Request", message}`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  error:
                    type: string
                  message:
                    type: string
  /api/v1/gs1/decode:
    post:
      operationId: decodeGs1
      tags:
        - Public Resolution
      summary: Decode GS1 scan data / element string / Digital Link into structured
        AIs + HRI
      description: >-
        Decodes raw scanner output — AIM-symbology-prefixed **scan data** (e.g.
        `]Q1https://id.gs1.org/01/09501101532007/21/VM-1`, `]C1010950…`), a
        bracketed GS1 **element string** (`(01)09501101532007(21)VM-1`), or a
        **Digital Link** URI — into its structured Application Identifiers, the
        Human-Readable Interpretation (HRI), and a Digital Link that resolves on
        this node. Parsing is performed by GS1's authoritative Barcode Syntax
        Engine (vendored WASM), so check digits and the AI grammar are
        validated, not approximated.


        **Public + stateless** — no permission and no tenant data is touched; it
        complements the public resolver. Supply **exactly one** of `scanData`,
        `elementString`, `digitalLink`; zero or more than one returns 400. After
        decoding, `GET` the returned `digitalLinkUri` (the canonical path
        rehosted on this node) to resolve the passport/unit.


        **Errors:** missing/multiple/over-long input, or a value GS1's grammar
        rejects, returns **400** (`Provide exactly one of: scanData,
        elementString, digitalLink` or `Not a valid GS1 <kind>: <engine
        message>`); **503** if the engine is unavailable.


        **Rate limit (changed in 1.12.0):** an **anonymous** caller gets **2
        requests/min per IP** — a per-route cap that replaces the global
        ceiling, so this endpoint stays a convenience for integrators rather
        than a free scripted service. Send an `Authorization` header (any valid
        API key) and the normal ladder applies instead: the global authenticated
        ceiling, with your per-key tier bucket underneath it. Standard
        `x-ratelimit-*` headers on both paths; **429** with `retry-after` when
        exceeded.
      security:
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Provide exactly one of the three input forms.
              properties:
                scanData:
                  type: string
                  maxLength: 4096
                  description: Raw scanner output including its AIM symbology identifier prefix
                    (e.g. `]Q1…` QR, `]d2…` DataMatrix, `]C1…` GS1-128).
                elementString:
                  type: string
                  maxLength: 4096
                  description: Bracketed GS1 AI element string, e.g. `(01)09501101532007(21)VM-1`.
                digitalLink:
                  type: string
                  maxLength: 4096
                  description: A GS1 Digital Link URI (http/https).
              additionalProperties: false
            examples:
              scanData:
                summary: Scanner output (QR, AIM prefix ]Q1)
                value:
                  scanData: "]Q1https://id.gs1.org/01/09501101532007/21/VM-LFP100-26-1"
              elementString:
                summary: Bracketed element string
                value:
                  elementString: (01)09501101532007(21)VM-LFP100-26-1
              digitalLink:
                summary: Digital Link URI
                value:
                  digitalLink: https://id.gs1.org/01/09501101532007
      responses:
        "200":
          description: Decoded GS1 data. `canonicalUpi` is the host-independent identity
            (`https://id.gs1.org/…`); `digitalLinkUri` is that same path
            rehosted on this node's resolver host (GET it to resolve).
            `elementString`/`canonicalUpi`/`digitalLinkUri` are null and `ai` is
            empty when the input is syntactically valid GS1 but carries no AI
            data.
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - input
                  - elementString
                  - hri
                  - canonicalUpi
                  - digitalLinkUri
                  - ai
                properties:
                  success:
                    type: boolean
                  input:
                    type: string
                    description: "Which input form was decoded: `scanData`, `elementString` or
                      `digitalLink`."
                  elementString:
                    type:
                      - string
                      - "null"
                    description: Bracketed AI element string, e.g. `(01)09501101532007(21)VM-1`.
                  hri:
                    type: array
                    items:
                      type: string
                    description: Human-Readable Interpretation lines, e.g. `["(01) 09501101532007",
                      "(21) VM-1"]`.
                  canonicalUpi:
                    type:
                      - string
                      - "null"
                    description: Host-independent canonical Digital Link (`https://id.gs1.org/…`).
                  digitalLinkUri:
                    type:
                      - string
                      - "null"
                    description: The canonical path rehosted on this node's resolver host — GET it
                      to resolve.
                  ai:
                    type: object
                    additionalProperties:
                      type: string
                    description: 'Application Identifier → value map, e.g. `{"01": "09501101532007",
                      "21": "VM-1"}`.'
              example:
                success: true
                input: scanData
                elementString: (01)09501101532007(21)VM-LFP100-26-1
                hri:
                  - (01) 09501101532007
                  - (21) VM-LFP100-26-1
                canonicalUpi: https://id.gs1.org/01/09501101532007/21/VM-LFP100-26-1
                digitalLinkUri: https://opendpp-node.eu/01/09501101532007/21/VM-LFP100-26-1
                ai:
                  "21": VM-LFP100-26-1
                  "01": "09501101532007"
        "400":
          description: "Missing/multiple inputs, over-long input, or a value GS1's grammar
            rejects. Messages: `Provide exactly one of: scanData, elementString,
            digitalLink`, `Input exceeds 4096 characters`, `Not a valid GS1
            <kind>: <engine message>`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Bad Request
                message: "Not a valid GS1 digitalLink: AI (01) value is too short"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: The GS1 Syntax Engine (WASM) could not be loaded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Service Unavailable
                message: GS1 Syntax Engine is unavailable
  /api/v1/gs1/decode/batch:
    post:
      operationId: decodeGs1Batch
      tags:
        - Public Resolution
      summary: Batch-decode many GS1 scans / element strings / Digital Links in one
        request
      description: >-
        Batch form of `POST /api/v1/gs1/decode` for line-side / warehouse
        stations capturing many scans per second. Send `{ "items": [ … ] }`
        (≤200), each item exactly one of
        `scanData`/`elementString`/`digitalLink`, and receive a `results` array
        aligned to input order — each entry either a decoded scan (`ok: true`,
        the same fields as the single-scan 200 minus `success`) or an error
        (`ok: false` + `error`). **Partial-success:** one bad item never fails
        the batch — the request returns **200** and per-item failures are
        reported in place. Parsing uses GS1's authoritative Barcode Syntax
        Engine (vendored WASM). **Public + stateless** (no permission, no tenant
        data).


        **Errors:** a missing/empty/non-array `items`, or more than 200 items,
        returns **400**; a body over the 256 KiB route cap returns **413**;
        **503** if the engine is unavailable.


        **Rate limit (changed in 1.12.0):** an **anonymous** caller gets **2
        requests/min per IP** — a per-route cap that replaces the global
        ceiling, so this endpoint stays a convenience for integrators rather
        than a free scripted service. Send an `Authorization` header (any valid
        API key) and the normal ladder applies instead: the global authenticated
        ceiling, with your per-key tier bucket underneath it. Standard
        `x-ratelimit-*` headers on both paths; **429** with `retry-after` when
        exceeded.
      security:
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - items
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 200
                  description: Up to 200 scans, each an object with exactly one of
                    scanData/elementString/digitalLink.
                  items:
                    type: object
                    properties:
                      scanData:
                        type: string
                        maxLength: 4096
                      elementString:
                        type: string
                        maxLength: 4096
                      digitalLink:
                        type: string
                        maxLength: 4096
                    additionalProperties: false
              additionalProperties: false
            example:
              items:
                - scanData: "]Q1https://id.gs1.org/01/09501101532007/21/VM-LFP100-26-1"
                - elementString: (01)09501101532007
                - digitalLink: not-a-valid-link
      responses:
        "200":
          description: Per-item decode results aligned to input order (partial-success —
            one bad item never fails the batch).
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - count
                  - results
                properties:
                  success:
                    type: boolean
                  count:
                    type: integer
                    description: Number of items decoded (== items.length).
                  results:
                    type: array
                    description: One entry per input item, in order.
                    items:
                      $ref: "#/components/schemas/Gs1BatchDecodeResult"
              example:
                success: true
                count: 3
                results:
                  - ok: true
                    input: scanData
                    elementString: (01)09501101532007(21)VM-LFP100-26-1
                    hri:
                      - (01) 09501101532007
                      - (21) VM-LFP100-26-1
                    canonicalUpi: https://id.gs1.org/01/09501101532007/21/VM-LFP100-26-1
                    digitalLinkUri: https://opendpp-node.eu/01/09501101532007/21/VM-LFP100-26-1
                    ai:
                      "21": VM-LFP100-26-1
                      "01": "09501101532007"
                  - ok: true
                    input: elementString
                    elementString: (01)09501101532007
                    hri:
                      - (01) 09501101532007
                    canonicalUpi: https://id.gs1.org/01/09501101532007
                    digitalLinkUri: https://opendpp-node.eu/01/09501101532007
                    ai:
                      "01": "09501101532007"
                  - ok: false
                    error: "Not a valid GS1 digitalLink: the URI is not a valid GS1 Digital Link"
        "400":
          description: "Missing/empty/non-array `items`, or more than 200 items. Messages:
            `Provide an 'items' array of scans to decode`, `'items' must be a
            non-empty array`, `'items' exceeds the 200-item batch limit`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Bad Request
                message: "'items' exceeds the 200-item batch limit"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: The GS1 Syntax Engine (WASM) could not be loaded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Service Unavailable
                message: GS1 Syntax Engine is unavailable
  /api/v1/passports/{id}/qr:
    get:
      operationId: getPassportQrCode
      tags:
        - QR Codes
      summary: Export a print-grade GS1 Digital Link QR code for a passport
      description: >-
        Renders the passport's GS1 Digital Link URI (its `digitalLinkUri`, e.g.
        `https://opendpp-node.eu/01/09501101530003`) as a print-grade QR code
        and returns it as a binary file download. The printed carrier resolves
        through the public GS1 gateway.


        **Permission:** `passport:read` (read-only — subscription status is
        **not** checked on `:read` permissions, so this endpoint never returns
        402). Works with a Bearer API key, a Bearer JWT, or an authenticated
        browser session — plain same-origin `<a href>` downloads are supported.


        **Identifier resolution:** `{id}` is matched first against the passport
        UUID, then against the caller-supplied `productId` (GTIN-14/GRAI/SKU),
        always scoped to your tenant. Credentials scoped to an Economic Operator
        receive **403** (`Your access is restricted to Economic Operator:
        <operatorId>`) when the passport belongs to a different operator.


        **QR rendering:** 4-module quiet zone (GS1 guidance); error-correction
        level per `ecl` (default `Q`, the GS1 recommendation for product
        labels); `size` is **clamped** to 128–2048 px — out-of-range values are
        clamped to the nearest bound, not rejected, and fractional values are
        truncated. The response carries `Content-Disposition: attachment;
        filename="qr-<productId>.png"` (or `.svg`); the filename base is the
        passport's `productId` with characters outside `[A-Za-z0-9._-]` replaced
        by `_`, truncated to 80 characters.


        **Errors:** an invalid query option returns **400** with one of these
        exact messages: `format must be png or svg`, `size must be a number`,
        `ecl must be M, Q or H`. An unknown passport returns **404** with
        message `Passport <id> not found under your Tenant workspace`.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Passport UUID, or the caller-supplied `productId`
            (GTIN-14/GRAI/SKU) as a fallback. Resolution is tenant-scoped.
          schema:
            type: string
          examples:
            byUuid:
              summary: Passport UUID
              value: 9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40
            byProductId:
              summary: GTIN-14 productId
              value: "09501101530003"
        - name: format
          in: query
          required: false
          description: Output image format. Case-insensitive; any other value returns 400
            (`format must be png or svg`).
          schema:
            type: string
            enum:
              - png
              - svg
            default: png
        - name: size
          in: query
          required: false
          description: Rendered width in pixels (PNG) / SVG width attribute. Clamped to
            128–2048 — out-of-range values are silently clamped, fractions
            truncated. A non-numeric value returns 400 (`size must be a
            number`). A PNG is never rendered below one pixel per module, so an
            unusually dense symbol comes back slightly wider than requested —
            see the 200 response.
          schema:
            type: integer
            minimum: 128
            maximum: 2048
            default: 1024
        - name: ecl
          in: query
          required: false
          description: "QR error-correction level: `M` (~15% recovery), `Q` (~25%, GS1
            product-label guidance, default) or `H` (~30%). Case-insensitive;
            any other value returns 400 (`ecl must be M, Q or H`). `L` is
            intentionally not offered."
          schema:
            type: string
            enum:
              - M
              - Q
              - H
            default: Q
        - name: hri
          in: query
          required: false
          description: When `1`/`true`, renders the GS1 Human-Readable Interpretation (the
            bracketed AI string, e.g. `(01) 09501101530003 (21)
            BAT-2026-000123`) as vector text beneath the QR symbol — the
            print-grade GS1 label form (machine-readable QR + the human-readable
            AI string). Requires `format=svg`; combining it with `format=png`
            returns 400 (`hri (Human-Readable Interpretation) labels require
            format=svg`).
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: "QR code image encoding the passport's `digitalLinkUri`. The
            returned media type follows the `format` query parameter (no
            `Accept`-header negotiation): `image/png` is a raw PNG bitmap,
            exactly `size` px wide — except when the symbol's own module grid
            (its modules plus both 4-module quiet zones) is wider than `size`,
            where it renders at that grid width instead (at most 185 px),
            because a raster cannot hold a symbol at less than one pixel per
            module; `image/svg+xml` is a vector SVG document, which has no such
            floor and always carries the requested width. Delivered as an
            attachment download."
          headers:
            Content-Disposition:
              description: '`attachment; filename="qr-<productId>.png"` or `...svg` — filename
                base sanitized to `[A-Za-z0-9._-]` (other characters become
                `_`), max 80 chars.'
              schema:
                type: string
          content:
            image/png: {}
            image/svg+xml:
              schema:
                type: string
                description: Vector SVG document (returned when `format=svg`).
        "400":
          description: "Invalid query option. Exact messages: `format must be png or svg`,
            `size must be a number`, `ecl must be M, Q or H`, `hri
            (Human-Readable Interpretation) labels require format=svg`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Bad Request
                message: ecl must be M, Q or H
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/passports/labels:
    post:
      operationId: bulkExportPassportLabels
      tags:
        - QR Codes
      summary: Bulk-export print-grade QR labels for many passports as a ZIP
      description: >-
        Renders a GS1 Digital Link QR code for each of the supplied passports
        and returns them as a single `application/zip` download
        (`Content-Disposition: attachment; filename="labels.zip"`) — the export
        counterpart to the bulk import. One image entry per resolved passport,
        named `<productId>.<png|svg>` (characters outside `[A-Za-z0-9._-]`
        replaced by `_`, truncated to 80 chars; duplicate names get a `-2`,
        `-3`, … suffix), plus a `manifest.json` listing what was `included` and
        `skipped`.


        **Permission:** `passport:read` (read-only — no subscription/402 gate,
        and NOT subject to the programmatic API-write entitlement).


        **Partial success:** an id that is unknown, not owned by your tenant, or
        outside an operator-scoped key's bound operator is **skipped and
        reported** in `manifest.json` (`{ id, reason }`) — it never fails the
        whole batch. Only the caller's own passports resolve, so this cannot
        enumerate another tenant's catalog.


        **Limits:** at most **200** ids per call (mirrors the bulk-import cap);
        more returns **400** pointing at the async export. `hri: true` requires
        `format: "svg"` (same constraint as the single QR). `size` is clamped to
        128–2048.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - ids
              properties:
                ids:
                  type: array
                  description: Passport UUIDs and/or `productId`s (GTIN-14/GRAI/SKU), 1–200 items.
                    Each is resolved tenant-scoped; unresolvable / not-owned ids
                    are skipped and listed in `manifest.json`.
                  items:
                    type: string
                  minItems: 1
                  maxItems: 200
                format:
                  type: string
                  enum:
                    - png
                    - svg
                  default: png
                  description: Image format for every label in the ZIP.
                size:
                  type: integer
                  minimum: 128
                  maximum: 2048
                  default: 1024
                  description: Rendered width in px; clamped to 128–2048. A PNG is never rendered
                    below one pixel per module, so an unusually dense symbol
                    comes back slightly wider than requested (at most 185 px).
                ecl:
                  type: string
                  enum:
                    - M
                    - Q
                    - H
                  default: Q
                  description: "QR error-correction level (GS1 product-label guidance: `Q`)."
                hri:
                  type: boolean
                  default: false
                  description: 'Overlay the GS1 Human-Readable Interpretation beneath each symbol.
                    Requires `format: "svg"`.'
            example:
              ids:
                - "09501101530003"
                - 9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40
              format: svg
              hri: true
      responses:
        "200":
          description: A ZIP archive of QR images (one per resolved passport) plus a
            `manifest.json` reporting included/skipped ids.
          content:
            application/zip:
              schema:
                type: string
                format: binary
        "400":
          description: 'Empty/oversize `ids` (> 200), an invalid `format`/`size`/`ecl`, or
            `hri: true` without `format: "svg"`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/units/{id}/qr:
    get:
      operationId: getBatteryUnitQrCode
      tags:
        - QR Codes
      summary: Export a print-grade QR code for an individual battery unit
      description: >-
        Renders the battery unit's GS1 Digital Link URI as a print-grade QR code
        — the AI-21 path segment carries the unit's **real physical serial
        number** (e.g.
        `https://opendpp-node.eu/01/09501101530003/21/BAT-2026-000123`). This is
        the carrier each individual battery must wear (per-unit passports, per
        the EU Battery Regulation).


        **Permission:** `battery:read` (read-only — subscription status is
        **not** checked on `:read` permissions, so this endpoint never returns
        402). Works with a Bearer API key, a Bearer JWT, or an authenticated
        browser session.


        **Identifier resolution:** `{id}` is the BatteryUnit **UUID only** —
        unlike the passport QR route there is **no** serial-number fallback.
        Lookup is tenant-scoped. Credentials scoped to an Economic Operator
        receive **403** (`Your access is restricted to Economic Operator:
        <operatorId>`) when the unit's parent passport belongs to a different
        operator.


        **QR rendering:** identical pipeline to the passport QR export —
        4-module quiet zone, `ecl` default `Q`, `size` clamped to 128–2048 px
        (clamped, not rejected; fractions truncated). The response carries
        `Content-Disposition: attachment; filename="qr-<serialNumber>.png"` (or
        `.svg`); the filename base is the unit's `serialNumber` with characters
        outside `[A-Za-z0-9._-]` replaced by `_`, truncated to 80 characters.


        **Errors:** an invalid query option returns **400** with one of these
        exact messages: `format must be png or svg`, `size must be a number`,
        `ecl must be M, Q or H`. An unknown unit returns **404** with message
        `Battery unit <id> not found under your Tenant workspace`.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: BatteryUnit UUID (primary key). Serial numbers are NOT accepted here.
          schema:
            type: string
          example: 9b2fa884-5e2b-4d1c-8a7f-3e9d0c4b6a21
        - name: format
          in: query
          required: false
          description: Output image format. Case-insensitive; any other value returns 400
            (`format must be png or svg`).
          schema:
            type: string
            enum:
              - png
              - svg
            default: png
        - name: size
          in: query
          required: false
          description: Rendered width in pixels (PNG) / SVG width attribute. Clamped to
            128–2048 — out-of-range values are silently clamped, fractions
            truncated. A non-numeric value returns 400 (`size must be a
            number`). A PNG is never rendered below one pixel per module, so an
            unusually dense symbol comes back slightly wider than requested —
            see the 200 response.
          schema:
            type: integer
            minimum: 128
            maximum: 2048
            default: 1024
        - name: ecl
          in: query
          required: false
          description: "QR error-correction level: `M` (~15% recovery), `Q` (~25%, GS1
            product-label guidance, default) or `H` (~30%). Case-insensitive;
            any other value returns 400 (`ecl must be M, Q or H`)."
          schema:
            type: string
            enum:
              - M
              - Q
              - H
            default: Q
        - name: hri
          in: query
          required: false
          description: When `1`/`true`, renders the GS1 Human-Readable Interpretation (the
            bracketed AI string, e.g. `(01) 09501101530003 (21)
            BAT-2026-000123`) as vector text beneath the QR symbol — the
            print-grade GS1 label form (machine-readable QR + the human-readable
            AI string). Requires `format=svg`; combining it with `format=png`
            returns 400 (`hri (Human-Readable Interpretation) labels require
            format=svg`).
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: "QR code image encoding the unit's `digitalLinkUri` (AI-21 = real
            physical serial). The returned media type follows the `format` query
            parameter (no `Accept`-header negotiation): `image/png` is a raw PNG
            bitmap, exactly `size` px wide — except when the symbol's own module
            grid (its modules plus both 4-module quiet zones) is wider than
            `size`, where it renders at that grid width instead (at most 185
            px), because a raster cannot hold a symbol at less than one pixel
            per module; `image/svg+xml` is a vector SVG document, which has no
            such floor and always carries the requested width. Delivered as an
            attachment download."
          headers:
            Content-Disposition:
              description: '`attachment; filename="qr-<serialNumber>.png"` or `...svg` —
                filename base sanitized to `[A-Za-z0-9._-]` (other characters
                become `_`), max 80 chars.'
              schema:
                type: string
          content:
            image/png: {}
            image/svg+xml:
              schema:
                type: string
                description: Vector SVG document (returned when `format=svg`).
        "400":
          description: "Invalid query option. Exact messages: `format must be png or svg`,
            `size must be a number`, `ecl must be M, Q or H`, `hri
            (Human-Readable Interpretation) labels require format=svg`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Bad Request
                message: format must be png or svg
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/materials:
    get:
      operationId: listMaterials
      tags:
        - Schemas & Vocabulary
      summary: List the platform-curated material vocabulary
      description: >-
        Lists active entries from the platform-global material vocabulary that
        powers the searchable material/fiber/chemistry pickers in the passport
        form. This is shared reference data, deliberately **not** tenant-scoped,
        so DPP data stays comparable across tenants.


        **Auth:** any authenticated session — Bearer API key, Bearer JWT, or
        browser session. **No specific permission string is required** and
        subscription status is not checked, so this endpoint never returns 402.
        On a tenant-subdomain host, credentials belonging to a different tenant
        receive **403** with message `Cross-tenant access blocked.`.


        **Filtering & ordering:** `kind` filters by vocabulary kind — an
        unrecognized value is **silently ignored** (the filter simply isn't
        applied; no 400). `search` is a trimmed, case-insensitive substring
        match on `name` (blank values ignored). Only active entries are
        returned, ordered by `kind` ascending then `name` ascending. `limit` is
        clamped to 1–1000 (default 1000); there is no pagination.


        **Envelope caveat:** the 200 body is `{ "materials": [...] }` — there is
        **no `success` field** on this endpoint.


        **Curation:** this vocabulary is curated by the platform operator — the
        API is read-only for tenant credentials. Free-text material values in
        passport metadata remain allowed but are never auto-added to this
        vocabulary.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: kind
          in: query
          required: false
          description: Filter by vocabulary kind. Unrecognized values are silently ignored
            (no error; the filter is not applied).
          schema:
            type: string
            enum:
              - material
              - fiber
              - chemistry
              - substance
              - hazard
              - crm
        - name: search
          in: query
          required: false
          description: Case-insensitive substring match on the entry `name` (value is
            trimmed; blank values ignored).
          schema:
            type: string
          example: lithium
        - name: limit
          in: query
          required: false
          description: Maximum number of entries to return. Clamped to 1–1000
            (out-of-range numeric values are clamped, not rejected); a
            non-numeric value falls back to the default 1000.
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 1000
      responses:
        "200":
          description: "Active vocabulary entries matching the filters, ordered by `kind`
            then `name` ascending. Note: no `success` field in this envelope."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MaterialVocabularyListResponse"
              example:
                materials:
                  - id: 9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40
                    name: Lithium Iron Phosphate (LFP)
                    kind: chemistry
                    casNumber: 15365-14-7
                    description: Cathode chemistry for stationary storage and EV cells
                  - id: 9b2fa884-77aa-4b2e-8c3d-0f1e2a3b4c5d
                    name: Organic Cotton
                    kind: fiber
                    casNumber: null
                    description: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/events:
    post:
      operationId: registerTraceabilityEvent
      tags:
        - Traceability & Audit
      summary: Register a UNTP/EPCIS 2.0 traceability event (VC-shaped)
      description: >-
        Registers a supply-chain traceability event carried as a VC-shaped UNTP
        credential and persists it as an EPCIS 2.0 event row scoped to your
        tenant.


        **Permission:** `passport:update` (write operation — subscription gating
        applies, see 402). When the node operator enforces MFA, writes from
        user-backed sessions (cookie or Bearer JWT) whose MFA policy requires a
        second factor (user policy `REQUIRED`, or `DEFAULT` with the workspace's
        MFA-by-default setting, which is on by default) receive 403 without one;
        API-key clients are exempt. Cookie-session clients must send the
        `X-CSRF-Token` header (double-submit with the `opendpp_csrf` cookie);
        Bearer JWT / API-key clients are exempt.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.


        **Validation pipeline (in order):**

        1. *Structural* — the body must be an object containing
        `credentialSubject`, otherwise 400 `Bad Request`.

        2. *EPCIS rule* — `action` is strictly forbidden on
        `TransformationEvent` (any non-null value → 400 `Schema Validation
        Error`).

        3. *Cryptographic* — the credential's `proof` MUST be a conformant W3C
        `DataIntegrityProof` with `cryptosuite: "ecdsa-jcs-2019"` and a
        multibase base58btc (`z…`) `proofValue`; any other proof shape (e.g. the
        legacy key-sorted `MerkleTreeAttestationProof`) is rejected. The ECDSA
        P-256 signature is verified per that cryptosuite over `sha256(JCS(proof
        options)) ‖ sha256(JCS(credential without proof))` — RFC 8785 JCS
        canonicalization, IEEE-P1363 raw r‖s — a conformant, interoperable Data
        Integrity suite, which is what makes the persisted `isUntpCompliant:
        true` honest. The verification key is resolved in trust order: (a) an
        embedded `proof.verificationMethod.x5c` chain, accepted ONLY when the
        node has trust anchors configured, the chain validates against them,
        every certificate is currently valid, and the leaf attests the issuer;
        (b) ALL of the authoritative vault keys (current + retired, so a
        pre-rotation credential still verifies) of the tenant whose UNIQUE
        subdomain EXACTLY equals the trailing `:`-segment of the issuer DID. If
        no key resolves or the signature does not verify → 400 `Cryptographic
        Verification Failed`.

        4. *Operator scoping* — if your API key is scoped to an Economic
        Operator, the credential's declared operator DID — the `issuer` DID, or
        `credentialSubject.responsibleOperatorDid` only when `issuer` is absent
        — must contain the bound operator's registration id (e.g.
        `EU-DEFAULT-001`), otherwise 403 with `message: "Your access is
        restricted to Economic Operator: <operatorId> (<regId>)"`.


        **Persistence:** the stored event id is ALWAYS server-generated (UUID) —
        the credential's own `id` is never used as the primary key (prevents
        cross-tenant id squatting); the issuer DID is retained as `issuerDid`.
        Defaults applied on write: `bizStep` →
        `urn:epcglobal:cbv:bizstep:receiving`; `disposition` →
        `urn:epcglobal:cbv:disp:in_progress`; `readPoint` →
        `geo:<latitude>,<longitude>` derived from
        `credentialSubject.originLocation` when present; `bizLocation` →
        `responsibleOperatorDid`; `eventTime` → `issuanceDate`, else the server
        clock; `epcList` → `[credentialSubject.id]` when not supplied as an
        array (or `[]`). The row is stored with `isUntpCompliant: true` and the
        `proof.proofValue` retained.


        **Caveats:** `credentialSubject.eventType` must be one of the documented
        event-type values and `action` (when present) one of
        `ADD`/`OBSERVE`/`DELETE` — both map to server-side enums, and a missing
        or unknown value is only rejected at the persistence layer and surfaces
        as the 500 `Database Persistence Failed` body, not as a 400. Note the
        201 envelope is `{status: "success", ...}`, NOT the usual `{success:
        true, ...}` shape. This endpoint does not create lineage edges between
        events; the lineage DAG read by `GET /api/v1/events/{id}/lineage` is
        built from lineage relations maintained separately on the node.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UntpEventCredential"
            example:
              "@context":
                - https://www.w3.org/ns/credentials/v2
                - https://vocabulary.uncefact.org/untp/dpp/
              id: urn:uuid:0e7a2c1c-6f4e-4a08-9d2e-3b1f5a7c9d10
              type:
                - VerifiableCredential
                - DigitalTraceabilityEvent
              issuer: did:web:opendpp-node.eu:EU-DEFAULT-001:demo
              issuanceDate: 2026-06-12T09:41:00.000Z
              credentialSubject:
                id: urn:epc:id:sgtin:0950110153.0003.SN-2026-000123
                eventType: ObjectEvent
                action: OBSERVE
                bizStep: urn:epcglobal:cbv:bizstep:shipping
                disposition: urn:epcglobal:cbv:disp:in_transit
                readPoint: geo:41.1496,-8.6109
                bizLocation: urn:epc:id:sgln:0950110153000..0
                eventTime: 2026-06-12T08:30:00.000Z
                epcList:
                  - urn:epc:id:sgtin:0950110153.0003.SN-2026-000123
                responsibleOperatorDid: did:web:opendpp-node.eu:EU-DEFAULT-001:demo
              proof:
                type: DataIntegrityProof
                cryptosuite: ecdsa-jcs-2019
                created: 2026-06-12T09:41:00.000Z
                proofPurpose: assertionMethod
                verificationMethod: did:web:opendpp-node.eu:EU-DEFAULT-001:demo#key-1
                proofValue: z4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY2xEQLExampleEcdsaJcs2019MultibaseBase58btcProofValue
      responses:
        "201":
          description: 'Event registered. Note the non-standard envelope: `status:
            "success"` (string), not `success: true`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TraceEventRegistered"
              example:
                status: success
                eventId: 9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                untpVerified: true
        "400":
          description: "Route-specific validation failure, always `{success: false, error,
            message}` with one of three `error` values: `Bad Request` (body
            missing or no `credentialSubject`), `Schema Validation Error`
            (`action` present on a `TransformationEvent`), or `Cryptographic
            Verification Failed` (no trusted key resolved for the issuer, or the
            proof signature does not verify)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalidStructure:
                  value:
                    success: false
                    error: Bad Request
                    message: Invalid credential payload structure.
                actionOnTransformationEvent:
                  value:
                    success: false
                    error: Schema Validation Error
                    message: The 'action' field is strictly forbidden on TransformationEvent rows
                      under the GS1 EPCIS 2.0 standard.
                signatureInvalid:
                  value:
                    success: false
                    error: Cryptographic Verification Failed
                    message: Credential signature is invalid or altered.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: 'Persistence failure — also returned when
            `credentialSubject.eventType`/`action` is missing or not a valid
            enum value (the server-side enum rejects the row). The
            authentication layer can additionally emit `{success: false, error:
            "Internal Server Error", message: "Authentication verification
            failed"}`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                persistenceFailed:
                  value:
                    success: false
                    error: Database Persistence Failed
                    message: Failed to persist the event.
                authVerificationFailed:
                  value:
                    success: false
                    error: Internal Server Error
                    message: Authentication verification failed
  /api/v1/events/epcis:
    post:
      operationId: captureEpcisDocument
      tags:
        - Traceability & Audit
      summary: Capture a native GS1 EPCIS 2.0 document (JSON/JSON-LD)
      description: >-
        Captures a native **GS1 EPCIS 2.0 document** — the standard's own
        JSON/JSON-LD interchange format — and persists each supported event as
        an EPCIS event row scoped to your tenant, alongside the VC-shaped `POST
        /api/v1/events` path. Send the document exactly as your EPCIS
        infrastructure produces it.


        **Permission:** `passport:update` (write operation — subscription gating
        applies, see 402). When the node operator enforces MFA, writes from
        user-backed sessions (cookie or Bearer JWT) whose MFA policy requires a
        second factor (user policy `REQUIRED`, or `DEFAULT` with the workspace's
        MFA-by-default setting, which is on by default) receive 403 without one;
        API-key clients are exempt. Cookie-session clients must send the
        `X-CSRF-Token` header (double-submit with the `opendpp_csrf` cookie);
        Bearer JWT / API-key clients are exempt.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.


        **Validation:** the WHOLE document is validated against the official GS1
        EPCIS **2.0.1** JSON Schema (vendored and pinned on the node) before any
        event is stored — a non-conformant document is rejected 400 with the
        first few schema violations under `errors[]`. Notable rules the OFFICIAL
        schema enforces: `@context` and `creationDate` are required;
        `bizStep`/`disposition` must use the CBV **short names** (e.g.
        `commissioning`, `in_transit`) or a custom (non-CBV) URI — the legacy
        `urn:epcglobal:cbv:*` URN form is REJECTED by the standard's schema;
        `action` is forbidden on `TransformationEvent`;
        `readPoint`/`bizLocation` carry `{id: <uri>}`. Only `type:
        "EPCISDocument"` is accepted (no `EPCISQueryDocument`, no bare events),
        and `epcisBody.eventList` must be non-empty.


        **Per-event capture (partial success):** events are processed
        independently and the 201 response reports `results[]` (captured) and
        `errors[]` (rejected) by `index`. An event is rejected — never silently
        dropped — when its type is outside this node's traceability model
        (`ObjectEvent`, `AggregationEvent`, `TransformationEvent`,
        `AssociationEvent` are supported; `TransactionEvent` is not) or when it
        identifies stock ONLY by quantity lists (no
        `epcList`/`parentID`/`childEPCs`/`inputEPCList`/`outputEPCList` —
        nothing EPC-identified would remain to trace). If EVERY event is
        rejected the response is 400 `No Events Captured` with the same
        `errors[]`.


        **Fidelity disclosure:** recognized EPCIS fields the node does not
        persist (`eventID`, quantity lists, `sensorElementList`,
        `bizTransactionList`, `sourceList`/`destinationList`,
        `persistentDisposition`, `errorDeclaration`, `ilmd`, custom extension
        fields, …) are listed per event under `results[].ignoredFields` instead
        of being silently discarded.


        **Persistence:** row ids are ALWAYS server-generated (UUID) — a
        client-supplied `eventID` is never adopted as the primary key (it is
        disclosed under `ignoredFields`); CBV short names are normalized to the
        node's stored URN form (`urn:epcglobal:cbv:bizstep:*` /
        `urn:epcglobal:cbv:disp:*`, the same form the VC-shaped path stores and
        the lineage projection reads); defaults when absent: `bizStep` →
        `receiving`, `disposition` → `in_progress`. Rows captured on this path
        carry **no per-event credential** and are stored with `isUntpCompliant:
        false` (API-key provenance only) — they are never presented as
        UNTP-verified. This endpoint does not create lineage edges between
        events.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EpcisDocument"
            example:
              "@context":
                - https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld
              type: EPCISDocument
              schemaVersion: "2.0"
              creationDate: 2026-07-02T10:00:00.000Z
              epcisBody:
                eventList:
                  - type: ObjectEvent
                    action: ADD
                    bizStep: commissioning
                    disposition: active
                    eventTime: 2026-01-15T08:00:00.000Z
                    eventTimeZoneOffset: +00:00
                    epcList:
                      - urn:epc:id:sgtin:0950110.154904.1
                    readPoint:
                      id: geo:41.1579,-8.6291
                    bizLocation:
                      id: urn:opendpp:location:PT-SAMPLE-SITE-3
                  - type: TransformationEvent
                    bizStep: commissioning
                    disposition: in_progress
                    eventTime: 2026-01-16T08:00:00.000Z
                    eventTimeZoneOffset: +00:00
                    inputEPCList:
                      - urn:epc:id:sgtin:0950110.154904.1
                    outputEPCList:
                      - urn:epc:id:sgtin:0950110.154100.1
                    readPoint:
                      id: geo:41.1579,-8.6291
      responses:
        "201":
          description: 'At least one event captured. Note the non-standard envelope:
            `status: "success"` (string). `errors[]` lists per-event rejections
            when the capture was partial.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EpcisCaptureResponse"
              example:
                status: success
                captured: 2
                results:
                  - index: 0
                    eventId: 9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                    eventType: ObjectEvent
                  - index: 1
                    eventId: 1c3d4e5f-8a6b-4c8d-9e0f-1a2b9b2fa884
                    eventType: TransformationEvent
                errors: []
        "400":
          description: "Always `{success: false, error, message}` (plus `errors[]` detail
            where noted) with one of: `EPCIS Schema Validation Failed` (the
            document does not conform to the official GS1 EPCIS 2.0 JSON Schema;
            `errors[]` carries the first few violations), `Unsupported Document
            Type` (`type` is not `EPCISDocument`), `Empty Document`
            (`epcisBody.eventList` is empty), or `No Events Captured` (every
            event was rejected; `errors[]` carries the per-event reasons)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                schemaInvalid:
                  value:
                    success: false
                    error: EPCIS Schema Validation Failed
                    message: The document does not conform to the GS1 EPCIS 2.0 JSON Schema.
                    errors:
                      - /epcisBody/eventList/0 must have required property
                        'eventTime'
                noneCaptured:
                  value:
                    success: false
                    error: No Events Captured
                    message: Every event in the document was rejected.
                    errors:
                      - index: 0
                        message: "event type \"TransactionEvent\" is not supported by this node's
                          traceability model (supported: ObjectEvent,
                          AggregationEvent, TransformationEvent,
                          AssociationEvent)."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/events/{id}/lineage:
    get:
      operationId: getEventLineage
      tags:
        - Traceability & Audit
      summary: Retrieve the upstream pedigree of an event as a recursive lineage DAG
      description: >-
        Returns the full upstream pedigree of a traceability event as a
        recursive Directed Acyclic Graph: the root event plus, in `parents`,
        every event linked upstream through lineage relations registered on the
        node, walked transitively (parents of parents). A shared ancestor
        reached through multiple downstream paths is repeated under EACH path —
        the DAG is expanded into a tree in the response, not deduplicated; only
        a true cycle aborts the walk (400).


        **Permission:** `passport:read`. Every node in the walk — the root AND
        each upstream parent — is scoped to the caller's tenant; an event
        belonging to another tenant is invisible and the request fails with 404
        (no cross-tenant pedigree reads).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.


        **Caveats:** if the lineage graph contains a circular reference the walk
        aborts with 400. Any other failure (unknown id, other-tenant id, missing
        parent) is reported as the same deliberately generic 404 body.
        `eventTime` is serialized as ISO 8601 UTC; `epcs` is parsed from the
        stored EPC list (a non-array value degrades to `[]`); `location` mirrors
        the stored `bizLocation`.


        **Content negotiation (EPCIS projection):** `Accept:
        application/ld+json` returns the SAME tenant-scoped lineage as a native
        **GS1 EPCIS 2.0 document** (`EPCISDocument` with the walk's events under
        `epcisBody.eventList`, ordered by `eventTime`, bounded to 500 events)
        instead of the recursive JSON tree — another projection of the same
        canonical rows, mirroring the AAS/VC Accept-header pattern on the
        passport resolution routes. Emitted documents use the official CBV short
        names (stored `urn:epcglobal:cbv:*` values are mapped back), expose row
        ids as `eventID` URNs (`urn:uuid:*`, or `urn:opendpp:event:*` for
        non-UUID ids), and wrap non-URI stored locations as
        `urn:opendpp:location:*`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: EPCIS event id — the server-generated UUID returned as `eventId` by
            `POST /api/v1/events`.
      responses:
        "200":
          description: The lineage DAG rooted at the requested event.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TraceLineageResponse"
              example:
                success: true
                lineage:
                  eventId: 9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                  eventType: TransformationEvent
                  bizStep: urn:epcglobal:cbv:bizstep:commissioning
                  disposition: urn:epcglobal:cbv:disp:active
                  eventTime: 2026-06-12T08:30:00.000Z
                  epcs:
                    - urn:epc:id:sgtin:0950110153.0003.SN-2026-000123
                  location: urn:epc:id:sgln:0950110153000..0
                  readPoint: geo:41.1496,-8.6109
                  isUntpCompliant: true
                  issuerDid: did:web:opendpp-node.eu:EU-DEFAULT-001:demo
                  parents:
                    - eventId: 4c81d2e6-9f0a-4b3c-8d5e-1a2b3c4d5e6f
                      eventType: ObjectEvent
                      bizStep: urn:epcglobal:cbv:bizstep:receiving
                      disposition: urn:epcglobal:cbv:disp:in_progress
                      eventTime: 2026-06-10T14:05:00.000Z
                      epcs:
                        - urn:epc:id:sgtin:0950110153.0001.RAW-COTTON-LOT-77
                      location: did:web:opendpp-node.eu:supplier-pt
                      readPoint: geo:38.7223,-9.1393
                      isUntpCompliant: true
                      issuerDid: did:web:opendpp-node.eu:supplier-pt
                      parents: []
            application/ld+json:
              schema:
                $ref: "#/components/schemas/EpcisDocument"
              example:
                "@context":
                  - https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld
                type: EPCISDocument
                schemaVersion: "2.0"
                creationDate: 2026-07-02T10:00:00.000Z
                epcisBody:
                  eventList:
                    - eventID: urn:uuid:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b
                      type: ObjectEvent
                      action: ADD
                      bizStep: commissioning
                      disposition: active
                      eventTime: 2026-01-15T08:00:00.000Z
                      eventTimeZoneOffset: +00:00
                      epcList:
                        - urn:epc:id:sgtin:0950110.154904.1
                      readPoint:
                        id: geo:41.1579,-8.6291
        "400":
          description: Circular reference detected while walking the lineage graph.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Lineage Retrieval Failed
                message: Circular reference detected in lineage graph.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The event does not exist, belongs to another tenant, or an upstream
            node could not be retrieved. The `error` value is `Lineage Retrieval
            Failed` (not the standard `Not Found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Lineage Retrieval Failed
                message: Lineage could not be retrieved.
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/audit/verify:
    post:
      operationId: verifyPassportSeal
      tags:
        - Traceability & Audit
      summary: Publicly verify a passport's seal, certificate chain and timestamp
      description: >-
        **Public seal-verification API** — cryptographically verifies that a
        Digital Product Passport document was sealed by an economic-operator
        tenant registered on this node and has not been tampered with. No
        authentication required.


        **Rate limit:** **30 requests/min per IP**. This bucket emits no
        rate-limit headers of its own — any `x-ratelimit-*` headers on responses
        (including this 429) come from the global 100 req/min limiter and
        describe that budget, not the 30/min one. The 429 body is the two-field
        `{"error": "Too Many Requests", "message": "Rate limit exceeded."}`.


        **Input resolution.** `payload` is required. `signature` and `publicKey`
        may be supplied top-level, or are extracted from the document's embedded
        proof block: `signature` ← `payload.proof.proofValue` (else
        `payload.proof.signatureValue`); `publicKey` ←
        `payload.proof.publicKeyPem`, else the leaf certificate's SPKI when
        `payload.proof.x5c` is present. If, after extraction, any of the three
        is still missing → 400. The public key is CRLF-normalized and trimmed
        before matching.


        **Verification pipeline (in order):**

        1. **Certificate-chain report (optional).** If `payload.proof.x5c` is a
        non-empty array of base64-DER certificates (leaf first), the chain is
        parsed and a `certificate` report is built: the leaf's `subject` /
        `issuer` / `validFrom` / `validTo` (X.509 textual dates such as `Jan 10
        00:00:00 2026 GMT` — NOT ISO 8601), `chainValid` (every link
        signature-verifies against the next certificate, every certificate is
        inside its validity window, and the top of the chain is anchored to this
        node's seal CA — SHA-256 fingerprint match or signature under the CA
        key; the CA is published at `GET /.well-known/opendpp-seal-ca.pem`), and
        `keyMatchesProof` (the leaf SPKI equals the supplied `publicKey`,
        whitespace-insensitive; always `true` when no explicit key was
        supplied). An unparseable chain yields `{"chainValid": false, "error":
        "Unparseable x5c certificate chain"}` and does NOT fail the request.
        This reports the CERTIFIED identity of the seal creator. SECURITY: the
        report is attached ONLY on a `verified: true` outcome whose chain is
        TRUSTED (`chainValid` AND `keyMatchesProof` both true) — an
        untrusted/self-signed chain, one outside its validity window or not
        anchored to this node's seal CA, or one whose leaf key does not match
        the verifying key, is never surfaced as a `certificate` block (it must
        not present an unverified identity as authoritative). The two
        policy-failure responses omit it too.

        2. **Key-registration gate.** The `publicKey` must exactly match the
        registered signing public key of a tenant on this node (trailing-newline
        tolerant) — otherwise HTTP **200** with `verified: false` and an
        explanatory `message`. Verification-policy failures are reported
        in-band, never as HTTP errors.

        3. **Operator-binding gate (fail-closed).** If the payload declares an
        operator registration id (`payload.operator.regId`, else
        `payload.economicOperator.regId`), that id MUST resolve to an Economic
        Operator registered on this node AND that operator MUST be bound to the
        signing tenant (a workspace–operator binding registered on this node). A
        declared operator that is unregistered, or registered but not bound to
        the key-owning tenant, → 200 `verified: false` with an explanatory
        `message`. Payloads that declare no operator id skip this gate.

        4. **Signature verification (two phases).** *Phase 1 — Merkle seal:*
        when `payload.metadata` is an object (or, when the `metadata` key is
        entirely absent, the whole `payload` is treated as the metadata), the
        SHA-256 Merkle tree over the metadata's top-level properties is rebuilt
        and the base64 ECDSA (P-256 / SHA-256) `signature` is verified against
        the recomputed root. Every leaf is recomputed from the actual values —
        caller-supplied redacted-leaf hashes are NOT accepted (they would let a
        tampered field be smuggled past verification), so a publicly redacted
        document will not pass the Merkle phase: verify the unredacted,
        privileged document. *Phase 2 — fallback:* if the Merkle phase does not
        verify, the signature is verified over the deterministic key-sorted
        canonicalization of the entire `payload`.

        5. **RFC 3161 timestamp report (optional).** When
        `payload.proof.rfc3161.token` is a non-empty base64-DER TimeStampToken,
        the response includes `timestamp` with the TSA-asserted `genTime` parsed
        from the token's TSTInfo (or `genTime: null` plus a `note` when the
        token cannot be parsed). When the node has a TSA trust anchor
        configured, the report also carries `timeAuthenticated` — the node's own
        verification of the token's CMS SignedData signature over its TSTInfo
        PLUS full RFC 3161 trust-path validation of the signer to that anchor (a
        critical `id-kp-timeStamping` EKU, validity at the asserted `genTime`,
        CA-constrained intermediates) (`false`, and the asserted time
        unauthenticated, when no CA is configured, the signature fails, or the
        path is not policy-valid); a verifier may still run its own `openssl ts
        -verify`. Like `certificate`, it appears only on the final verification
        outcome.


        **Outcome.** A processed verification ALWAYS returns HTTP 200 with
        `verified: true|false`; 400 is reserved for missing parameters or an
        exception thrown while verifying (e.g. an undecodable public key).
        `timestamp` is attached only when verification proceeds past the
        key-registration and operator-binding gates; `certificate` is attached
        only on a `verified: true` outcome whose chain is trusted (`chainValid`
        AND `keyMatchesProof`) — the two policy `verified: false` responses (and
        any untrusted-chain outcome) omit `certificate`, even when an x5c chain
        and/or an RFC 3161 token were supplied. The 400 bodies on this public
        endpoint are `{"success": false, "message": "..."}` — they include
        `success` but OMIT the `error` field. (A syntactically malformed JSON
        body is rejected earlier by the framework with its default `{statusCode,
        error, message}` body; a POST with no body at all — no `Content-Type` —
        fails before processing with a framework-default 500, so send at least
        `{}`. An empty `application/json` body is treated as `{}` and yields the
        documented 400.)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SealVerifyRequest"
            example:
              payload:
                passportId: 9b2fa884-5b1d-4c0e-9a3f-2d7c8e1f6a45
                productId: "09501101530003"
                operator:
                  name: OpenDPP Demo Eco Industries
                  regId: EU-DEFAULT-001
                metadata:
                  category: textiles
                  originCountry: PT
                  materialComposition:
                    - material: Organic Cotton
                      percentage: 80
                    - material: Recycled Polyester
                      percentage: 20
                proof:
                  type: DataIntegrityProof
                  proofValue: MEQCIB3pZ8sVxampleMerkleRootSealSignatureFirstIntegerAiAW6kQexampleSecondDerIntegerValue0123456789abcd==
                  publicKeyPem: |-
                    -----BEGIN PUBLIC KEY-----
                    MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEexampleDemoTenantSealPublicKey
                    0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO=
                    -----END PUBLIC KEY-----
                  x5c:
                    - MIIB2zCCAYGgAwIBAgIUExampleLeafSealCertificateBase64Der
                    - MIIB4TCCAYagAwIBAgIUExampleNodeSealCaCertificateBase64Der
                  rfc3161:
                    token: MIIKlAYJKoZIhvcNAQcCoIIKhTCCCoECAQMxDzANBglghkgBZQMEAgEFADCBExampleTimeStampTokenDer
      responses:
        "200":
          description: "Verification processed. `verified` reports the cryptographic
            outcome; policy failures (unregistered key, unbound operator) come
            back as `verified: false` with a `message` — still HTTP 200, and
            WITHOUT `certificate`/`timestamp` even when an x5c chain or RFC 3161
            token was supplied. Once verification proceeds past the policy
            gates, `certificate` is present for x5c-carrying proofs ONLY when
            the outcome is `verified: true` with a trusted chain (`chainValid`
            AND `keyMatchesProof`), and `timestamp` when an RFC 3161 token was
            embedded."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SealVerifyResponse"
              examples:
                sealVerified:
                  summary: Authentic seal with certificate chain and trusted timestamp
                  value:
                    success: true
                    verified: true
                    certificate:
                      subject: CN=OpenDPP Demo Eco Industries Seal
                      issuer: CN=OpenDPP Node Seal CA
                      validFrom: Jan 10 00:00:00 2026 GMT
                      validTo: Jan 10 00:00:00 2028 GMT
                      chainValid: true
                      keyMatchesProof: true
                    timestamp:
                      present: true
                      genTime: 2026-06-12T09:41:03.000Z
                unregisteredKey:
                  summary: Public key not registered to any tenant on this node
                  value:
                    success: true
                    verified: false
                    message: "Cryptographic verification failed: The public key used to seal this
                      passport is not registered to any authorized economic
                      operator tenant on this node."
                operatorNotBound:
                  summary: Declared operator not bound to the signing tenant (fail-closed)
                  value:
                    success: true
                    verified: false
                    message: "Cryptographic verification failed: The economic operator declared in
                      this passport is not a registered operator bound to the
                      signing tenant."
        "400":
          description: "Missing cryptographic parameters (after proof-block extraction),
            or an exception during verification (e.g. undecodable key material).
            Body is `{success: false, message}` — NO `error` field on this
            public endpoint."
          content:
            application/json:
              schema:
                type: object
                required:
                  - success
                  - message
                properties:
                  success:
                    type: boolean
                    const: false
                  message:
                    type: string
                    enum:
                      - "Missing cryptographic parameter: payload, signature,
                        and publicKey are required"
                      - Signature verification failed.
              example:
                success: false
                message: "Missing cryptographic parameter: payload, signature, and publicKey are
                  required"
        "429":
          $ref: "#/components/responses/PublicRateLimited"
  /tenants/{tenantId}/did.json:
    get:
      operationId: getTenantDidDocument
      tags:
        - Verifiable Credentials
      summary: Resolve a tenant's did:web DID document
      description: >-
        Resolves the issuing workspace's `did:web` DID document
        (`application/did+json`). The DID is
        `did:web:opendpp-node.eu:tenants:{tenantId}`, which per the did:web
        method dereferences here. The document exposes **only public key
        material** — the workspace's signing public key(s) as `JsonWebKey2020`
        verification methods, each with a stable `#key-<index>` id matching the
        `kid` of the credentials it signs.


        Use it to verify any OpenDPP-issued Verifiable Credential (`Accept:
        application/vc+jwt`, `application/vc+ld+json`, or
        `application/dc+sd-jwt` on the public resolution endpoints) without
        out-of-band key exchange. Both current and retired keys are published
        and listed in `assertionMethod`/`authentication`, so credentials issued
        before a key rotation still verify; new credentials always use the
        current key. The optional `name` is the issuer's authoritative legal
        name (the same value used in every credential's `issuer.name`).


        No authentication, no permission (public endpoint). Subject only to the
        global platform rate limit (100 req/min/IP). A workspace that has never
        provisioned a signing key returns 404. Never returns private key
        material.
      security: []
      parameters:
        - name: tenantId
          in: path
          required: true
          description: The issuing workspace (tenant) id — the `{tenantId}` of its
            `did:web:opendpp-node.eu:tenants:{tenantId}` DID.
          schema:
            type: string
          example: 9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40
      responses:
        "200":
          description: The tenant's did:web DID document (public keys only).
          content:
            application/did+json:
              schema:
                $ref: "#/components/schemas/DidWebDocument"
              example:
                "@context":
                  - https://www.w3.org/ns/did/v1
                  - https://w3id.org/security/suites/jws-2020/v1
                id: did:web:opendpp-node.eu:tenants:9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40
                name: ACME Manufacturing UAB
                verificationMethod:
                  - id: did:web:opendpp-node.eu:tenants:9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40#key-0
                    type: JsonWebKey2020
                    controller: did:web:opendpp-node.eu:tenants:9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40
                    publicKeyJwk:
                      kty: EC
                      crv: P-256
                      x: f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU
                      y: x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0
                      alg: ES256
                      use: sig
                assertionMethod:
                  - did:web:opendpp-node.eu:tenants:9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40#key-0
                authentication:
                  - did:web:opendpp-node.eu:tenants:9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40#key-0
        "404":
          description: No DID document for this tenant (the workspace has not provisioned
            a signing key).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Not Found
                message: No DID document for this tenant.
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /tenants/{tenantId}/status/revocation:
    get:
      operationId: getTenantRevocationStatusList
      tags:
        - Verifiable Credentials
      summary: Tenant revocation status list (W3C Bitstring Status List)
      description: >-
        Serves the workspace's W3C **Bitstring Status List** as a signed
        enveloping `vc+jwt` (`application/vc+jwt`) — a
        `BitstringStatusListCredential` whose GZIP+base64 encoded bitstring sets
        the bit of every passport whose status is RECALLED or DECOMMISSIONED. An
        OpenDPP-issued DPP credential's `credentialStatus.statusListCredential`
        points here; a verifier fetches this list and checks the bit at the
        credential's `statusListIndex` to determine whether it has been revoked.
        Signed by the workspace's current key (stable `kid`), verifiable via the
        DID document at `/tenants/{tenantId}/did.json`.


        No authentication, no permission (public endpoint). Subject only to the
        global platform rate limit (100 req/min/IP). A workspace with no signing
        key returns 404.
      security: []
      parameters:
        - name: tenantId
          in: path
          required: true
          description: The issuing workspace (tenant) id.
          schema:
            type: string
          example: 9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40
      responses:
        "200":
          description: The signed BitstringStatusListCredential as a compact `vc+jwt` (JWS
            string). Decode and verify with the issuer's DID key, then read the
            encoded bitstring.
          content:
            application/vc+jwt:
              schema:
                type: string
                description: Compact JWS (`vc+jwt`) enveloping the W3C
                  BitstringStatusListCredential.
        "404":
          description: No status list for this tenant (unknown workspace or no signing key).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Not Found
                message: No status list for this tenant.
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/webhooks/subscriptions:
    post:
      operationId: createWebhookSubscription
      tags:
        - Webhooks
      summary: Register a webhook subscription (signing secret returned once)
      description: >-
        Registers an endpoint to receive passport lifecycle webhooks for the
        calling workspace.


        **Permission:** `webhook:write`. Cookie-session callers must send the
        `X-CSRF-Token` header (double-submit); Bearer API-key/JWT clients are
        exempt. Write permissions are additionally gated on an active workspace
        subscription (`402`).


        **URL validation (SSRF guard):** `url` must be an absolute `http(s)`
        URL. At registration the hostname is DNS-resolved and the request is
        rejected with `400` if any resolved A/AAAA record is loopback, RFC
        1918/CGNAT private, link-local / cloud-metadata (`169.254.0.0/16`),
        multicast, or an equivalent IPv6 range. At delivery time the socket is
        pinned to the validated IP and redirects are never followed.


        **Event filters:** `events` must be a non-empty array drawn from
        `passport.ingested`, `passport.updated`, `passport.sealed`,
        `passport.recalled`, `passport.status_updated`, `*`. The `*` wildcard
        matches every emitted event.


        **Signing secret — shown once:** the `201` response contains the full
        subscription row **including** the HMAC-SHA256 signing secret (`whsec_`
        + 32 lowercase hex chars, server-generated, never client-supplied). This
        is the only time the secret is ever returned: the list endpoint strips
        it and there is no rotation or update endpoint — delete and re-create to
        rotate.


        **Limits:** maximum **25 subscriptions per workspace** (`409 Conflict`).
        Global rate limit 100 requests/min/IP (`429` with `x-ratelimit-*`
        headers). Unknown request-body fields are ignored.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookSubscriptionCreateRequest"
            example:
              url: https://erp.example.com/hooks/opendpp
              events:
                - passport.ingested
                - passport.sealed
      responses:
        "201":
          description: Subscription registered. `subscription` is the full row **including
            `secret`** — store it now; it is never returned again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscriptionCreateResponse"
              example:
                success: true
                message: Webhook subscription registered successfully
                subscription:
                  id: 5e8d2c47-9a1b-4f63-8c0d-7b4e2f9a6d35
                  tenantId: f47ac10b-58cc-4372-a567-0e02b2c3d479
                  url: https://erp.example.com/hooks/opendpp
                  secret: whsec_00000000000000000000000000000000
                  events:
                    - passport.ingested
                    - passport.sealed
                  isActive: true
                  createdAt: 2026-06-12T09:41:00.000Z
                  updatedAt: 2026-06-12T09:41:00.000Z
        "400":
          description: "Validation failure. Exact messages: `\"url must be a valid string
            URL\"` (missing, empty, or non-string `url`); `\"Outbound webhook
            URL rejected: <reason>\"` (SSRF guard — covers a malformed URL
            string (reason `Invalid URL format`), a non-http(s) scheme, an
            unresolvable host, or a private/loopback/metadata address);
            `\"events must be a non-empty array of strings\"`; `\"event
            '<event>' is invalid. Allowed events: passport.ingested,
            passport.updated, passport.sealed, passport.recalled,
            passport.status_updated, *\"`. (A malformed JSON body instead yields
            a framework-default `{statusCode, error, message}` 400 body.)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Bad Request
                message: "event 'passport.deleted' is invalid. Allowed events:
                  passport.ingested, passport.updated, passport.sealed,
                  passport.recalled, passport.status_updated, *"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: The workspace already has 25 webhook subscriptions (the per-tenant
            cap). Delete an existing subscription first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Conflict
                message: Maximum of 25 webhook subscriptions per workspace reached.
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: Persistence failed (details logged server-side) — message `"Failed
            to register webhook subscription."`. Auth-layer failures use the
            message `"Authentication verification failed"`. (A failure of the
            pre-create subscription-count query instead returns a
            framework-default `{statusCode, error, message}` body without
            `success`.)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Internal Server Error
                message: Failed to register webhook subscription.
    get:
      operationId: listWebhookSubscriptions
      tags:
        - Webhooks
      summary: List webhook subscriptions (signing secrets stripped)
      description: >-
        Lists all webhook subscriptions of the calling workspace. Unpaginated
        (the per-workspace cap is 25).


        **Permission:** `webhook:read` (read permissions are not
        subscription-gated, so no `402`).


        The HMAC signing `secret` is **stripped from every row** — it is
        returned once by the create endpoint and once by `rotate-secret`.
        `isActive` reflects whether the subscription receives deliveries; toggle
        it with `PATCH /api/v1/webhooks/subscriptions/{id}`. Global rate limit
        100 requests/min/IP.
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: All subscriptions of the workspace, secrets removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscriptionListResponse"
              example:
                success: true
                subscriptions:
                  - id: 5e8d2c47-9a1b-4f63-8c0d-7b4e2f9a6d35
                    tenantId: f47ac10b-58cc-4372-a567-0e02b2c3d479
                    url: https://erp.example.com/hooks/opendpp
                    events:
                      - passport.ingested
                      - passport.sealed
                    isActive: true
                    createdAt: 2026-06-12T09:41:00.000Z
                    updatedAt: 2026-06-12T09:41:00.000Z
                  - id: 0c7b1e92-4d5a-4b38-a6f1-83e9d27c50b4
                    tenantId: f47ac10b-58cc-4372-a567-0e02b2c3d479
                    url: https://plm.example.com/integrations/opendpp/events
                    events:
                      - "*"
                    isActive: true
                    createdAt: 2026-05-30T14:02:11.000Z
                    updatedAt: 2026-05-30T14:02:11.000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: 'Unexpected server error. Auth-layer failures return the standard
            `{success: false, error, message}` envelope (message
            `"Authentication verification failed"`); an unhandled database error
            in the handler itself instead returns a framework-default
            `{statusCode, error, message}` body without `success`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Internal Server Error
                message: Authentication verification failed
  /api/v1/webhooks/subscriptions/{id}:
    delete:
      operationId: deleteWebhookSubscription
      tags:
        - Webhooks
      summary: Delete a webhook subscription
      description: >-
        Deletes a webhook subscription, stopping future deliveries to its
        endpoint.


        **Permission:** `webhook:write` (cookie sessions must send
        `X-CSRF-Token`; write permissions are subscription-gated, `402`).


        The lookup is tenant-scoped: an `id` that exists but belongs to another
        workspace returns the same `404` with message `"Webhook subscription not
        found under your tenant"`. Deleting and re-creating is the only way to
        rotate a signing secret. Global rate limit 100 requests/min/IP.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Webhook subscription UUID (as returned at creation / by the list
            endpoint).
          schema:
            type: string
      responses:
        "200":
          description: Subscription deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscriptionDeleteResponse"
              example:
                success: true
                message: Webhook subscription successfully deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: 'Unexpected server error. Auth-layer failures return the standard
            `{success: false, error, message}` envelope (message
            `"Authentication verification failed"`); an unhandled database error
            in the handler itself instead returns a framework-default
            `{statusCode, error, message}` body without `success`.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Internal Server Error
                message: Authentication verification failed
    patch:
      operationId: updateWebhookSubscription
      tags:
        - Webhooks
      summary: Update a webhook subscription (url / events / active)
      description: >-
        Partially updates a subscription: any of `url`, `events`, `isActive`
        (key present = set, omitted = unchanged). A new `url` is re-validated by
        the DNS-resolving SSRF guard (same rules as creation); `events` must be
        a non-empty subset of the allowed filters. The signing `secret` is
        **not** editable here — use `rotate-secret`. An empty body returns the
        current subscription unchanged. The response **strips the secret**.


        **Permission:** `webhook:write`. Cookie sessions must send
        `X-CSRF-Token`; write permissions are subscription-gated (**402** when
        lapsed).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Webhook subscription UUID (as returned at creation / by the list
            endpoint).
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookSubscriptionUpdateRequest"
            example:
              events:
                - passport.sealed
                - passport.recalled
              isActive: false
      responses:
        "200":
          description: Updated subscription (secret stripped).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscriptionUpdateResponse"
              example:
                success: true
                message: Webhook subscription updated
                subscription:
                  id: 9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61
                  tenantId: 7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b
                  url: https://erp.example.com/hooks/opendpp
                  events:
                    - passport.sealed
                    - passport.recalled
                  isActive: false
                  createdAt: 2026-06-12T09:41:00.000Z
                  updatedAt: 2026-06-12T10:05:00.000Z
        "400":
          description: Invalid `url` (not a string / SSRF-rejected), empty/invalid
            `events`, or non-boolean `isActive`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                success: false
                error: Bad Request
                message: events must be a non-empty array of strings
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/webhooks/subscriptions/{id}/rotate-secret:
    post:
      operationId: rotateWebhookSecret
      tags:
        - Webhooks
      summary: Rotate a webhook subscription's signing secret
      description: >-
        Mints a fresh HMAC-SHA256 signing secret for the subscription and
        returns it **once** (the old secret stops validating immediately). Use
        this after a suspected secret leak, or on a rotation schedule. There is
        no request body.


        Update your receiver to verify signatures with the new secret as soon as
        you rotate — deliveries in flight use whichever secret was current when
        signed.


        **Permission:** `webhook:write`. Cookie sessions must send
        `X-CSRF-Token`; write permissions are subscription-gated (**402** when
        lapsed).


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Webhook subscription UUID (as returned at creation / by the list
            endpoint).
          schema:
            type: string
      responses:
        "200":
          description: Rotated. The full row INCLUDING the new `secret` is returned — the
            only time it is shown after rotation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSecretRotateResponse"
              example:
                success: true
                message: Webhook signing secret rotated. Store the new secret now — it is shown
                  only once.
                subscription:
                  id: 9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61
                  tenantId: 7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b
                  url: https://erp.example.com/hooks/opendpp
                  events:
                    - passport.sealed
                  secret: whsec_00000000000000000000000000000000
                  isActive: true
                  createdAt: 2026-06-12T09:41:00.000Z
                  updatedAt: 2026-06-12T10:06:00.000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/webhooks/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags:
        - Webhooks
      summary: List recent webhook delivery attempts (the outbox)
      description: >-
        Returns recent delivery records (the outbox), newest first, for
        debugging endpoint failures. Records are **event-level** (one per
        emitted event, fanned out to all matching subscriptions), not
        per-subscription — `status` reflects the event's overall delivery state
        and `errorMessage` joins per-endpoint errors. Payloads are **not**
        included.


        Filter with `?status=PENDING|DELIVERED|FAILED` and cap with `?limit=`
        (1–200, default 50; a non-numeric value falls back to the default).


        **Permission:** `webhook:read`.


        **Rate limit:** your plan's per-key budget applies — **Growth** 120/min,
        **Scale** 600/min, **Enterprise** unlimited — with a ceiling of 3x that
        rate across all of the workspace's keys. The per-IP ceiling is not the
        binding limit for authenticated calls. Standard `x-ratelimit-*` headers;
        **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: status
          in: query
          required: false
          description: Filter by delivery state. Unrecognized values are ignored (no
            filter applied).
          schema:
            type: string
            enum:
              - PENDING
              - DELIVERED
              - FAILED
        - name: limit
          in: query
          required: false
          description: Max records to return.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        "200":
          description: Recent delivery records, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeliveriesResponse"
              example:
                success: true
                count: 2
                deliveries:
                  - id: 1a2b3c4d-0001-4a3c-9d6a-1e0a4f3a7b21
                    event: passport.sealed
                    status: DELIVERED
                    retryCount: 0
                    lastAttempt: 2026-06-12T09:42:00.000Z
                    nextRetryAt: null
                    errorMessage: null
                    createdAt: 2026-06-12T09:41:59.000Z
                  - id: 1a2b3c4d-0002-4a3c-9d6a-1e0a4f3a7b21
                    event: passport.ingested
                    status: FAILED
                    retryCount: 2
                    lastAttempt: 2026-06-12T09:50:00.000Z
                    nextRetryAt: 2026-06-12T10:20:00.000Z
                    errorMessage: "https://erp.example.com/hooks/opendpp: HTTP 503"
                    createdAt: 2026-06-12T09:41:00.000Z
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/webhooks/subscriptions/{id}/test:
    post:
      operationId: testWebhookSubscription
      tags:
        - Webhooks
      summary: Send a signed test event to a subscription
      description: >-
        Delivers a single **signed sample** event to the subscription's URL
        right now and reports the outcome — use it to confirm your endpoint is
        reachable and that your signature verification works, without waiting
        for a real passport event. The payload is a representative public
        JSON-LD passport document marked `_test: true`; it is signed exactly
        like a production delivery (HMAC-SHA256 over `${timestamp}.${body}`).
        The event type is a concrete value from the subscription's filter (the
        `*` wildcard is skipped; defaults to `passport.sealed`).


        **Permission:** `webhook:write`. **Rate limit:** your plan's per-key
        budget applies — **Growth** 120/min, **Scale** 600/min, **Enterprise**
        unlimited — with a ceiling of 3x that rate across all of the workspace's
        keys. The per-IP ceiling is not the binding limit for authenticated
        calls. Standard `x-ratelimit-*` headers; **429** carries `Retry-After`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Webhook subscription UUID (as returned at creation / by the list
            endpoint).
          schema:
            type: string
      responses:
        "200":
          description: The test delivery was attempted (check `delivered`/`statusCode` for
            the receiver's response).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookTestResult"
              example:
                success: true
                event: passport.sealed
                url: https://erp.example.com/hooks/opendpp
                delivered: true
                statusCode: 200
                error: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
webhooks:
  passport.ingested:
    post:
      operationId: passportIngestedWebhook
      tags:
        - Webhooks
      summary: Passport created or first published
      description: >-
        Sent when a passport becomes active for the first time: a non-draft
        `POST /api/v1/passports` create, the **first publish** of a draft via
        `PUT /api/v1/passports/{id}`, or each successfully ingested row of `POST
        /api/v1/passports/bulk`. Draft creation does NOT emit it, non-publish
        updates and deletes emit nothing, and **AAS ingestion (`POST
        /api/v1/passports/aas/ingest`) emits no webhook events at all**. The
        payload is the freshly created passport: `status: "ACTIVE"` with
        `digitalSeal` and `proof` still `null` (unless a previously sealed draft
        is re-published). Emission is enqueued transactionally with the passport
        write (outbox pattern) and delivered asynchronously to every active
        subscription whose filter contains `passport.ingested` or `*`.


        **Delivery contract** (sender `User-Agent: OpenDPP-Webhook-Outbox/1.0`):

        - The body is a JSON **envelope** `{ id, type, created, data }`: `data`
        is the **public (redacted) JSON-LD passport document**, `type` is the
        event name (also in the `X-OpenDPP-Event` header), and `id` is the
        stable delivery id (also in the `X-OpenDPP-Delivery` header, constant
        across retries). Owner-tier fields (facility street address, restricted
        metadata values) are never included. The owner-only `facilityDetails`
        metadata key **always appears with the masked value `[REDACTED -
        Privileged Access Required]`** — in `metadata`, flattened at top level,
        and in the `@context` term map — even when the passport never set that
        key; restricted metadata keys likewise appear masked, with their true
        leaf hashes preserved in `proof.redactedLeaves` once sealed.

        - **Success = any HTTP 2xx** returned within the **5-second** timeout.
        Redirects are **never followed** — a 3xx counts as failure. The response
        body is ignored.

        - **Retries:** up to **5 delivery attempts** total. Failed attempts 1–4
        schedule the next attempt with exponential backoff — ~1 min, 5 min, 30
        min, then 2 h after the previous failure. The **5th failed attempt
        dead-letters the event** (no further deliveries) and the workspace
        receives an in-app notification.

        - **Per-subscription dedup:** endpoints that already returned 2xx are
        not re-POSTed when the same event is retried for sibling subscriptions.
        Still treat delivery as at-least-once (a 2xx the sender fails to record
        can re-deliver), but the **`X-OpenDPP-Delivery`** id is STABLE across
        retries, so deduplicate on it for exactly-once.

        - **Signature verification:** compute `HMAC-SHA256(secret,
        X-OpenDPP-Timestamp + "." + rawBody)` keyed with the FULL `whsec_…`
        secret (including the prefix), hex-encode lowercase,
        constant-time-compare with `X-OpenDPP-Signature` (bare hex — no
        `v1=`/`sha256=` prefix). Verify over the **raw body bytes** before any
        JSON parsing. Reject timestamps older than ~5 minutes; the timestamp and
        signature are **re-minted on every retry attempt**, so retried
        deliveries always carry a fresh, valid pair.
      security: []
      parameters:
        - name: X-OpenDPP-Event
          in: header
          required: true
          description: Event type (also present as `type` in the body envelope).
          schema:
            type: string
            const: passport.ingested
        - $ref: "#/components/parameters/WebhookDeliveryHeader"
        - $ref: "#/components/parameters/WebhookTimestampHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookUserAgentHeader"
      requestBody:
        required: true
        description: Webhook envelope `{id,type,created,data}`; `data` is the public
          JSON-LD passport document.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelope"
            example:
              id: evt_5e8d2c479a1b4f63
              type: passport.ingested
              created: 2026-06-12T10:15:02.000Z
              data:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                    economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                    manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                    metadata: https://opendpp-node.eu/ns/dpp#metadata
                    digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                    signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                    status: https://opendpp-node.eu/ns/dpp#status
                    archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                    retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                    category: https://opendpp-node.eu/contexts/dpp/v1#category
                    originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                    batteryChemistry: https://opendpp-node.eu/contexts/dpp/v1#batteryChemistry
                    facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                "@type": DigitalProductPassport
                "@id": https://opendpp-node.eu/01/09501101530003
                id: 9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71
                productId: "09501101530003"
                digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                digitalSeal: null
                signingPublicKey: null
                status: ACTIVE
                archivedAt: null
                retentionUntil: null
                proof: null
                createdAt: 2026-06-12T09:41:00.000Z
                updatedAt: 2026-06-12T09:41:00.000Z
                economicOperator:
                  "@type": EconomicOperator
                  id: 1d4f7a92-3b6e-4c08-8a5d-9e2b7c4f6a13
                  name: Volta Demo Industries GmbH
                  regId: EU-DEFAULT-001
                  role: Manufacturer
                manufacturingFacility:
                  "@type": Facility
                  id: 7c3e9b15-8d2a-4f6c-b1e7-0a5d4c8f2e96
                  gln: "0950110153007"
                  name: Demo Gigafactory One
                  activity: Cell manufacturing
                  country: DE
                metadata:
                  category: batteries
                  originCountry: DE
                  batteryChemistry: LFP
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                category: batteries
                originCountry: DE
                batteryChemistry: LFP
                facilityDetails: "[REDACTED - Privileged Access Required]"
      responses:
        2XX:
          description: Your endpoint acknowledges the delivery. Any 2xx within 5 seconds
            marks this subscription delivered; the response body is ignored.
        default:
          description: Any non-2xx status (including 3xx — redirects are never followed),
            a response slower than 5 seconds, or a connection error counts as a
            failed attempt. Up to 5 attempts total with ~1m/5m/30m/2h backoff
            between attempts; the 5th failed attempt dead-letters the event.
  passport.sealed:
    post:
      operationId: passportSealedWebhook
      tags:
        - Webhooks
      summary: Passport sealed with an advanced electronic seal
      description: >-
        Sent when a passport is sealed via `POST /api/v1/passports/{id}/seal`,
        transactionally with the seal write. The payload carries the populated
        `digitalSeal`, `signingPublicKey`, and the `proof` block: `merkleRoot`
        always; an `x5c` certificate chain binding the signing key to the
        tenant's legal identity **when the tenant's signing key has an issued
        chain**; an **optional** `rfc3161` trusted timestamp; and
        `redactedLeaves` hashes **when the passport carries masked metadata
        keys**. Delivered to every active subscription whose filter contains
        `passport.sealed` or `*`.


        **Delivery contract** (sender `User-Agent: OpenDPP-Webhook-Outbox/1.0`):

        - The body is a JSON **envelope** `{ id, type, created, data }`: `data`
        is the **public (redacted) JSON-LD passport document**, `type` is the
        event name (also in the `X-OpenDPP-Event` header), and `id` is the
        stable delivery id (also in the `X-OpenDPP-Delivery` header, constant
        across retries). The owner-only `facilityDetails` metadata key **always
        appears with the masked value `[REDACTED - Privileged Access
        Required]`** (in `metadata`, flattened at top level, and in the
        `@context` term map) even when the passport never set that key;
        restricted metadata keys likewise appear masked, with their true leaf
        hashes in `proof.redactedLeaves`.

        - **Success = any HTTP 2xx** within the **5-second** timeout. Redirects
        are **never followed** (3xx = failure). Response body ignored.

        - **Retries:** up to **5 delivery attempts** total. Failed attempts 1–4
        schedule the next attempt ~1 min / 5 min / 30 min / 2 h after the
        previous failure; the **5th failed attempt dead-letters the event** and
        the workspace is notified in-app.

        - **Per-subscription dedup:** endpoints that already returned 2xx are
        not re-POSTed on retries; still treat delivery as at-least-once, but the
        **`X-OpenDPP-Delivery`** id is STABLE across retries, so deduplicate on
        it for exactly-once.

        - **Signature verification:** `HMAC-SHA256(secret, X-OpenDPP-Timestamp +
        "." + rawBody)` keyed with the FULL `whsec_…` secret, lowercase hex,
        constant-time compare against `X-OpenDPP-Signature` (bare hex, no scheme
        prefix). Verify over the **raw body bytes**; reject >~5 min timestamp
        skew; timestamp+signature are re-minted per retry attempt.
      security: []
      parameters:
        - name: X-OpenDPP-Event
          in: header
          required: true
          description: Event type (also present as `type` in the body envelope).
          schema:
            type: string
            const: passport.sealed
        - $ref: "#/components/parameters/WebhookDeliveryHeader"
        - $ref: "#/components/parameters/WebhookTimestampHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookUserAgentHeader"
      requestBody:
        required: true
        description: Webhook envelope `{id,type,created,data}`; `data` is the public
          JSON-LD passport document with seal and proof populated.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelope"
            example:
              id: evt_5e8d2c479a1b4f63
              type: passport.sealed
              created: 2026-06-12T10:15:02.000Z
              data:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                    economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                    manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                    metadata: https://opendpp-node.eu/ns/dpp#metadata
                    digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                    signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                    status: https://opendpp-node.eu/ns/dpp#status
                    archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                    retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                    category: https://opendpp-node.eu/contexts/dpp/v1#category
                    originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                    batteryChemistry: https://opendpp-node.eu/contexts/dpp/v1#batteryChemistry
                    facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                "@type": DigitalProductPassport
                "@id": https://opendpp-node.eu/01/09501101530003
                id: 9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71
                productId: "09501101530003"
                digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                digitalSeal: MEUCIQDx5n0L8q2vY7c3T1k9rWm4ZpB6aH8sJdQfN2eK0uXgRwIgVtY3mC1bL7pDqE5fS9hA2nO4xWzUjMK6cTiPeB8oG1s=
                signingPublicKey: |
                  -----BEGIN PUBLIC KEY-----
                  MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEx7Qb1k2mP4cV9sJh3nT6fL0wR8dZ
                  5yA1uE7gXiK2oB4qNvC6tS9jM8hW0aFrD3pUzGmYLk5eHxOI1cR7vJbT2g==
                  -----END PUBLIC KEY-----
                status: ACTIVE
                archivedAt: null
                retentionUntil: null
                proof:
                  "@type":
                    - MerkleTreeAttestationProof
                  type: MerkleTreeAttestationProof
                  signatureAlgorithm: ECDSA-P256-SHA256-over-MerkleRoot
                  created: 2026-06-12T10:15:02.000Z
                  proofPurpose: assertionMethod
                  verificationMethod: https://opendpp-node.eu/passport/9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71#key-1
                  signatureValue: MEUCIQDx5n0L8q2vY7c3T1k9rWm4ZpB6aH8sJdQfN2eK0uXgRwIgVtY3mC1bL7pDqE5fS9hA2nO4xWzUjMK6cTiPeB8oG1s=
                  publicKeyPem: |
                    -----BEGIN PUBLIC KEY-----
                    MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEx7Qb1k2mP4cV9sJh3nT6fL0wR8dZ
                    5yA1uE7gXiK2oB4qNvC6tS9jM8hW0aFrD3pUzGmYLk5eHxOI1cR7vJbT2g==
                    -----END PUBLIC KEY-----
                  x5c:
                    - MIIBxjCCAWygAwIBAgIUKp3c8f0AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbMAoGCCqGSM49BAMCMCExHzAdBgNVBAMMFk9wZW5EUFAgU2VhbCBJc3N1aW5nIENB
                  merkleRoot: 7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069
                createdAt: 2026-06-12T09:41:00.000Z
                updatedAt: 2026-06-12T10:15:02.000Z
                economicOperator:
                  "@type": EconomicOperator
                  id: 1d4f7a92-3b6e-4c08-8a5d-9e2b7c4f6a13
                  name: Volta Demo Industries GmbH
                  regId: EU-DEFAULT-001
                  role: Manufacturer
                manufacturingFacility:
                  "@type": Facility
                  id: 7c3e9b15-8d2a-4f6c-b1e7-0a5d4c8f2e96
                  gln: "0950110153007"
                  name: Demo Gigafactory One
                  activity: Cell manufacturing
                  country: DE
                metadata:
                  category: batteries
                  originCountry: DE
                  batteryChemistry: LFP
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                category: batteries
                originCountry: DE
                batteryChemistry: LFP
                facilityDetails: "[REDACTED - Privileged Access Required]"
      responses:
        2XX:
          description: Your endpoint acknowledges the delivery. Any 2xx within 5 seconds
            marks this subscription delivered; the response body is ignored.
        default:
          description: Any non-2xx status (including 3xx — redirects are never followed),
            a response slower than 5 seconds, or a connection error counts as a
            failed attempt. Up to 5 attempts total with ~1m/5m/30m/2h backoff
            between attempts; the 5th failed attempt dead-letters the event.
  passport.recalled:
    post:
      operationId: passportRecalledWebhook
      tags:
        - Webhooks
      summary: Passport recalled
      description: >-
        Sent when `PUT /api/v1/passports/{id}/status` transitions a passport to
        `RECALLED`, transactionally with the status write. The payload is the
        public JSON-LD passport with `status: "RECALLED"` (seal/proof fields
        reflect whatever state the passport was in when recalled). Delivered to
        every active subscription whose filter contains `passport.recalled` or
        `*`.


        **Delivery contract** (sender `User-Agent: OpenDPP-Webhook-Outbox/1.0`):

        - The body is a JSON **envelope** `{ id, type, created, data }`: `data`
        is the **public (redacted) JSON-LD passport document**, `type` is the
        event name (also in the `X-OpenDPP-Event` header), and `id` is the
        stable delivery id (also in the `X-OpenDPP-Delivery` header, constant
        across retries). The owner-only `facilityDetails` metadata key **always
        appears with the masked value `[REDACTED - Privileged Access
        Required]`** (in `metadata`, flattened at top level, and in the
        `@context` term map) even when the passport never set that key;
        restricted metadata keys likewise appear masked.

        - **Success = any HTTP 2xx** within the **5-second** timeout. Redirects
        are **never followed** (3xx = failure). Response body ignored.

        - **Retries:** up to **5 delivery attempts** total. Failed attempts 1–4
        schedule the next attempt ~1 min / 5 min / 30 min / 2 h after the
        previous failure; the **5th failed attempt dead-letters the event** and
        the workspace is notified in-app.

        - **Per-subscription dedup:** endpoints that already returned 2xx are
        not re-POSTed on retries; still treat delivery as at-least-once, but the
        **`X-OpenDPP-Delivery`** id is STABLE across retries, so deduplicate on
        it for exactly-once.

        - **Signature verification:** `HMAC-SHA256(secret, X-OpenDPP-Timestamp +
        "." + rawBody)` keyed with the FULL `whsec_…` secret, lowercase hex,
        constant-time compare against `X-OpenDPP-Signature` (bare hex, no scheme
        prefix). Verify over the **raw body bytes**; reject >~5 min timestamp
        skew; timestamp+signature are re-minted per retry attempt.
      security: []
      parameters:
        - name: X-OpenDPP-Event
          in: header
          required: true
          description: Event type (also present as `type` in the body envelope).
          schema:
            type: string
            const: passport.recalled
        - $ref: "#/components/parameters/WebhookDeliveryHeader"
        - $ref: "#/components/parameters/WebhookTimestampHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookUserAgentHeader"
      requestBody:
        required: true
        description: Webhook envelope `{id,type,created,data}`; `data` is the public
          JSON-LD passport document with status RECALLED.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelope"
            example:
              id: evt_5e8d2c479a1b4f63
              type: passport.recalled
              created: 2026-06-12T10:15:02.000Z
              data:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                    economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                    manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                    metadata: https://opendpp-node.eu/ns/dpp#metadata
                    digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                    signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                    status: https://opendpp-node.eu/ns/dpp#status
                    archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                    retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                    category: https://opendpp-node.eu/contexts/dpp/v1#category
                    originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                    batteryChemistry: https://opendpp-node.eu/contexts/dpp/v1#batteryChemistry
                    facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                "@type": DigitalProductPassport
                "@id": https://opendpp-node.eu/01/09501101530003
                id: 9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71
                productId: "09501101530003"
                digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                digitalSeal: null
                signingPublicKey: null
                status: RECALLED
                archivedAt: null
                retentionUntil: null
                proof: null
                createdAt: 2026-06-12T09:41:00.000Z
                updatedAt: 2026-06-12T11:02:45.000Z
                economicOperator:
                  "@type": EconomicOperator
                  id: 1d4f7a92-3b6e-4c08-8a5d-9e2b7c4f6a13
                  name: Volta Demo Industries GmbH
                  regId: EU-DEFAULT-001
                  role: Manufacturer
                manufacturingFacility:
                  "@type": Facility
                  id: 7c3e9b15-8d2a-4f6c-b1e7-0a5d4c8f2e96
                  gln: "0950110153007"
                  name: Demo Gigafactory One
                  activity: Cell manufacturing
                  country: DE
                metadata:
                  category: batteries
                  originCountry: DE
                  batteryChemistry: LFP
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                category: batteries
                originCountry: DE
                batteryChemistry: LFP
                facilityDetails: "[REDACTED - Privileged Access Required]"
      responses:
        2XX:
          description: Your endpoint acknowledges the delivery. Any 2xx within 5 seconds
            marks this subscription delivered; the response body is ignored.
        default:
          description: Any non-2xx status (including 3xx — redirects are never followed),
            a response slower than 5 seconds, or a connection error counts as a
            failed attempt. Up to 5 attempts total with ~1m/5m/30m/2h backoff
            between attempts; the 5th failed attempt dead-letters the event.
  passport.status_updated:
    post:
      operationId: passportStatusUpdatedWebhook
      tags:
        - Webhooks
      summary: Passport status changed (decommission or reactivation)
      description: >-
        Sent when `PUT /api/v1/passports/{id}/status` performs a non-recall
        transition: decommissioning (`DECOMMISSIONED`, which also sets
        `retentionUntil` and starts the retention clock) or reactivation back to
        `ACTIVE`. Subscribe to it directly with the `passport.status_updated`
        filter (or `*`).


        **Delivery contract** (sender `User-Agent: OpenDPP-Webhook-Outbox/1.0`):

        - The body is a JSON **envelope** `{ id, type, created, data }`: `data`
        is the **public (redacted) JSON-LD passport document**, `type` is the
        event name (also in the `X-OpenDPP-Event` header), and `id` is the
        stable delivery id (also in the `X-OpenDPP-Delivery` header, constant
        across retries). The owner-only `facilityDetails` metadata key **always
        appears with the masked value `[REDACTED - Privileged Access
        Required]`** (in `metadata`, flattened at top level, and in the
        `@context` term map) even when the passport never set that key;
        restricted metadata keys likewise appear masked.

        - **Success = any HTTP 2xx** within the **5-second** timeout. Redirects
        are **never followed** (3xx = failure). Response body ignored.

        - **Retries:** up to **5 delivery attempts** total. Failed attempts 1–4
        schedule the next attempt ~1 min / 5 min / 30 min / 2 h after the
        previous failure; the **5th failed attempt dead-letters the event** and
        the workspace is notified in-app.

        - **Per-subscription dedup:** endpoints that already returned 2xx are
        not re-POSTed on retries; still treat delivery as at-least-once, but the
        **`X-OpenDPP-Delivery`** id is STABLE across retries, so deduplicate on
        it for exactly-once.

        - **Signature verification:** `HMAC-SHA256(secret, X-OpenDPP-Timestamp +
        "." + rawBody)` keyed with the FULL `whsec_…` secret, lowercase hex,
        constant-time compare against `X-OpenDPP-Signature` (bare hex, no scheme
        prefix). Verify over the **raw body bytes**; reject >~5 min timestamp
        skew; timestamp+signature are re-minted per retry attempt.
      security: []
      parameters:
        - name: X-OpenDPP-Event
          in: header
          required: true
          description: Event type (also present as `type` in the body envelope).
          schema:
            type: string
            const: passport.status_updated
        - $ref: "#/components/parameters/WebhookDeliveryHeader"
        - $ref: "#/components/parameters/WebhookTimestampHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookUserAgentHeader"
      requestBody:
        required: true
        description: Webhook envelope `{id,type,created,data}`; `data` is the public
          JSON-LD passport document with the new status.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelope"
            example:
              id: evt_5e8d2c479a1b4f63
              type: passport.status_updated
              created: 2026-06-12T10:15:02.000Z
              data:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                    economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                    manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                    metadata: https://opendpp-node.eu/ns/dpp#metadata
                    digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                    signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                    status: https://opendpp-node.eu/ns/dpp#status
                    archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                    retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                    category: https://opendpp-node.eu/contexts/dpp/v1#category
                    originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                    batteryChemistry: https://opendpp-node.eu/contexts/dpp/v1#batteryChemistry
                    facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                "@type": DigitalProductPassport
                "@id": https://opendpp-node.eu/01/09501101530003
                id: 9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71
                productId: "09501101530003"
                digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                digitalSeal: null
                signingPublicKey: null
                status: DECOMMISSIONED
                archivedAt: null
                retentionUntil: 2041-06-12T10:30:00.000Z
                proof: null
                createdAt: 2026-06-12T09:41:00.000Z
                updatedAt: 2026-06-12T10:30:00.000Z
                economicOperator:
                  "@type": EconomicOperator
                  id: 1d4f7a92-3b6e-4c08-8a5d-9e2b7c4f6a13
                  name: Volta Demo Industries GmbH
                  regId: EU-DEFAULT-001
                  role: Manufacturer
                manufacturingFacility:
                  "@type": Facility
                  id: 7c3e9b15-8d2a-4f6c-b1e7-0a5d4c8f2e96
                  gln: "0950110153007"
                  name: Demo Gigafactory One
                  activity: Cell manufacturing
                  country: DE
                metadata:
                  category: batteries
                  originCountry: DE
                  batteryChemistry: LFP
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                category: batteries
                originCountry: DE
                batteryChemistry: LFP
                facilityDetails: "[REDACTED - Privileged Access Required]"
      responses:
        2XX:
          description: Your endpoint acknowledges the delivery. Any 2xx within 5 seconds
            marks this subscription delivered; the response body is ignored.
        default:
          description: Any non-2xx status (including 3xx — redirects are never followed),
            a response slower than 5 seconds, or a connection error counts as a
            failed attempt. Up to 5 attempts total with ~1m/5m/30m/2h backoff
            between attempts; the 5th failed attempt dead-letters the event.
  passport.updated:
    post:
      operationId: passportUpdatedWebhook
      tags:
        - Webhooks
      summary: Passport edited in place
      description: >-
        Sent when an already-published (non-draft) passport's content is edited
        in place via `PUT /api/v1/passports/{id}`, transactionally with the
        update write. This is **distinct from first publish** (that emits
        `passport.ingested`, not this event) and is **never** emitted for sealed
        passports — in-place edits of a sealed passport are rejected with `403`.
        The payload is the updated public JSON-LD passport with `status:
        "ACTIVE"`. Delivered to every active subscription whose filter contains
        `passport.updated` or `*`.


        **Delivery contract** (sender `User-Agent: OpenDPP-Webhook-Outbox/1.0`):

        - The body is a JSON **envelope** `{ id, type, created, data }`: `data`
        is the **public (redacted) JSON-LD passport document**, `type` is the
        event name (also in the `X-OpenDPP-Event` header), and `id` is the
        stable delivery id (also in the `X-OpenDPP-Delivery` header, constant
        across retries). The owner-only `facilityDetails` metadata key **always
        appears with the masked value `[REDACTED - Privileged Access
        Required]`** (in `metadata`, flattened at top level, and in the
        `@context` term map) even when the passport never set that key;
        restricted metadata keys likewise appear masked.

        - **Success = any HTTP 2xx** within the **5-second** timeout. Redirects
        are **never followed** (3xx = failure). Response body ignored.

        - **Retries:** up to **5 delivery attempts** total. Failed attempts 1–4
        schedule the next attempt ~1 min / 5 min / 30 min / 2 h after the
        previous failure; the **5th failed attempt dead-letters the event** and
        the workspace is notified in-app.

        - **Per-subscription dedup:** endpoints that already returned 2xx are
        not re-POSTed on retries; still treat delivery as at-least-once, but the
        **`X-OpenDPP-Delivery`** id is STABLE across retries, so deduplicate on
        it for exactly-once.

        - **Signature verification:** `HMAC-SHA256(secret, X-OpenDPP-Timestamp +
        "." + rawBody)` keyed with the FULL `whsec_…` secret, lowercase hex,
        constant-time compare against `X-OpenDPP-Signature` (bare hex, no scheme
        prefix). Verify over the **raw body bytes**; reject >~5 min timestamp
        skew; timestamp+signature are re-minted per retry attempt.
      security: []
      parameters:
        - name: X-OpenDPP-Event
          in: header
          required: true
          description: Event type (also present as `type` in the body envelope).
          schema:
            type: string
            const: passport.updated
        - $ref: "#/components/parameters/WebhookDeliveryHeader"
        - $ref: "#/components/parameters/WebhookTimestampHeader"
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookUserAgentHeader"
      requestBody:
        required: true
        description: Webhook envelope `{id,type,created,data}`; `data` is the public
          JSON-LD passport document with the edited content.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelope"
            example:
              id: evt_5e8d2c479a1b4f63
              type: passport.updated
              created: 2026-06-12T10:15:02.000Z
              data:
                "@context":
                  - https://opendpp-node.eu/contexts/dpp/v1
                  - DigitalProductPassport: https://opendpp-node.eu/ns/dpp#DigitalProductPassport
                    economicOperator: https://opendpp-node.eu/ns/dpp#economicOperator
                    manufacturingFacility: https://opendpp-node.eu/ns/dpp#manufacturingFacility
                    metadata: https://opendpp-node.eu/ns/dpp#metadata
                    digitalSeal: https://opendpp-node.eu/ns/dpp#digitalSeal
                    signingPublicKey: https://opendpp-node.eu/ns/dpp#signingPublicKey
                    status: https://opendpp-node.eu/ns/dpp#status
                    archivedAt: https://opendpp-node.eu/ns/dpp#archivedAt
                    retentionUntil: https://opendpp-node.eu/ns/dpp#retentionUntil
                    category: https://opendpp-node.eu/contexts/dpp/v1#category
                    originCountry: https://opendpp-node.eu/contexts/dpp/v1#originCountry
                    batteryChemistry: https://opendpp-node.eu/contexts/dpp/v1#batteryChemistry
                    facilityDetails: https://opendpp-node.eu/contexts/dpp/v1#facilityDetails
                "@type": DigitalProductPassport
                "@id": https://opendpp-node.eu/01/09501101530003
                id: 9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71
                productId: "09501101530003"
                digitalLinkUri: https://opendpp-node.eu/01/09501101530003
                digitalSeal: null
                signingPublicKey: null
                status: ACTIVE
                archivedAt: null
                retentionUntil: null
                proof: null
                createdAt: 2026-06-12T09:41:00.000Z
                updatedAt: 2026-06-12T10:30:00.000Z
                economicOperator:
                  "@type": EconomicOperator
                  id: 1d4f7a92-3b6e-4c08-8a5d-9e2b7c4f6a13
                  name: Volta Demo Industries GmbH
                  regId: EU-DEFAULT-001
                  role: Manufacturer
                manufacturingFacility:
                  "@type": Facility
                  id: 7c3e9b15-8d2a-4f6c-b1e7-0a5d4c8f2e96
                  gln: "0950110153007"
                  name: Demo Gigafactory One
                  activity: Cell manufacturing
                  country: DE
                metadata:
                  category: batteries
                  originCountry: DE
                  batteryChemistry: LFP
                  facilityDetails: "[REDACTED - Privileged Access Required]"
                category: batteries
                originCountry: DE
                batteryChemistry: LFP
                facilityDetails: "[REDACTED - Privileged Access Required]"
      responses:
        2XX:
          description: Your endpoint acknowledges the delivery. Any 2xx within 5 seconds
            marks this subscription delivered; the response body is ignored.
        default:
          description: Any non-2xx status (including 3xx — redirects are never followed),
            a response slower than 5 seconds, or a connection error counts as a
            failed attempt. Up to 5 attempts total with ~1m/5m/30m/2h backoff
            between attempts; the 5th failed attempt dead-letters the event.
components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: Tenant API key (`op_dpp_token_…`), created in the Client Console
        (Developers → API keys) and shown once at creation. Keys carry a role,
        optional narrowed permissions, and optional expiry. Session JWTs from
        the Console use the same header; API-key clients are exempt from CSRF
        requirements.
  schemas:
    Error:
      type: object
      description: "Standard error body. Authenticated-API errors include `success:
        false`; some endpoints (and all public resolution errors) omit `success`
        and return only `error` + `message`."
      required:
        - error
        - message
      properties:
        success:
          type: boolean
          description: Always `false` when present. Omitted by public endpoints and some
            self-service endpoints.
        error:
          type: string
          description: Short error title (usually the HTTP reason phrase).
          examples:
            - Bad Request
            - Not Found
            - Validation Failed
        message:
          type: string
          description: Human-readable explanation.
        requestId:
          type: string
          description: Correlation id for this request, also returned as the
            `X-Request-Id` response header on EVERY response. Present in generic
            (server-error / framework) bodies; quote it to support to correlate
            with server logs. Adopts a well-formed inbound `X-Request-Id` if you
            send one.
        code:
          type: string
          description: Optional MACHINE-STABLE error code for the developer-facing
            write/ingest surface (passport / operator / unit / resolver /
            facility / events / webhooks) — branch on this instead of parsing
            `message`. Present on the errors it covers — the `code` enum below
            is the full set — and omitted otherwise.
          enum:
            - OPERATOR_NOT_BOUND
            - OPERATOR_AMBIGUOUS
            - OPERATOR_SCOPE_FORBIDDEN
            - GTIN_CHECK_DIGIT_INVALID
            - GLN_CHECK_DIGIT_INVALID
            - COMPRESSED_DIGITAL_LINK
            - PASSPORT_DUPLICATE
            - PASSPORT_SEALED_IMMUTABLE
            - CATEGORY_IMMUTABLE
            - FACILITY_NOT_FOUND
            - FACILITY_DUPLICATE
            - WEBHOOK_NOT_FOUND
            - WEBHOOK_LIMIT_REACHED
            - WEBHOOK_URL_REJECTED
    ValidationErrorItem:
      type: object
      description: One field-level finding from ESPR category validation. `path` uses
        dot/bracket notation into the metadata object (e.g.
        `materialComposition[0].percentage`).
      required:
        - path
        - message
      properties:
        path:
          type: string
          description: Dot/bracket path of the offending metadata field.
        message:
          type: string
          description: Technical validation message.
        friendlyMessage:
          type: string
          description: Localized, human-friendly explanation (language from `?lang=` or
            `Accept-Language`; 28 languages, default `en`).
    AdvisoryItem:
      type: object
      description: One non-blocking advisory on a response's `warnings[]` (a heads-up
        — the request still succeeded) or `notices[]` (informational — something
        helpful the API did). The `code` is a MACHINE-STABLE handle an interface
        can switch on, map to its own localized string, or link to docs; the
        human `message` (developer-facing) and `friendlyMessage` (end-user,
        localizable) wording may change, but the code will not.
      required:
        - code
        - message
        - friendlyMessage
      properties:
        code:
          type: string
          description: "Stable advisory code. WARNINGS: `NON_GS1_PRODUCT_ID` (the
            productId is not a GS1 GTIN/GRAI → no scannable GS1 link),
            `PII_SHAPE_DETECTED` (metadata looks like personal data),
            `UNIT_NO_SCANNABLE_LINK` (units under a non-GTIN passport have no
            scannable unit link), `DRAFT_DEMOTED` (draft:true took an
            already-published passport offline), `EORI_NOT_FOUND` (a declared
            EORI was not in the EU EOS register). NOTICES:
            `OPERATOR_AUTO_ATTRIBUTED` (operatorId omitted → the workspace's
            first bound operator was used), `GTIN_AUTO_COPIED` (a valid
            GTIN-14/GRAI productId was copied into
            metadata.gtin/metadata.grai)."
          enum:
            - NON_GS1_PRODUCT_ID
            - PII_SHAPE_DETECTED
            - UNIT_NO_SCANNABLE_LINK
            - DRAFT_DEMOTED
            - EORI_NOT_FOUND
            - OPERATOR_AUTO_ATTRIBUTED
            - GTIN_AUTO_COPIED
        path:
          type: string
          description: The field the advisory is about (e.g. `productId`, `draft`,
            `regId`), when applicable.
        message:
          type: string
          description: Developer-facing detail (English).
        friendlyMessage:
          type: string
          description: End-user-facing, localizable summary.
    PassportQuotaError:
      type: object
      description: "402 body for a write blocked by billing. Always carries `error` +
        `message`. A block caused by the subscription tier's published-passport
        CAP additionally sets `code: \"passport_quota_exceeded\"` plus `quota`
        and `upgradeUrl`. A programmatic (API-key) write on a tier without API
        access sets `code: \"api_access_required\"` plus `upgradeUrl` instead.
        Clients distinguish each from a lapsed-subscription 402 (no `code`) and
        can prompt an upgrade."
      required:
        - error
        - message
      properties:
        success:
          type: boolean
          description: Always `false` when present.
        error:
          type: string
          description: Short error title.
          examples:
            - Payment Required
        message:
          type: string
          description: Human-readable explanation.
        code:
          type: string
          description: Machine-readable discriminator. `passport_quota_exceeded` = the
            tier's passport cap is reached; `api_access_required` = a
            programmatic (API-key) write was attempted on a tier without the
            API-access entitlement (use the dashboard session, or upgrade to a
            Growth+ plan); omitted for a lapsed-subscription 402.
          examples:
            - passport_quota_exceeded
            - api_access_required
        quota:
          type: object
          description: "Present only on a `passport_quota_exceeded` block: current usage
            vs the tier cap."
          properties:
            tier:
              type: string
              description: The workspace subscription tier.
            activePassports:
              type: integer
              description: Active (non-draft, non-archived) passports, counted tenant-wide.
            passportLimit:
              type: integer
              description: The tier's passport cap.
        upgradeUrl:
          type: string
          description: Where the workspace owner can upgrade the plan.
    WhoamiResponse:
      description: "The calling credential's identity: its workspace, the resolved
        auth principal and permissions, and active-passport usage against the
        plan quota."
      type: object
      required:
        - success
        - tenant
        - auth
        - usage
      properties:
        success:
          type: boolean
          const: true
        tenant:
          type: object
          required:
            - id
            - name
            - subdomain
            - tier
            - subscriptionStatus
          properties:
            id:
              type: string
              description: Workspace (tenant) id.
            name:
              type: string
              description: Workspace company name.
            subdomain:
              type:
                - string
                - "null"
              description: Workspace subdomain (`<subdomain>.opendpp-node.eu`), or null if
                none is assigned.
            tier:
              type: string
              description: Subscription tier (e.g. `pilot`, `micro`, `starter`, `growth`,
                `scale`, `enterprise`).
            subscriptionStatus:
              type:
                - string
                - "null"
              description: Billing status string (e.g. `active`, `past_due`). When not
                `active`, write operations may return `402`. No amounts or
                processor identifiers are exposed.
        auth:
          type: object
          required:
            - role
            - permissions
            - isApiKeySession
            - operatorId
          properties:
            role:
              type: string
              description: The principal's role.
            permissions:
              type: array
              items:
                type: string
              description: Effective permission strings, re-derived server-side from the role
                (never trusted from the token). May include wildcards like
                `operator:*`.
            isApiKeySession:
              type: boolean
              description: "`true` when authenticated with an API key, `false` for a session
                JWT."
            operatorId:
              type:
                - string
                - "null"
              description: The economic operator this credential is scoped to, or `null` for a
                workspace-wide key. A scoped key's writes and reads are
                restricted to this operator.
        usage:
          type: object
          required:
            - activePassports
            - passportLimit
          properties:
            activePassports:
              type: integer
              description: Active (non-draft, non-archived) passports counted against the
                quota — operator-scoped for a scoped key.
            passportLimit:
              type:
                - integer
                - "null"
              description: Tier passport quota, or `null` for an unlimited tier.
    BatteryUnitSerialiseBadRequest:
      description: "The three 400 bodies of unit serialisation: the standard error
        triple, the all-items-failed `Serialisation Failed` report, and the
        framework's default request-rejection body."
      oneOf:
        - $ref: "#/components/schemas/Error"
        - $ref: "#/components/schemas/BatteryUnitSerialisationFailedError"
        - $ref: "#/components/schemas/DefaultRequestRejectionError"
    BatteryUnitEventBadRequest:
      description: "The two 400 bodies of event recording: the standard error triple
        from handler validation, and the framework's default request-rejection
        body for malformed JSON."
      oneOf:
        - $ref: "#/components/schemas/Error"
        - $ref: "#/components/schemas/DefaultRequestRejectionError"
    BatteryUnitStatus:
      type: string
      enum:
        - IN_SERVICE
        - DECOMMISSIONED
        - RECALLED
        - REPURPOSED
        - REMANUFACTURED
        - REUSED
        - WASTE
        - RECYCLED
      description: "Annex XIII battery-status vocabulary (EU Battery Regulation).
        `RECYCLED` means the passport has ceased to exist: the public unit view
        answers 410 Gone whenever `status` is `RECYCLED` **or** `ceasedAt` is
        set. `RECYCLED` is terminal however it is reached: a unit *created* with
        initial status `RECYCLED` has `ceasedAt` stamped at creation, exactly
        like one transitioned there via the events route (the stamp is never
        cleared). A terminal unit is refused as a `predecessorUnitId`, and the
        events endpoint refuses every further event (400 `Terminal Unit
        Status`), so neither `status` nor telemetry can change again."
    BatteryUnitEventType:
      type: string
      enum:
        - SOH_MEASUREMENT
        - CHARGE_CYCLE
        - STATUS_CHANGE
        - NEGATIVE_EVENT
        - OTHER
      description: Per-unit dynamic-data event category (Annex XIII / Art. 77 telemetry).
    BatteryUnitRow:
      type: object
      description: One physical serialised battery — the reads return exactly the
        fields documented here. A `BatteryUnit` is an individual instance of a
        SKU/type-level passport, carrying its real serial in GS1 AI-21.
      properties:
        id:
          type: string
        serialNumber:
          type: string
          pattern: ^[A-Za-z0-9._-]{1,20}$
          description: The battery's real physical serial number (GS1 AI-21 value). 1–20
            URL-safe characters; GS1 recommends ≤ 20.
        digitalLinkUri:
          type: string
          format: uri
          description: "Per-unit GS1 Digital Link:
            `{origin}/{01|8003}/{productId}/21/{serialNumber}` — AI `01` for
            GTIN (and non-GS1 SKUs), `8003` for GRAI. Unique platform-wide."
        passportId:
          type: string
          description: The SKU/type-level passport this unit is an instance of.
        tenantId:
          type: string
          description: Owning tenant id.
        manufacturedAt:
          type:
            - string
            - "null"
          format: date-time
        status:
          $ref: "#/components/schemas/BatteryUnitStatus"
        ceasedAt:
          type:
            - string
            - "null"
          format: date-time
          description: Stamped when the unit enters a terminal status — at creation for a
            unit created with initial status `RECYCLED`, or by the events
            endpoint on the `RECYCLED` transition; never cleared afterwards.
            Non-null means the public unit view is a 410 tombstone and the unit
            is refused as a `predecessorUnitId`.
        predecessorUnitId:
          type:
            - string
            - "null"
          description: "Lineage: the original unit this battery was
            repurposed/remanufactured from (`null` for first-life units)."
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required:
        - id
        - serialNumber
        - digitalLinkUri
        - passportId
        - tenantId
        - manufacturedAt
        - status
        - ceasedAt
        - predecessorUnitId
        - createdAt
        - updatedAt
    BatteryUnitEventRow:
      type: object
      description: "One immutable per-unit telemetry record — the reads return exactly
        the fields documented here. Append-only: no update or delete path
        exists."
      properties:
        id:
          type: string
        batteryUnitId:
          type: string
        tenantId:
          type: string
        eventType:
          $ref: "#/components/schemas/BatteryUnitEventType"
        stateOfHealth:
          type:
            - number
            - "null"
          minimum: 0
          maximum: 100
          description: State of health, percent.
        cycleCount:
          type:
            - integer
            - "null"
          minimum: 0
          description: Cumulative full-equivalent cycles (truncated to an integer on write).
        remainingCapacityAh:
          type:
            - number
            - "null"
          minimum: 0
          description: Measured remaining capacity, ampere-hours.
        temperatureC:
          type:
            - number
            - "null"
          minimum: -273.15
          maximum: 10000
          description: Observed temperature, °C.
        payload:
          type:
            - object
            - array
            - "null"
          description: "Free-form additional telemetry/context as persisted: a JSON object
            **or array** (both pass the server's `typeof` check and are stored
            verbatim); `null` when omitted or when the submitted value was
            dropped (any non-object, non-array value)."
        recordedAt:
          type: string
          format: date-time
          description: When the measurement was taken (client-supplied; server time when
            omitted).
        createdAt:
          type: string
          format: date-time
          description: Immutable append timestamp (server-assigned).
      required:
        - id
        - batteryUnitId
        - tenantId
        - eventType
        - stateOfHealth
        - cycleCount
        - remainingCapacityAh
        - temperatureC
        - payload
        - recordedAt
        - createdAt
    BatteryUnitCreateItem:
      type: object
      description: "One unit to serialise. Validation is per-item: an invalid item is
        skipped (its error string collected) without failing the rest of the
        batch."
      properties:
        serialNumber:
          type: string
          pattern: ^[A-Za-z0-9._-]{1,20}$
          description: Required. The battery's real physical serial (GS1 AI-21). Trimmed,
            then must match `^[A-Za-z0-9._-]{1,20}$` (GS1 AI-21's 20-character
            maximum) — a longer serial is rejected at ingest with a per-item
            error. Must be unique within the passport (duplicates are skipped
            with an item error).
        manufacturedAt:
          type:
            - string
            - number
          description: Optional. Any value accepted by JavaScript `Date` parsing (ISO 8601
            recommended; epoch milliseconds also work). Invalid dates skip the
            item.
        status:
          $ref: "#/components/schemas/BatteryUnitStatus"
          description: "Optional initial status. Defaults to `IN_SERVICE`. Creating a unit
            directly with `RECYCLED` records an already-ceased battery:
            `ceasedAt` is stamped at creation, the public view is a 410
            tombstone, and the unit is refused as a `predecessorUnitId`."
          default: IN_SERVICE
        predecessorUnitId:
          type: string
          description: "Optional lineage linkage: id of an existing unit **in your
            tenant** (any passport) that this battery was
            repurposed/remanufactured from. A recycled predecessor is refused —
            the check keys on terminality (`ceasedAt` set or a terminal
            `status`), however the unit reached it. Atomically with creation, a
            `STATUS_CHANGE` event (`{status, successorUnitId, successorSerial}`)
            is appended to the predecessor and its status set to
            `predecessorStatus`."
        predecessorStatus:
          type: string
          enum:
            - REPURPOSED
            - REMANUFACTURED
            - REUSED
          default: REPURPOSED
          description: Optional; only meaningful with `predecessorUnitId`. The status the
            predecessor transitions to. Defaults to `REPURPOSED`.
      required:
        - serialNumber
    SerializeBatteryUnitsRequest:
      description: "Either a single unit object, or a batch wrapper `{units: [...]}`.
        Precedence: when `units` is present **and is an array** it is used;
        otherwise the whole body is treated as one unit. (`anyOf`, not `oneOf`:
        a body carrying both a top-level `serialNumber` and a `units` array
        matches both shapes and is accepted by the server — `units` wins.)"
      anyOf:
        - $ref: "#/components/schemas/BatteryUnitCreateItem"
        - type: object
          properties:
            units:
              type: array
              items:
                $ref: "#/components/schemas/BatteryUnitCreateItem"
              minItems: 1
              maxItems: 200
              description: 1–200 units per request. An empty array is rejected with 400 `Bad
                Request`; more than 200 likewise.
          required:
            - units
    SerializeBatteryUnitsResponse:
      type: object
      description: "Returned (201) when at least one unit was created. Partial success
        is possible: skipped items are reported in `errors` while `units` holds
        the created rows."
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          description: E.g. `Serialised 2 individual unit(s)` or `Serialised 1 individual
            unit(s), skipped 1`.
        count:
          type: integer
          minimum: 1
          description: Number of units actually created (equals `units.length`).
        units:
          type: array
          items:
            $ref: "#/components/schemas/BatteryUnitRow"
        errors:
          type: array
          items:
            type: string
          description: Present only when some items were skipped — one plain-English
            string per skipped item, generally prefixed `[<serialNumber>]`.
        warnings:
          type: array
          description: Non-blocking advisories. Carries a single note when the passport's
            `productId` is NOT a GS1 GTIN — the created units then have no
            scannable GS1 unit Digital Link (`/01/{gtin}/21/{serial}`) and
            resolve only via `/unit/{id}`. Empty `[]` for a GTIN-keyed passport.
          items:
            $ref: "#/components/schemas/AdvisoryItem"
      required:
        - success
        - message
        - count
        - units
        - warnings
    BatteryUnitSerialisationFailedError:
      type: object
      description: "400 body when **every** item in the serialisation batch failed.
        Note: `errors` is an array of plain strings and there is **no `message`
        field** (unlike the standard error triple)."
      properties:
        success:
          type: boolean
          const: false
        error:
          type: string
          const: Serialisation Failed
        errors:
          type: array
          items:
            type: string
          minItems: 1
      required:
        - success
        - error
        - errors
    BatteryUnitListResponse:
      description: A page of the serialised battery units recorded under one passport,
        with the paging envelope.
      type: object
      properties:
        success:
          type: boolean
          const: true
        count:
          type: integer
          description: Number of items returned in THIS page (≤ `limit`).
        productId:
          type: string
          description: The passport's caller-supplied product identifier (GTIN-14 / GRAI /
            SKU).
        units:
          type: array
          items:
            $ref: "#/components/schemas/BatteryUnitRow"
          description: All units of the passport, `createdAt` DESC.
        page:
          type: integer
          description: 1-based page number returned.
        limit:
          type: integer
          description: Effective page size (default 100, max 200).
        total:
          type: integer
          description: Total items matching across all pages.
        totalPages:
          type: integer
          description: Total number of pages (≥ 1).
      required:
        - success
        - count
        - productId
        - units
        - page
        - limit
        - total
        - totalPages
    BatteryUnitDynamicDataEvent:
      type: object
      description: One telemetry event in the JSON-LD `dynamicData` history
        (privileged view only).
      properties:
        "@type":
          type: string
          const: BatteryUnitEvent
        eventType:
          $ref: "#/components/schemas/BatteryUnitEventType"
        stateOfHealth:
          type:
            - number
            - "null"
        cycleCount:
          type:
            - integer
            - "null"
        remainingCapacityAh:
          type:
            - number
            - "null"
        temperatureC:
          type:
            - number
            - "null"
        payload:
          type:
            - object
            - array
            - "null"
          description: The persisted free-form payload — a JSON object **or array**
            (arrays pass the write-path `typeof` check), `null` when absent.
        recordedAt:
          type: string
          format: date-time
      required:
        - "@type"
        - eventType
        - stateOfHealth
        - cycleCount
        - remainingCapacityAh
        - temperatureC
        - payload
        - recordedAt
    BatteryUnitJsonLd:
      type: object
      description: "JSON-LD document for one serialised battery unit, **privileged
        tenant view** (`isPrivileged=true`): includes `currentState` +
        `dynamicData` telemetry (restricted to legitimate-interest
        holders/authorities on the public view, where a `restrictedData` marker
        appears instead — never on this endpoint)."
      properties:
        "@context":
          type: array
          description: "JSON-LD context: the shared
            `https://opendpp-node.eu/contexts/dpp/v1` IRI plus an inline term
            map for the battery-unit vocabulary."
          items:
            type:
              - string
              - object
        "@type":
          type: string
          const: BatteryUnit
        "@id":
          type: string
          format: uri
          description: The unit's GS1 Digital Link URI (same value as `digitalLinkUri`).
        id:
          type: string
        serialNumber:
          type: string
          pattern: ^[A-Za-z0-9._-]{1,20}$
        digitalLinkUri:
          type: string
          format: uri
        status:
          $ref: "#/components/schemas/BatteryUnitStatus"
        manufacturedAt:
          type:
            - string
            - "null"
          format: date-time
        repurposedFrom:
          oneOf:
            - $ref: "#/components/schemas/BatteryUnitLineageRef"
            - type: "null"
          description: Predecessor link. **Always `null` on `GET /api/v1/units/{id}`** —
            the authenticated handler does not load the lineage relation; use
            the public resolver `GET /unit/{id}` to see resolved lineage.
        successorUnits:
          type: array
          items:
            $ref: "#/components/schemas/BatteryUnitLineageRef"
          description: Units repurposed/remanufactured from this one. **Always `[]` on
            `GET /api/v1/units/{id}`** (relation not loaded; see
            `repurposedFrom`).
        ofModel:
          $ref: "#/components/schemas/PublicPassportJsonLd"
          description: "The SKU/type-level passport this unit is an instance of. On this
            authenticated endpoint it is rendered in the **owner (privileged,
            unredacted) variant**: legitimate-interest-tier metadata keys and
            owner-only keys (e.g. `facilityDetails`) are NOT masked, unlike the
            anonymous public document this schema describes. The passport's own
            `@context` inline map always carries 9 fixed terms
            (DigitalProductPassport, economicOperator, manufacturingFacility,
            metadata, digitalSeal, signingPublicKey, status, archivedAt,
            retentionUntil) plus one dynamically generated term per metadata
            key."
        currentState:
          oneOf:
            - $ref: "#/components/schemas/BatteryUnitCurrentState"
            - type: "null"
        dynamicData:
          type: array
          items:
            $ref: "#/components/schemas/BatteryUnitDynamicDataEvent"
          maxItems: 500
          description: Full telemetry history, newest first by `recordedAt`, capped at the
            500 most recent events.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required:
        - "@context"
        - "@type"
        - "@id"
        - id
        - serialNumber
        - digitalLinkUri
        - status
        - manufacturedAt
        - repurposedFrom
        - successorUnits
        - ofModel
        - currentState
        - dynamicData
        - createdAt
        - updatedAt
    RecordBatteryUnitEventRequest:
      type: object
      description: One telemetry record. All measurements are optional and
        independently nullable; numeric ranges are enforced with 400 on
        violation.
      properties:
        eventType:
          $ref: "#/components/schemas/BatteryUnitEventType"
        stateOfHealth:
          type:
            - number
            - "null"
          minimum: 0
          maximum: 100
          description: State of health, percent (0–100).
        cycleCount:
          type:
            - number
            - "null"
          minimum: 0
          maximum: 9007199254740991
          description: Cumulative full-equivalent cycles. Fractional values are accepted
            but truncated to an integer before persisting.
        remainingCapacityAh:
          type:
            - number
            - "null"
          minimum: 0
          maximum: 9007199254740991
          description: Remaining capacity, ampere-hours.
        temperatureC:
          type:
            - number
            - "null"
          minimum: -273.15
          maximum: 10000
          description: Observed temperature, °C.
        payload:
          type:
            - object
            - array
            - "null"
          description: Free-form JSON with additional telemetry/context. Objects **and
            arrays** pass the server's `typeof` check and are persisted
            verbatim; any other value (string, number, boolean) — and an
            explicit `null` — is silently dropped and stored as `null`.
        recordedAt:
          type:
            - string
            - number
          description: When the measurement was taken. Any value accepted by JavaScript
            `Date` parsing (ISO 8601 recommended; epoch milliseconds also work);
            400 if unparseable. Defaults to the server's current time.
        status:
          $ref: "#/components/schemas/BatteryUnitStatus"
          description: Optional status transition, applied to the unit in the same
            transaction when it differs from the current status (works with any
            `eventType`; conventionally paired with `STATUS_CHANGE`).
            Transitioning to `RECYCLED` stamps `ceasedAt` (if not already set;
            never cleared) and turns the public unit view into a 410 tombstone;
            `RECYCLED` is terminal — once the unit's status is `RECYCLED` every
            further event is refused (400 `Terminal Unit Status`), so the status
            can never change again.
      required:
        - eventType
    RecordBatteryUnitEventResponse:
      description: Confirmation that a dynamic-data record was appended to a battery
        unit, echoing the stored event.
      type: object
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          const: Dynamic data recorded
        event:
          $ref: "#/components/schemas/BatteryUnitEventRow"
      required:
        - success
        - message
        - event
    BatteryUnitEventListResponse:
      description: One page of a battery unit's append-only dynamic-data history,
        newest first.
      type: object
      properties:
        success:
          type: boolean
          const: true
        count:
          type: integer
          minimum: 0
          maximum: 500
          description: Equals `events.length`; never exceeds the page `limit`.
        serialNumber:
          type: string
          description: The unit's physical serial (GS1 AI-21 value).
        nextCursor:
          type:
            - string
            - "null"
          description: Opaque cursor for the next (older) page — pass it as the `cursor`
            query parameter. `null` when this page ends the history.
        events:
          type: array
          items:
            $ref: "#/components/schemas/BatteryUnitEventRow"
          maxItems: 500
          description: Newest first by `recordedAt` (ties broken by `id`), at most one
            page (`limit`) per response.
      required:
        - success
        - count
        - serialNumber
        - nextCursor
        - events
    BulkBatteryUnitEventsRequest:
      description: A batch of telemetry records for one unit. Telemetry only — a
        record carrying `status` is refused per-item; status transitions go
        through the single-event endpoint.
      type: object
      required:
        - events
      properties:
        events:
          type: array
          minItems: 1
          maxItems: 500
          items:
            type: object
            required:
              - eventType
            properties:
              eventType:
                $ref: "#/components/schemas/BatteryUnitEventType"
              stateOfHealth:
                type:
                  - number
                  - "null"
                minimum: 0
                maximum: 100
                description: State of Health, %.
              cycleCount:
                type:
                  - number
                  - "null"
                minimum: 0
                description: Cumulative full-equivalent cycles; truncated to an integer before
                  persisting.
              remainingCapacityAh:
                type:
                  - number
                  - "null"
                minimum: 0
                description: Measured remaining capacity, Ah.
              temperatureC:
                type:
                  - number
                  - "null"
                minimum: -273.15
                maximum: 10000
                description: Observed temperature, °C.
              payload:
                type:
                  - object
                  - array
                  - "null"
                description: Free-form context, persisted verbatim; any non-object, non-array
                  value is dropped (stored as `null`).
              recordedAt:
                type: string
                format: date-time
                description: When the measurement was taken; defaults to server time when
                  omitted.
    BulkBatteryUnitEventsResponse:
      description: The partial-success report of a bulk telemetry ingest.
      type: object
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
        count:
          type: integer
          minimum: 1
          maximum: 500
          description: How many records were accepted — equals `events.length`.
        events:
          type: array
          items:
            $ref: "#/components/schemas/BatteryUnitEventRow"
          maxItems: 500
          description: The persisted rows, in the order the accepted records appeared in
            the request.
        errors:
          type: array
          items:
            type: string
          description: Skipped records as `[index]`-prefixed reasons; empty when the whole
            batch was accepted.
      required:
        - success
        - message
        - count
        - events
        - errors
    FacilityRow:
      type: object
      description: "A facility (GS1 GLN) master-data row, exactly as stored. Returned
        in full to the owning tenant. Public exposure in passport documents
        differs by format: the *JSON-LD* document exposes `id`, `gln`, `name`,
        `activity` and `country` of a linked facility; the *AAS* export emits
        only the GLN, name and country
        (`manufacturingFacilityGln`/`Name`/`Country`). `streetAddress`, `city`
        and `postalCode` are emitted only to the owning/bound tenant in both
        formats."
      required:
        - id
        - gln
        - name
        - activity
        - streetAddress
        - city
        - postalCode
        - country
        - operatorId
        - tenantId
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          description: Facility id (UUID).
        gln:
          type: string
          pattern: ^[0-9]{13}$
          description: GS1 Global Location Number — 13 digits with a valid GS1 modulo-10
            check digit. Unique platform-wide. Immutable after registration (it
            is the resolvable UFI).
        name:
          type: string
          description: Facility display name (trimmed, non-empty).
        activity:
          type:
            - string
            - "null"
          description: Free-text activity, e.g. "Cell assembly", "Final manufacturing",
            "Recycling". Public in JSON-LD; not emitted in the AAS export.
        streetAddress:
          type:
            - string
            - "null"
          description: "Street address. Owner-only: redacted from public JSON-LD and never
            emitted in AAS."
        city:
          type:
            - string
            - "null"
          description: "City. Owner-only: redacted from public JSON-LD and never emitted
            in AAS."
        postalCode:
          type:
            - string
            - "null"
          description: "Postal code. Owner-only: redacted from public JSON-LD and never
            emitted in AAS."
        country:
          type: string
          pattern: ^[A-Z]{2}$
          description: 2-letter ISO 3166-1 alpha-2 country code, stored uppercase. Public
            in both JSON-LD and AAS.
        operatorId:
          type:
            - string
            - "null"
          description: Id of the owning Economic Operator, or null for a tenant-level
            facility. Set at creation; not updatable via PUT.
        tenantId:
          type: string
          description: Owning tenant workspace id.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    FacilityCreateRequest:
      description: "A facility to register: its GS1 GLN-13, name and country, plus
        optional activity and street address."
      type: object
      required:
        - gln
        - name
        - country
      properties:
        gln:
          type: string
          pattern: ^[0-9]{13}$
          description: "GS1 GLN-13. Trimmed, then validated: exactly 13 digits with a
            valid GS1 modulo-10 check digit. Unique platform-wide (409 on
            duplicate). Immutable after registration."
        name:
          type: string
          minLength: 1
          description: Facility name. Must be a non-empty string; stored trimmed.
        country:
          type: string
          pattern: ^[A-Za-z]{2}$
          description: 2-letter ISO country code (case-insensitive on input; stored
            uppercase).
        activity:
          type: string
          description: Optional activity, e.g. "Cell assembly". Trimmed; empty/whitespace
            is stored as null.
        streetAddress:
          type: string
          description: Optional street address (owner-only in public views). Trimmed;
            empty is stored as null.
        city:
          type: string
          description: Optional city (owner-only in public views). Trimmed; empty is
            stored as null.
        postalCode:
          type: string
          description: Optional postal code (owner-only in public views). Trimmed; empty
            is stored as null.
        operatorId:
          type: string
          description: Optional owning Economic Operator id. Must be bound to your tenant
            workspace (403 otherwise). Empty/whitespace is treated as absent.
            Operator-scoped API keys may only use their own operator id (it is
            applied automatically when omitted).
    FacilityUpdateRequest:
      type: object
      description: 'Partial update. `gln` and `operatorId` are immutable — if present
        they are silently ignored, as is any unknown key. For
        `activity`/`streetAddress`/`city`/`postalCode` the *presence* of the key
        matters: the value is stringified and trimmed, and anything that trims
        to empty (null, "", or a whitespace-only string) clears the field to
        null — the same normalization as POST.'
      properties:
        name:
          type: string
          minLength: 1
          description: New name. Applied only when a non-empty string; empty/whitespace or
            non-string values are silently ignored (the name can never be
            cleared).
        activity:
          type:
            - string
            - "null"
          description: New activity, or null/"" to clear. A whitespace-only string is
            stored as "" (see schema description).
        streetAddress:
          type:
            - string
            - "null"
          description: New street address, or null/"" to clear. A whitespace-only string
            is stored as "" (see schema description).
        city:
          type:
            - string
            - "null"
          description: New city, or null/"" to clear. A whitespace-only string is stored
            as "" (see schema description).
        postalCode:
          type:
            - string
            - "null"
          description: New postal code, or null/"" to clear. A whitespace-only string is
            stored as "" (see schema description).
        country:
          type: string
          pattern: ^[A-Za-z]{2}$
          description: New 2-letter ISO country code (400 if a string that does not match;
            stored uppercase; non-string values are silently ignored).
    FacilityCreatedEnvelope:
      description: Confirmation that a facility was registered, carrying the stored record.
      type: object
      required:
        - success
        - message
        - facility
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          const: Facility registered successfully
        facility:
          $ref: "#/components/schemas/FacilityRow"
    FacilityListEnvelope:
      description: A page of the workspace's facilities, with the paging envelope.
      type: object
      required:
        - success
        - count
        - facilities
        - page
        - limit
        - total
        - totalPages
      properties:
        success:
          type: boolean
          const: true
        count:
          type: integer
          description: Number of items returned in THIS page (≤ `limit`).
        facilities:
          type: array
          items:
            $ref: "#/components/schemas/FacilityRow"
          description: All facilities in the workspace, sorted by createdAt descending.
            Operator-scoped keys see only their operator's facilities.
        page:
          type: integer
          description: 1-based page number returned.
        limit:
          type: integer
          description: Effective page size (default 100, max 200).
        total:
          type: integer
          description: Total items matching across all pages.
        totalPages:
          type: integer
          description: Total number of pages (≥ 1).
    FacilityEnvelope:
      description: A single facility record.
      type: object
      required:
        - success
        - facility
      properties:
        success:
          type: boolean
          const: true
        facility:
          $ref: "#/components/schemas/FacilityRow"
    FacilityDeletedEnvelope:
      description: Confirmation that a facility was deleted.
      type: object
      required:
        - success
      properties:
        success:
          type: boolean
          const: true
    GrantRevokeForbidden:
      description: "The two 403 bodies of grant revocation: the route-level `{error,
        message}` body (an `AUTHORITY` grant cannot be revoked by the workspace;
        no `success` field) and the standard middleware envelope."
      anyOf:
        - $ref: "#/components/schemas/GrantRouteError"
        - $ref: "#/components/schemas/Error"
    GrantRow:
      type: object
      description: Tenant-facing projection of an access grant. The token hash, issuer
        user id, revoking actor and request IP are never exposed; raw capability
        tokens appear only in the one-time issuance/approval responses. All
        fields are always present (nullable ones serialize as `null`).
      required:
        - id
        - status
        - kind
        - granteeName
        - granteeEmail
        - organization
        - purpose
        - scopeType
        - passportId
        - batteryUnitId
        - issuerType
        - issuerEmail
        - decidedAt
        - decidedBy
        - expiresAt
        - revokedAt
        - lastUsedAt
        - useCount
        - createdAt
        - revocable
      properties:
        id:
          type: string
        status:
          type: string
          enum:
            - PENDING
            - ACTIVE
            - DENIED
            - REVOKED
          description: "`PENDING` = undecided third-party request (no token exists yet);
            `ACTIVE` = usable token; `DENIED` = rejected request; `REVOKED` =
            soft-revoked."
        kind:
          type: string
          enum:
            - LEGITIMATE_INTEREST
            - AUTHORITY
          description: "`LEGITIMATE_INTEREST` (`dpp_li_…` tokens, tenant-issued or
            approved from a request) or `AUTHORITY` (`dpp_auth_…` tokens,
            platform-issued for market surveillance; not tenant-revocable)."
        granteeName:
          type: string
          maxLength: 160
        granteeEmail:
          type:
            - string
            - "null"
          maxLength: 254
        organization:
          type:
            - string
            - "null"
          maxLength: 200
        purpose:
          type:
            - string
            - "null"
          maxLength: 2000
          description: The stated legitimate interest.
        scopeType:
          type: string
          enum:
            - UNIT
            - PASSPORT
            - TENANT
          description: "What the token unlocks on the public resolvers: a single battery
            unit, a single passport, or the whole workspace."
        passportId:
          type:
            - string
            - "null"
          description: Set for `PASSPORT` scope, and also for `UNIT` scope (the unit's
            parent passport). `null` for `TENANT` scope.
        batteryUnitId:
          type:
            - string
            - "null"
          description: Set only for `UNIT` scope.
        issuerType:
          type: string
          enum:
            - TENANT
            - PLATFORM
            - REQUEST
          description: "`TENANT` = issued directly via this API; `REQUEST` = submitted by
            a third party through the hosted request-access page; `PLATFORM` =
            platform-admin-issued (AUTHORITY grants)."
        issuerEmail:
          type:
            - string
            - "null"
          description: E-mail of the issuing user; `null` when issued by an API key or
            created from a public request.
        decidedAt:
          type:
            - string
            - "null"
          format: date-time
          description: When a PENDING request was approved/denied; `null` for direct
            issuances.
        decidedBy:
          type:
            - string
            - "null"
          description: "The deciding actor: a user e-mail, `API_KEY_<keyId>` when decided
            via an API key, or the literal `unknown` in degenerate
            authentication states."
        expiresAt:
          type: string
          format: date-time
          description: Hard expiry; the public resolvers reject the token after this
            instant. PENDING requests carry a provisional 90-day expiry that is
            replaced on approval.
        revokedAt:
          type:
            - string
            - "null"
          format: date-time
        lastUsedAt:
          type:
            - string
            - "null"
          format: date-time
          description: Last successful use on a public resolver (book-kept best-effort).
        useCount:
          type: integer
          description: Successful public-resolver uses (incremented best-effort).
        createdAt:
          type: string
          format: date-time
        revocable:
          type: boolean
          description: "Computed: `false` for `AUTHORITY` grants (platform-managed),
            `true` otherwise."
    GrantRouteError:
      type: object
      description: Error body used by the grants endpoints' route-level errors
        (400/403/404/409). Unlike the standard error envelope, it has NO
        `success` field.
      required:
        - error
        - message
      not:
        required:
          - success
      properties:
        error:
          type: string
          description: HTTP reason phrase, e.g. `Bad Request`, `Not Found`, `Conflict`,
            `Forbidden`.
        message:
          type: string
          description: Human-readable explanation.
    GrantListResponse:
      type: object
      description: List envelope for `GET /api/v1/grants` (paginated).
      required:
        - success
        - grants
        - count
        - page
        - limit
        - total
        - totalPages
      properties:
        success:
          type: boolean
          const: true
        count:
          type: integer
          description: Number of items returned in THIS page (≤ `limit`).
        page:
          type: integer
          description: 1-based page number returned.
        limit:
          type: integer
          description: Effective page size (default 100, max 200).
        total:
          type: integer
          description: Total items matching across all pages.
        totalPages:
          type: integer
          description: Total number of pages (≥ 1).
        grants:
          type: array
          maxItems: 200
          items:
            $ref: "#/components/schemas/GrantRow"
          description: Grants for this page (≤ `limit`), ordered by `status` ascending
            then `createdAt` descending.
    GrantIssuedResponse:
      type: object
      description: Returned by direct issuance (201) and request approval (200).
        `token` is the raw capability token — shown ONCE here (and, on approval,
        in the grantee's inspection-link e-mail); only its SHA-256 hash is
        persisted.
      required:
        - success
        - grant
        - token
      properties:
        success:
          type: boolean
          const: true
        grant:
          $ref: "#/components/schemas/GrantRow"
        token:
          type: string
          pattern: ^dpp_li_[0-9a-f]{32}$
          description: "Legitimate-interest capability token (`dpp_li_` + 32 lowercase
            hex). Present it to public resolution endpoints as `Authorization:
            Bearer <token>` or `?grant=<token>`. Treat like a password — it
            cannot be retrieved again."
    GrantDecisionResponse:
      type: object
      description: "Returned by deny and revoke: the updated grant, no token."
      required:
        - success
        - grant
      properties:
        success:
          type: boolean
          const: true
        grant:
          $ref: "#/components/schemas/GrantRow"
    CreateGrantRequest:
      type: object
      description: Direct-issuance body. Over-length strings are silently truncated to
        the documented maximum, not rejected; unknown fields are ignored. The
        grant kind is always `LEGITIMATE_INTEREST` — there is no `kind`/`type`
        field.
      required:
        - granteeName
        - scopeType
        - expiresAt
      properties:
        granteeName:
          type: string
          minLength: 1
          maxLength: 160
          description: Required (whitespace-only is rejected as missing). Truncated to 160
            characters.
        granteeEmail:
          type: string
          format: email
          maxLength: 254
          description: Optional. Truncated to 254 characters, then must match a basic
            e-mail pattern (`x@y.z`) or the request fails with 400 `granteeEmail
            is invalid`. Stored as given (not lowercased).
        organization:
          type: string
          maxLength: 200
          description: Optional. Truncated to 200 characters.
        purpose:
          type: string
          maxLength: 2000
          description: Optional stated legitimate interest. Truncated to 2000 characters.
        scopeType:
          type: string
          enum:
            - UNIT
            - PASSPORT
            - TENANT
          description: Required. `UNIT` needs `batteryUnitId`; `PASSPORT` needs
            `passportId`; `TENANT` is workspace-wide. Any other value ⇒ 400.
        passportId:
          type: string
          maxLength: 64
          description: Required when `scopeType` is `PASSPORT`; ignored otherwise. Must be
            a non-DRAFT passport in this workspace — a missing, cross-tenant, or
            `DRAFT` id (or omitting the field entirely) returns 404.
        batteryUnitId:
          type: string
          maxLength: 64
          description: Required when `scopeType` is `UNIT`; ignored otherwise. Must be a
            battery unit in this workspace — a missing or cross-tenant id (or
            omitting the field entirely) returns 404.
        expiresAt:
          type: string
          format: date-time
          description: Required. Date parsing is lenient (some non-ISO values may be
            accepted) — ISO 8601 is strongly recommended. Must be in the future
            and at most 366 days from now.
    ApproveGrantRequest:
      type: object
      description: Approval body — only the final expiry is supplied; everything else
        comes from the original request.
      required:
        - expiresAt
      properties:
        expiresAt:
          type: string
          format: date-time
          description: Required. Date parsing is lenient (some non-ISO values may be
            accepted) — ISO 8601 is strongly recommended. Must be in the future
            and at most 366 days from now. Replaces the request's provisional
            90-day expiry.
    OperatorMinimalErrorResponse:
      description: "An operator-endpoint error: either the route's minimal `{error,
        message}` body (no `success` field) or the standard middleware
        envelope."
      anyOf:
        - $ref: "#/components/schemas/OperatorMinimalError"
        - $ref: "#/components/schemas/Error"
    OperatorRow:
      type: object
      description: An economic-operator record (`EconomicOperator`). Operators are
        scoped to your workspace (each workspace keeps its own row for a given
        `regId`). Returned verbatim from the database (no field stripping);
        nullable fields are serialized as `null`.
      required:
        - id
        - name
        - regId
        - regIdScheme
        - role
        - archivedAt
        - createdAt
      properties:
        id:
          type: string
          description: Operator UUID.
        name:
          type: string
          description: Legal/display name of the operator.
        regId:
          type: string
          description: Official registration id (EORI number, VAT id, DUNS, or national
            business-registry id). Unique within your workspace and immutable
            after registration.
        regIdScheme:
          type:
            - string
            - "null"
          enum:
            - EORI
            - VAT
            - DUNS
            - NATIONAL
            - OTHER
            - null
          description: Which kind of registration id `regId` is. `null` = unspecified
            national/business id. When `EORI`, `regId` is guaranteed to satisfy
            the EORI syntax `^[A-Z]{2}[A-Za-z0-9]{1,15}$`.
        role:
          type: string
          description: Supply-chain role, free text — e.g. `"MANUFACTURER"`, `"IMPORTER"`,
            `"RETAILER"`. Defaults to `"MANUFACTURER"` at registration.
        archivedAt:
          type:
            - string
            - "null"
          format: date-time
          description: Soft-delete / cessation-of-trading marker. Non-null = the operator
            is archived (its passports are retained and still publicly
            resolvable).
        createdAt:
          type: string
          format: date-time
    RegisterOperatorRequest:
      description: "An economic operator to register: its legal name and registration
        identifier, with an optional identifier scheme and supply-chain role."
      type: object
      required:
        - name
        - regId
      properties:
        name:
          type: string
          description: Legal/display name. Ignored if your workspace already has an
            operator with this `regId` (the existing record is returned
            instead).
        regId:
          type: string
          description: Official registration id (EORI, VAT, DUNS, or national registry
            id). Unique within your workspace; immutable after registration.
            Fabricated `EORI-MOCK…` ids are rejected. When `regIdScheme` is
            `EORI`, must match `^[A-Z]{2}[A-Za-z0-9]{1,15}$` (e.g.
            `DE1234567890`).
        regIdScheme:
          type:
            - string
            - "null"
          description: "Optional declaration of what kind of id `regId` is. Allowed
            values: `EORI`, `VAT`, `DUNS`, `NATIONAL`, `OTHER` — matched
            case-insensitively (uppercased server-side). Any other value is
            rejected with `400`. Omit or send `null` for an unspecified
            national/business id. Ignored when binding to an existing operator."
        role:
          type: string
          default: MANUFACTURER
          description: Supply-chain role, free text — e.g. `MANUFACTURER`, `IMPORTER`,
            `RETAILER`. Defaults to `MANUFACTURER`. Ignored when binding to an
            existing operator.
    RegisterOperatorResponse:
      description: Confirmation that an economic operator was registered, carrying the
        stored record and any non-blocking advisories.
      type: object
      required:
        - success
        - message
        - operator
        - warnings
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          description: Always `"Economic Operator supplier registered successfully"` (also
            when an existing operator was bound rather than created).
        operator:
          $ref: "#/components/schemas/OperatorRow"
        warnings:
          type: array
          description: Non-blocking advisories. Carries a single EORI-not-found note when
            the OPT-IN EORI existence check is enabled and a declared EORI is
            not found in the EU EOS register. Empty `[]` otherwise. Never blocks
            registration.
          items:
            $ref: "#/components/schemas/AdvisoryItem"
    UpdateOperatorRequest:
      type: object
      description: Both fields are optional. Values must be non-empty strings after
        trimming; anything else (missing, non-string, whitespace-only) is
        silently ignored. `regId` and `regIdScheme` cannot be changed. An
        omitted body or an empty object `{}` is accepted and returns the
        unchanged row.
      properties:
        name:
          type: string
          description: New display name (trimmed).
        role:
          type: string
          description: New supply-chain role, free text (trimmed) — e.g. `MANUFACTURER`,
            `IMPORTER`, `RETAILER`.
    UpdateOperatorResponse:
      description: The economic operator as stored after the update.
      type: object
      required:
        - success
        - operator
      properties:
        success:
          type: boolean
          const: true
        operator:
          $ref: "#/components/schemas/OperatorRow"
    DeleteOperatorResponse:
      description: "Outcome of removing an economic operator: whether it was archived
        rather than deleted, and how many of its passports were archived with
        it."
      type: object
      required:
        - success
        - archived
      properties:
        success:
          type: boolean
          const: true
        archived:
          type: boolean
          description: "`true` = the operator was archived (soft-deleted; passports
            retained, restorable). `false` = the operator was hard-deleted (it
            had no passports)."
        archivedPassports:
          type: integer
          minimum: 0
          description: Number of active passports archived alongside the operator. Present
            only on the primary archive path (operator had passports); absent on
            hard deletes and on the foreign-key fallback archive.
    RestoreOperatorResponse:
      description: Outcome of restoring an archived economic operator, including how
        many of its passports were restored.
      type: object
      required:
        - success
        - restoredPassports
      properties:
        success:
          type: boolean
          const: true
        restoredPassports:
          type: integer
          minimum: 0
          description: Number of archived passports returned to the active catalogue
            (`archivedAt` and `retentionUntil` cleared). Passports independently
            DECOMMISSIONED are not restored and not counted.
    RotateTenantKeysResponse:
      description: Confirmation that the workspace's signing key was rotated,
        returning the new public key.
      type: object
      required:
        - success
        - message
        - publicKey
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          description: 'Outcome message. On a first provisioning: `"eIDAS Asymmetric Key
            Pair generated in secure DB custody successfully."` On a rotation, a
            message noting that the previous key is retired but retained in your
            DID document so existing credentials keep verifying.'
        publicKey:
          type: string
          description: The new ECDSA prime256v1 (P-256) public key, PEM-encoded (SPKI,
            `-----BEGIN PUBLIC KEY-----` block, trailing newline). The matching
            private key is held only AES-256-GCM-encrypted in the platform vault
            and is never returned.
    OperatorMinimalError:
      type: object
      description: Minimal error envelope used by the operator/key self-service
        handlers — note the standard `error` key is ABSENT (unlike the shared
        Error schema).
      required:
        - success
        - message
      properties:
        success:
          type: boolean
          const: false
        message:
          type: string
    OperatorListResponse:
      description: The economic operators bound to the calling workspace.
      type: object
      required:
        - success
        - count
        - operators
      properties:
        success:
          type: boolean
          const: true
        count:
          type: integer
          description: Number of operators returned (the list is not paginated).
        operators:
          type: array
          items:
            $ref: "#/components/schemas/OperatorRow"
    OperatorGetResponse:
      description: A single economic operator record.
      type: object
      required:
        - success
        - operator
      properties:
        success:
          type: boolean
          const: true
        operator:
          $ref: "#/components/schemas/OperatorRow"
    PassportCreateBadRequest:
      description: "The 400 bodies of passport creation: an ESPR validation failure
        with per-field `errors[]`, or the standard error triple / pre-handler
        rejection."
      anyOf:
        - $ref: "#/components/schemas/PassportCreateValidationError"
        - $ref: "#/components/schemas/Error"
    PassportCreateValidationError:
      type: object
      title: ESPR validation failure
      required:
        - success
        - error
        - message
        - errors
      properties:
        success:
          type: boolean
          const: false
        error:
          type: string
          const: Validation Failed
        message:
          type: string
        errors:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
          description: Blocking findings. Items produced by the category-validity check
            (`metadata.category` missing/unknown) carry no `friendlyMessage`.
        warnings:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
          description: Omitted entirely when there are no warnings.
    PassportBulkBadRequest:
      description: "The 400 bodies of bulk ingestion: every row failed (`Bulk
        Ingestion Failed`), or the request never reached row processing and
        returns the default request-rejection body."
      oneOf:
        - $ref: "#/components/schemas/PassportBulkFailure"
        - $ref: "#/components/schemas/DefaultRequestRejectionError"
    DefaultRequestRejectionError:
      type: object
      title: Default request-rejection error body
      description: The default 400 body of a request rejected **before the handler
        runs** — a syntactically malformed JSON body, or an envelope (schema)
        violation — so none of the handler-built `{success:false, ...}` shapes
        apply.
      required:
        - statusCode
        - error
        - message
      properties:
        statusCode:
          type: integer
          const: 400
        code:
          type: string
          description: Machine-readable error code, e.g. `FST_ERR_VALIDATION` (an
            envelope/schema violation) or `FST_ERR_CTP_INVALID_JSON_BODY` (a
            syntactically malformed JSON body); may be absent.
          examples:
            - FST_ERR_VALIDATION
        error:
          type: string
          const: Bad Request
        message:
          type: string
    AasIngestBadRequest:
      description: "The 400 bodies of AAS ingestion: the standard error triple (bad
        request / signature verification / ingestion failure), or an ESPR
        validation failure with per-field `errors[]`."
      anyOf:
        - $ref: "#/components/schemas/Error"
        - $ref: "#/components/schemas/AasIngestValidationError"
    AasIngestValidationError:
      type: object
      title: AAS ESPR validation failure
      required:
        - success
        - error
        - message
        - errors
      properties:
        success:
          type: boolean
          const: false
        error:
          type: string
          const: Validation Failed
        message:
          type: string
        errors:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
        warnings:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
          description: Omitted entirely when there are no warnings.
    PassportMetadataInput:
      type: object
      description: "The ESPR product metadata payload. For non-draft ingestion and for
        the validate-only endpoints, `category` is mandatory and must be one of
        the 9 ESPR categories; each category then mandates its own field set
        (e.g. textiles require `fiberComposition`, `careInstructions`, `size`;
        batteries require `batteryCategory`, `chemistry`,
        `electrochemicalCapacity`, `durability`, `recycledContentShare`,
        `carbonFootprint`). For five categories — textiles, batteries,
        electronics, chemicals, construction — the authoritative per-category
        JSON Schema (required fields, value constraints, field help) is served
        live at `GET /api/v1/schemas/{category}`; the other four (cosmetics,
        toys, iron-steel, aluminium) are validated by built-in server-side rules
        and `GET /api/v1/schemas/{category}` returns 404 for them. Cross-field
        rules are enforced on top: `materialComposition` (and textile
        `fiberComposition`) percentages must sum to 100 ±0.1, `originCountry`
        must be a real ISO 3166-1 alpha-2 code, textile hazardous-substance
        concentrations are checked against REACH ppm limits. A documented set of
        supplementary objects (e.g. `technicalProperties`,
        `environmentalFootprint`, `circularityAttributes`, `esgDueDiligence`,
        `detailedPerformance`) produce non-blocking `warnings` instead of
        `errors` when malformed. With `draft: true` (single ingestion only)
        validation is skipped entirely and any object is accepted."
      properties:
        category:
          type: string
          enum:
            - textiles
            - batteries
            - electronics
            - cosmetics
            - toys
            - iron-steel
            - aluminium
            - chemicals
            - construction
          description: "ESPR product category; selects the validation rules. Required
            whenever validation runs (i.e. always, except `draft: true` single
            ingestion)."
        originCountry:
          type: string
          pattern: ^[A-Z]{2}$
          description: ISO 3166-1 alpha-2 country code (validated against the full
            249-code set).
        commodityCode:
          type: string
          pattern: ^[0-9]{4,10}$
          description: Optional HS / TARIC commodity code (4–10 digits). Validated for
            every category when present (a malformed code is a 400). Not
            category-mandated, but required to project the passport into the EU
            DPP registry pointer (ESPR Art. 13).
      additionalProperties: true
    PassportEnrichmentInput:
      type: object
      description: "Optional presentational (non-regulatory) marketing enrichment,
        stored OUTSIDE the ESPR-validated metadata and the Merkle seal; it never
        appears in the JSON-LD passport document. Server-side it is
        whitelist-sanitized rather than rejected: unknown keys are dropped;
        `tagline` is trimmed and capped at 200 chars, `description` at 4000,
        image `caption` at 200, link `label` at 120; at most 24 `images` and 24
        `links` are kept; URLs must be http, https, or mailto (any other scheme,
        e.g. `javascript:` or `data:`, is silently dropped). An enrichment that
        sanitizes down to nothing is stored as null."
      properties:
        tagline:
          type: string
          description: Short marketing tagline (server-capped at 200 chars).
        description:
          type: string
          description: Marketing description (server-capped at 4000 chars).
        images:
          type: array
          description: Up to 24 kept. Items without a valid http/https/mailto `url` are
            dropped.
          items:
            type: object
            properties:
              url:
                type: string
                format: uri
                description: http, https, or mailto only.
              caption:
                type: string
                description: Server-capped at 200 chars.
        links:
          type: array
          description: Up to 24 kept. Items without a valid http/https/mailto `url` are
            dropped; a missing `label` defaults to the URL.
          items:
            type: object
            properties:
              label:
                type: string
                description: Server-capped at 120 chars.
              url:
                type: string
                format: uri
                description: http, https, or mailto only.
      additionalProperties: true
    PassportCreateRequest:
      description: "A passport to create: its product identifier and ESPR category
        metadata, with optional operator and facility binding, a draft flag, and
        enrichment held outside the sealed metadata."
      type: object
      required:
        - productId
        - metadata
      properties:
        productId:
          type: string
          minLength: 1
          description: "Product identifier: a GTIN-14 (exactly 14 digits with a valid GS1
            mod-10 check digit — auto-copied to `metadata.gtin`), a GRAI
            (14-digit numeric asset id with valid check digit + optional up to
            16 alphanumeric serial chars, total 14–30 — auto-copied to
            `metadata.grai`), or a free-form SKU. Determines the GS1 Application
            Identifier (`01` vs `8003`) in the generated Digital Link URI.
            Whitespace-only values are rejected 400. Unique per economic
            operator (409 on duplicate)."
        operatorId:
          type: string
          description: UUID of an EconomicOperator bound to your tenant workspace (403 if
            not bound). Defaults to your workspace's first bound operator.
            Operator-scoped API keys force their own operator (403 on mismatch).
        facilityId:
          type: string
          description: Optional UUID of a Facility (GLN-backed Unique Facility Identifier)
            in your workspace; 400 if not found.
        metadata:
          $ref: "#/components/schemas/PassportMetadataInput"
        draft:
          type: boolean
          default: false
          description: 'When true: skips ALL ESPR validation, stores the passport with
            `status: "DRAFT"` (not publicly resolvable), and emits no webhook.
            Publish later via a validated edit.'
        enrichment:
          $ref: "#/components/schemas/PassportEnrichmentInput"
    PassportIngestCreated:
      type: object
      required:
        - success
        - message
        - passport
        - warnings
        - notices
      description: 201 envelope of `POST /api/v1/passports`. `passport` is the public
        redacted JSON-LD; `warnings`/`notices` are always present (possibly
        empty); `vcReady`/`vcReadyReason` report UNTP Verifiable-Credential
        readiness.
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          description: '"Digital Product Passport successfully validated and ingested", or
            "Draft passport saved" when `draft: true`.'
        passport:
          $ref: "#/components/schemas/PublicPassportJsonLd"
          description: 'The PUBLIC redacted JSON-LD passport document (unsealed at
            creation: `digitalSeal`/`proof` are null). The owner-only metadata
            key `facilityDetails` is always replaced with the literal string
            "[REDACTED - Privileged Access Required]" — even in this
            creator-facing echo, and even when the submitted metadata did not
            contain it. For `category: "batteries"`, the restricted
            legitimate-interest keys `detailedPerformance`, `lifecycleAndInUse`,
            and `circularityAndDisassembly` are masked the same way when
            present.'
        warnings:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
          description: Non-blocking findings — a MIX of ESPR validation warnings (no
            `code`) and machine-coded advisories (a stable `code`, e.g.
            `NON_GS1_PRODUCT_ID`, `PII_SHAPE_DETECTED`). Always present; empty
            for drafts. See `AdvisoryItem` for the coded shape.
        notices:
          type: array
          items:
            $ref: "#/components/schemas/AdvisoryItem"
          description: "Informational advisories about helpful things the API did (always
            coded): `OPERATOR_AUTO_ATTRIBUTED` (operatorId omitted → the
            workspace's sole bound operator used), `GTIN_AUTO_COPIED` (a valid
            GTIN/GRAI copied into metadata.gtin/grai). Always present; empty
            when nothing to note."
        vcReady:
          type: boolean
          description: Whether this passport can emit a UNTP Verifiable Credential — true
            only when a manufacturing facility with a country of production is
            linked (`producedAtFacility` + `countryOfProduction` are required by
            the UNTP DPP schema; a GLN is optional). The passport still
            publishes and resolves as AAS / JSON-LD / HTML regardless.
        vcReadyReason:
          type:
            - string
            - "null"
          description: Null when `vcReady` is true; otherwise a short, actionable reason
            (link a facility with a country of production).
    PassportValidateOnlyRequest:
      description: A metadata payload to validate against its ESPR category rules
        without persisting anything.
      type: object
      required:
        - productId
        - metadata
      properties:
        productId:
          type: string
          minLength: 1
          description: Product identifier (GTIN-14 / GRAI / SKU). Required and checked
            non-empty (whitespace-only → 400), but not otherwise used by the
            dry-run.
        operatorId:
          type: string
          description: Accepted by the body schema but IGNORED by the validate-only
            handlers.
        metadata:
          $ref: "#/components/schemas/PassportMetadataInput"
    PassportValidateOnlyResult:
      type: object
      required:
        - success
        - message
        - category
        - errors
      description: 200 envelope of the validate-only endpoints (only the declared keys
        are emitted).
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          example: Passport metadata payload is valid against the ESPR category data
            schema
        category:
          type: string
          description: Echo of `metadata.category` (or "unknown" if absent).
        errors:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
          maxItems: 0
          description: Always an empty array on 200.
        warnings:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
          description: Non-blocking findings (e.g. malformed supplementary objects like
            `circularityAttributes`). The key is OMITTED entirely when there are
            no warnings — it is never an empty array.
    PassportValidateOnlyError:
      type: object
      required:
        - error
        - message
      description: '400 envelope of the validate-only endpoints. Three variants: ESPR
        validation failure (`error: "Validation Failed"`, with `success`,
        `category`, `errors[]`, optional `warnings[]` — omitted when none;
        category-validity error items carry no `friendlyMessage`);
        whitespace-only `productId` (`error: "Bad Request"`, `category:
        "unknown"`, `errors: []`, no `warnings`); and structural request
        rejections (body-schema violations, malformed JSON), which return only
        `error` + `message`.'
      properties:
        success:
          type: boolean
          const: false
        error:
          type: string
          enum:
            - Bad Request
            - Validation Failed
        message:
          type: string
        category:
          type: string
          description: '`metadata.category` echo, or "unknown" for structural failures.'
        errors:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
        warnings:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
          description: Omitted when there are no warnings.
    PassportBulkRow:
      type: object
      description: 'One bulk-ingestion row. The HTTP layer only requires each row to
        be an object; rows missing `productId` or `metadata`, failing ESPR
        validation, referencing unbound operators/unknown facilities, or
        duplicating an existing `(productId, operatorId)` pair are SKIPPED and
        reported as strings in the response `errors[]` — they never fail the
        whole request (unless every row fails). Bulk rows do not support `draft`
        or `enrichment`, are always created with `status: "ACTIVE"`, and do NOT
        get `metadata.gtin`/`metadata.grai` auto-injected.'
      properties:
        productId:
          type: string
          description: GTIN-14 / GRAI / free-form SKU (required in practice; rows without
            it are skipped with an error string).
        operatorId:
          type: string
          description: Optional EconomicOperator UUID bound to your workspace; defaults to
            the workspace's first bound operator. Operator-scoped API keys force
            their operator.
        facilityId:
          type: string
          description: Optional Facility UUID in your workspace; unknown ids skip the row.
        metadata:
          $ref: "#/components/schemas/PassportMetadataInput"
      additionalProperties: true
    PassportBulkRequest:
      description: A batch of passports to ingest, with optional dry-run preview and
        upsert-on-conflict behaviour.
      type: object
      required:
        - passports
      properties:
        passports:
          type: array
          minItems: 1
          maxItems: 200
          items:
            $ref: "#/components/schemas/PassportBulkRow"
          description: 1–200 rows. The bounds are enforced before any row is processed;
            violations return the default `{statusCode, code, error, message}`
            error body.
        dryRun:
          type: boolean
          description: When `true`, every row is validated and the
            duplicate/operator/facility checks run, but **nothing is written** —
            the response is **200** and reports which rows are OK (`results`) vs
            failed (`errors[]`). Powers a pre-import preview.
        upsert:
          type: boolean
          description: When `true`, a row whose `(productId, operator)` already exists
            **updates** the existing passport instead of being reported as a
            duplicate (a sealed passport is never overwritten). Enables
            idempotent re-import of a corrected catalog.
    PassportBulkResult:
      type: object
      required:
        - success
        - message
        - insertedCount
        - results
      description: 201 partial-success envelope of `POST /api/v1/passports/bulk`.
        Returned whenever at least one row was inserted, even if other rows
        failed. Each result row carries a `vcReady` UNTP Verifiable-Credential
        readiness signal and a per-row non-blocking `warnings[]` (non-GS1
        advisory + PII-shape privacy advisory; empty when the row is clean).
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          description: 'Template: "Bulk CSV ingestion finished. Registered <n> passports,
            skipped <m> rows with errors."'
        insertedCount:
          type: integer
          minimum: 0
          description: Number of rows actually inserted (= `results.length`).
        results:
          type: array
          items:
            type: object
            required:
              - productId
              - digitalLinkUri
              - vcReady
            properties:
              productId:
                type: string
              digitalLinkUri:
                type: string
                format: uri
                description: Generated GS1 Digital Link URI
                  `https://opendpp-node.eu/{01|8003}/{productId}`.
              vcReady:
                type: boolean
                description: Whether this row's passport can emit a UNTP Verifiable Credential —
                  true only when a manufacturing facility with a country of
                  production is linked. On a `dryRun` preview this reflects the
                  EFFECTIVE facility after import (the row's facility, else the
                  existing passport's preserved one).
              vcReadyReason:
                type:
                  - string
                  - "null"
                description: Null when `vcReady` is true; otherwise a short, actionable reason
                  (link a facility with a country of production).
              warnings:
                type: array
                description: Per-row non-blocking advisories — the non-GS1 "no scannable QR"
                  note and the PII-shape privacy advisory. Empty `[]` when the
                  row is clean; never blocks the row.
                items:
                  $ref: "#/components/schemas/AdvisoryItem"
        errors:
          type: array
          items:
            type: string
          description: 'Human-readable per-row failure strings, prefixed `[SKU:
            <productId>]` (or "Missing or invalid productId in spreadsheet
            row"). Present ONLY when at least one row failed — omitted
            otherwise.'
    PassportBulkFailure:
      type: object
      required:
        - success
        - error
        - errors
      description: "400 body of `POST /api/v1/passports/bulk` when EVERY row failed.
        Note: `errors` is an array of STRINGS (not objects) and there is NO
        `message` field."
      properties:
        success:
          type: boolean
          const: false
        error:
          type: string
          const: Bulk Ingestion Failed
        errors:
          type: array
          items:
            type: string
          description: "One human-readable string per failed row, prefixed `[SKU:
            <productId>]` where the productId was readable."
    AasEnvironmentInput:
      type: object
      required:
        - submodels
      description: "An Asset Administration Shell (AAS) JSON Environment — the format
        produced by OpenDPP's AAS export of a passport. MUST contain a submodel
        with `idShort: \"ComplianceMetadata\"` whose `submodelElements` (AAS
        `Property` elements and `SubmodelElementCollection`s) are parsed back
        into the passport metadata object; absence fails 400 `Ingestion Failed`.
        MAY contain an `eidasVerificationSeal` submodel (elements
        `digitalSealHash`, `cryptographicSignature`, `pemPublicKey`) — when
        present, the seal is verified against the tenant's SERVER-HELD signing
        public key (the embedded `pemPublicKey` is never trusted as the
        verification key). Body limit 256 KiB."
      properties:
        assetAdministrationShells:
          type: array
          items:
            type: object
            additionalProperties: true
          description: "AAS shells. The first shell's `assetInformation.specificAssetIds`
            entry with `name: \"productId\"` is the last-resort productId source
            (after `metadata.gtin`/`metadata.grai`/`metadata.productId`)."
        submodels:
          type: array
          items:
            type: object
            additionalProperties: true
          description: Must include the `ComplianceMetadata` submodel; may include
            `eidasVerificationSeal`.
        conceptDescriptions:
          type: array
          items:
            type: object
            additionalProperties: true
      additionalProperties: true
    AasIngestCreated:
      type: object
      required:
        - success
        - message
        - passportId
        - productId
        - isSealed
        - signatureVerified
        - vcReady
        - warnings
      description: 201 envelope of `POST /api/v1/passports/aas/ingest`. Returned for
        both newly created passports and in-place updates of existing UNSEALED
        passports. No webhook event is emitted by this endpoint.
        `vcReady`/`vcReadyReason` report UNTP Verifiable-Credential readiness
        and `warnings` carries the non-GS1 advisory, for parity with `POST
        /api/v1/passports`.
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          const: Digital Product Passport successfully ingested from AAS
        passportId:
          type: string
        productId:
          type: string
        isSealed:
          type: boolean
          description: True when the environment embedded an `eidasVerificationSeal`
            submodel (the seal is then stored on the passport).
        signatureVerified:
          type: boolean
          description: True when the embedded seal verified against the tenant's
            server-held signing public key. Always false for unsealed documents.
            (A sealed-but-unverified document never reaches 201 — it fails 400.)
        vcReady:
          type: boolean
          description: Whether the ingested passport can emit a UNTP Verifiable Credential
            — true only when a manufacturing facility with a country of
            production is linked. AAS ingestion does not set a facility, so a
            newly created passport is false; an in-place update preserves
            whatever facility the existing passport had.
        vcReadyReason:
          type:
            - string
            - "null"
          description: Null when `vcReady` is true; otherwise a short, actionable reason
            (link a facility with a country of production).
        warnings:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
          description: Non-blocking advisories. Always present (empty array when none);
            carries the non-GS1 advisory when the resolved `productId` is not a
            GS1 GTIN-14/GRAI.
    PassportGetNotFound:
      description: "The two 404 bodies of an authenticated passport read: the standard
        workspace-scoped envelope, or the body forwarded from the public
        resolver (no `success` field)."
      anyOf:
        - $ref: "#/components/schemas/Error"
        - $ref: "#/components/schemas/ForwardedResolverError"
    ForwardedResolverError:
      type: object
      description: Forwarded public-resolver body (no `success` field).
      required:
        - error
        - message
      properties:
        error:
          type: string
        message:
          type: string
    PassportGetTooManyRequests:
      description: "The two 429 bodies of an authenticated passport read: the global
        limiter's default body (with `x-ratelimit-*` headers), or the body
        forwarded from the inner public resolver's limiter (no headers)."
      anyOf:
        - $ref: "#/components/schemas/GlobalRateLimitError"
        - $ref: "#/components/schemas/ForwardedResolverRateLimitError"
    GlobalRateLimitError:
      type: object
      description: Global rate-limit plugin default body (with x-ratelimit-* headers).
      required:
        - statusCode
        - error
        - message
      properties:
        statusCode:
          type: integer
        error:
          type: string
        message:
          type: string
    ForwardedResolverRateLimitError:
      type: object
      description: Forwarded public-resolver limiter body (no `success` field, no headers).
      required:
        - error
        - message
      properties:
        error:
          type: string
        message:
          type: string
    PassportUpdateBadRequest:
      description: "The two 400 bodies of a passport update: the standard error
        triple, or an ESPR validation failure (which, unlike creation, carries
        NO `warnings` array)."
      anyOf:
        - $ref: "#/components/schemas/Error"
        - $ref: "#/components/schemas/PassportUpdateValidationError"
    PassportListResponse:
      type: object
      description: "Envelope of GET /api/v1/passports. The response is serialized
        against a declared response schema: top-level keys other than these four
        are stripped. There is NO total count."
      required:
        - success
        - page
        - limit
        - passports
      properties:
        success:
          type: boolean
          description: Always true on 200.
        page:
          type: integer
          minimum: 1
          description: Effective page after server-side clamping (min 1).
        limit:
          type: integer
          minimum: 1
          maximum: 100
          description: Effective page size after server-side clamping (default 10, max 100).
        passports:
          type: array
          items:
            $ref: "#/components/schemas/PassportListItem"
    PassportAasEnvironment:
      type: object
      description: "IDTA Asset Administration Shell environment (returned when
        `Accept` contains `application/aas+json`), role-filtered for the
        caller's access tier. Identifiers use `urn:opendpp:*` forms: the shell
        id is `urn:opendpp:aas:<passportUuid>` with idShort `AAS_<productId>`
        and `globalAssetId` `urn:opendpp:asset:<operatorId>:<productId>` (plus
        `specificAssetIds` carrying the productId and, when a facility is
        assigned, its GLN). The environment contains, at minimum, a
        `GeneralProductInformation` submodel
        (`urn:opendpp:submodel:general:<passportUuid>`) and a
        `ComplianceMetadata` submodel (`urn:opendpp:submodel:compliance`); for a
        manufacturer/product-identified passport it also carries an IDTA Digital
        Nameplate submodel (idShort `Nameplate`) and one or more additive
        per-category submodel views (ESPR-category views such as CarbonFootprint
        / TechnicalData, id prefix `urn:opendpp:submodel:category:`). An
        `eidasVerificationSeal` submodel is appended when the tenant has signing
        keys configured. Loose schema — the full AAS document structure is
        documented with the public resolution endpoints."
      required:
        - assetAdministrationShells
        - submodels
        - conceptDescriptions
      properties:
        assetAdministrationShells:
          type: array
          items:
            type: object
            additionalProperties: true
        submodels:
          type: array
          items:
            type: object
            additionalProperties: true
        conceptDescriptions:
          type: array
          items:
            type: object
            additionalProperties: true
      additionalProperties: true
    PassportUpdateRequest:
      type: object
      description: Body of PUT /api/v1/passports/{id}. Unknown keys are ignored.
      required:
        - metadata
      additionalProperties: true
      properties:
        metadata:
          type: object
          description: "Full replacement ESPR metadata object. `metadata.category` must be
            one of: textiles, batteries, electronics, cosmetics, toys,
            iron-steel, aluminium, chemicals, construction. Validated against
            the category's compliance rules unless `draft: true` (400 with
            `errors[]` on failure — no `warnings` on this route). A
            machine-readable JSON Schema is published at GET
            /api/v1/schemas/{category} for **textiles, batteries, electronics,
            chemicals and construction only** — cosmetics, toys, iron-steel and
            aluminium are validated by built-in rules and return 404 from that
            endpoint."
        draft:
          type: boolean
          default: false
          description: "true = save as draft: skips ESPR validation and forces status
            DRAFT (this also demotes an already-published passport back to
            DRAFT). false/absent = validated save; a DRAFT passport is published
            (status ACTIVE, emits the passport.ingested webhook)."
        changeReason:
          type: string
          description: Free-text reason recorded on the version-history snapshot. Defaults
            to "API Update".
        facilityId:
          type:
            - string
            - "null"
          description: GLN-backed facility assignment. Omit to leave unchanged; null or ""
            detaches; a UUID attaches a facility that must be owned by your
            tenant (otherwise 400).
        enrichment:
          oneOf:
            - $ref: "#/components/schemas/PassportEnrichmentInput"
            - type: "null"
          description: Presentational marketing block stored OUTSIDE the ESPR-validated
            metadata and the Merkle seal. Include the key (even null/{}) to
            overwrite; omit to leave unchanged. An empty result after sanitation
            clears the block.
    PassportUpdateResponse:
      type: object
      description: 200 envelope of PUT /api/v1/passports/{id}. The passport document
        is serialized at the PUBLIC redaction tier (owner-only/restricted
        metadata keys masked) even for the owner. Also carries the
        `vcReady`/`vcReadyReason` UNTP readiness signal and a non-blocking
        `warnings[]`.
      required:
        - success
        - message
        - passport
        - warnings
      properties:
        success:
          type: boolean
          description: Always true on 200.
        message:
          type: string
          enum:
            - Draft published
            - Digital Product Passport successfully updated and history versioned
          description: '"Draft published" when a validated save promoted a DRAFT to
            ACTIVE; the longer message otherwise.'
        passport:
          $ref: "#/components/schemas/PublicPassportJsonLd"
        warnings:
          type: array
          description: 'Non-blocking advisories. Carries a single note when saving with
            `"draft": true` DEMOTED an already-published
            (ACTIVE/RECALLED/DECOMMISSIONED) passport to DRAFT — it is then no
            longer publicly resolvable. Empty `[]` otherwise.'
          items:
            $ref: "#/components/schemas/AdvisoryItem"
    PassportUpdateValidationError:
      type: object
      description: "400 ESPR validation failure body of PUT /api/v1/passports/{id}.
        DIVERGENCE from POST /api/v1/passports: there is never a `warnings`
        array on this route."
      required:
        - success
        - error
        - message
        - errors
      properties:
        success:
          type: boolean
          const: false
        error:
          type: string
          const: Validation Failed
        message:
          type: string
          example: Dynamic metadata payload failed ESPR category schema validation
        errors:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
          description: Blocking validation errors. `friendlyMessage` is localized via the
            `lang` query parameter / Accept-Language (default en).
    PassportSealResponse:
      type: object
      description: "200 envelope of POST /api/v1/passports/{id}/seal. `digitalSeal` is
        duplicated inside `passport.digitalSeal` and
        `passport.proof.signatureValue`. The passport document is serialized at
        the PUBLIC redaction tier; masked keys keep their true leaf hashes in
        `proof.redactedLeaves`. Note: despite the message wording, this endpoint
        does not change the passport's `status`."
      required:
        - success
        - message
        - digitalSeal
        - signingPublicKey
        - passport
        - warnings
      properties:
        success:
          type: boolean
          description: Always true on 200.
        message:
          type: string
          const: Passport sealed with the tenant's eIDAS advanced electronic seal and
            published.
        digitalSeal:
          type: string
          description: Base64 ECDSA P-256 (prime256v1) SHA-256 signature over the metadata
            Merkle root (ADVANCED electronic seal — not a qualified seal, not a
            W3C DataIntegrityProof).
        signingPublicKey:
          type: string
          description: PEM-encoded public key of the tenant's signing key pair; verify the
            seal offline against `proof.merkleRoot`.
        passport:
          $ref: "#/components/schemas/PublicPassportJsonLd"
        warnings:
          type: array
          description: "Publish-time re-warning: a single non-GS1 advisory when the sealed
            passport's `productId` is not a GS1 GTIN/GRAI (it has no scannable
            Digital Link — mint a GTIN you own via `POST /api/v1/gs1/gtin`).
            Empty `[]` when the productId is GS1-keyed. Non-blocking."
          items:
            $ref: "#/components/schemas/AdvisoryItem"
    PassportStatusUpdateRequest:
      type: object
      description: Body of PUT /api/v1/passports/{id}/status. Only `status` is read;
        any other keys are ignored (there is no `reason` field).
      required:
        - status
      additionalProperties: true
      properties:
        status:
          type: string
          enum:
            - ACTIVE
            - RECALLED
            - DECOMMISSIONED
          description: Target lifecycle state. DECOMMISSIONED starts the retention clock
            (retentionUntil = now + the configured retention period, default 15
            years); ACTIVE reactivates (clears retentionUntil and archivedAt);
            RECALLED marks the product recalled. DRAFT is not a valid target —
            drafts are published via PUT /api/v1/passports/{id}.
    PassportStatusUpdateResponse:
      type: object
      description: 200 envelope of PUT /api/v1/passports/{id}/status. The passport
        document is serialized at the PUBLIC redaction tier.
      required:
        - success
        - message
        - status
        - passport
      properties:
        success:
          type: boolean
          description: Always true on 200.
        message:
          type: string
          description: "`Passport status successfully updated to <STATUS>`."
        status:
          type: string
          enum:
            - ACTIVE
            - RECALLED
            - DECOMMISSIONED
          description: The passport's new lifecycle state.
        passport:
          $ref: "#/components/schemas/PublicPassportJsonLd"
    PassportListItem:
      type: object
      description: "One JSON-LD passport document as it appears in `GET
        /api/v1/passports` list responses. Same shape as `PublicPassportJsonLd`
        with list-specific divergences imposed by the route's declared response
        serialization: `economicOperator` never carries `role`;
        `manufacturingFacility` is always `null`; the `@context` term map
        (second array element) is emptied to `{}`; and `proof` is emptied to
        `{}` on sealed items (`null` on unsealed) — fetch the single passport
        for the verifiable proof block."
      additionalProperties: true
      required:
        - "@context"
        - "@type"
        - "@id"
        - id
        - productId
        - digitalLinkUri
        - digitalSeal
        - signingPublicKey
        - status
        - archivedAt
        - retentionUntil
        - proof
        - createdAt
        - updatedAt
        - economicOperator
        - manufacturingFacility
        - metadata
      properties:
        "@context":
          type: array
          minItems: 2
          maxItems: 2
          items:
            anyOf:
              - type: string
              - type: object
                additionalProperties:
                  type: string
          description: "Exactly two entries: the context URL
            `https://opendpp-node.eu/contexts/dpp/v1` and an inline term map
            covering the 9 fixed DPP terms (`DigitalProductPassport`,
            `economicOperator`, `manufacturingFacility`, `metadata`,
            `digitalSeal`, `signingPublicKey`, `status`, `archivedAt`,
            `retentionUntil`) plus one generated term per metadata key
            (`https://opendpp-node.eu/contexts/dpp/v1#<key>`)."
        "@type":
          type: string
          const: DigitalProductPassport
        "@id":
          type: string
          format: uri
          description: The passport's canonical GS1 Digital Link URI (same value as
            `digitalLinkUri`).
        id:
          type: string
          description: Server-assigned passport UUID.
        productId:
          type: string
          description: "Caller-supplied product identifier: a GTIN-14 (`^[0-9]{14}$` with
            valid GS1 modulo-10 check digit), a GRAI
            (`^[0-9]{14}[A-Za-z0-9]{0,16}$`), or a free-form SKU."
        digitalLinkUri:
          type: string
          format: uri
          description: "SKU/type-level GS1 Digital Link URI:
            `{origin}/{01|8003}/{productId}` (AI-21 carries the passport UUID at
            SKU level; individual units carry their physical serial instead)."
        digitalSeal:
          type:
            - string
            - "null"
          description: "ADVANCED electronic seal: base64 ECDSA prime256v1 (P-256)
            signature over the Merkle root of the key-sorted metadata. `null`
            when the passport has not been sealed."
        signingPublicKey:
          type:
            - string
            - "null"
          description: PEM public key that verifies `digitalSeal`. `null` when unsealed.
        status:
          type: string
          enum:
            - DRAFT
            - ACTIVE
            - RECALLED
            - DECOMMISSIONED
          description: Passport lifecycle status (serialized as `ACTIVE` when unset).
            `DRAFT` is only ever visible to owner-tier callers — public/grant
            resolution of a draft returns 404.
        archivedAt:
          type:
            - string
            - "null"
          format: date-time
          description: Soft-delete marker (owner off-boarded / decommissioned). Archived
            passports remain publicly resolvable (ESPR persistence duty).
        retentionUntil:
          type:
            - string
            - "null"
          format: date-time
          description: Minimum-availability deadline; the passport is never purged before
            this instant.
        proof:
          anyOf:
            - type: object
              additionalProperties: false
              description: Always the empty object on sealed items — proof contents are
                stripped by the list serialization.
            - type: "null"
          description: "`{}` when sealed, `null` when unsealed. The full
            `MerkleTreeAttestationProof` is only available on single-passport
            reads."
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        economicOperator:
          anyOf:
            - $ref: "#/components/schemas/EconomicOperatorNode"
            - type: "null"
          description: The economic operator (manufacturer/importer/retailer) responsible
            for the product. Public in all tiers.
        manufacturingFacility:
          type: "null"
          description: Always `null` in list responses (facility nodes are only embedded
            on single-passport reads).
        metadata:
          type: object
          additionalProperties: true
          description: "The ESPR category metadata, tier-masked: keys above the caller's
            tier hold the literal string `[REDACTED - Privileged Access
            Required]` instead of their value."
    Gs1BatchDecodeResult:
      description: "One batch-decode result, aligned to its input item: a decoded scan
        (`ok: true`) or a per-item error (`ok: false` + `error`). `ok` is the
        discriminant."
      oneOf:
        - $ref: "#/components/schemas/Gs1BatchDecodeOk"
        - $ref: "#/components/schemas/Gs1BatchDecodeError"
    Gs1BatchDecodeOk:
      type: object
      description: A successfully decoded item — the same fields as the single-scan
        200 minus `success`.
      required:
        - ok
        - input
        - elementString
        - hri
        - canonicalUpi
        - digitalLinkUri
        - ai
      properties:
        ok:
          type: boolean
          const: true
        input:
          type: string
        elementString:
          type:
            - string
            - "null"
        hri:
          type: array
          items:
            type: string
        canonicalUpi:
          type:
            - string
            - "null"
        digitalLinkUri:
          type:
            - string
            - "null"
        ai:
          type: object
          additionalProperties:
            type: string
    Gs1BatchDecodeError:
      type: object
      description: A per-item decode failure — the batch itself still returns 200
        (partial-success).
      required:
        - ok
        - error
      properties:
        ok:
          type: boolean
          const: false
        error:
          type: string
    PublicPassportJsonLd:
      type: object
      description: "The public, redacted JSON-LD Digital Product Passport document
        (`application/ld+json`). All listed top-level keys are ALWAYS present
        (`null` where not applicable). Additionally, every key of the (masked)
        `metadata` object — except the reserved document keys (`@context`,
        `@type`, `@id`, `id`, `productId`, `digitalLinkUri`, `digitalSeal`,
        `signingPublicKey`, `status`, `archivedAt`, `retentionUntil`, `proof`,
        `createdAt`, `updatedAt`, `economicOperator`, `manufacturingFacility`,
        `metadata`) — is ALSO flattened onto the document root for direct
        semantic-graph querying (hence `additionalProperties: true`); flattened
        values are identical to the corresponding `metadata` values, including
        redaction placeholders. Tier-masked metadata keys are replaced (in both
        places) with the literal string `[REDACTED - Privileged Access
        Required]`. Masking by tier: anonymous public callers lose the
        per-category restricted keys (category `batteries`:
        `detailedPerformance`, `lifecycleAndInUse`, `circularityAndDisassembly`
        — masked only when actually present) AND the owner-only key
        `facilityDetails`; legitimate-interest/authority grant holders lose only
        `facilityDetails`; owner-tier responses are unmasked and additionally
        include the facility street address fields. Note: `facilityDetails` is
        placeholder-masked in EVERY non-owner response, even when the underlying
        metadata never contained the key — in that case it has no entry in
        `proof.redactedLeaves`. Each masked key that exists in the sealed
        metadata keeps its true Merkle leaf hash in `proof.redactedLeaves`, so
        the seal stays verifiable offline after redaction (see
        `MerkleTreeAttestationProof` for the reconstruction rule)."
      additionalProperties: true
      required:
        - "@context"
        - "@type"
        - "@id"
        - id
        - productId
        - digitalLinkUri
        - digitalSeal
        - signingPublicKey
        - status
        - archivedAt
        - retentionUntil
        - proof
        - createdAt
        - updatedAt
        - economicOperator
        - manufacturingFacility
        - metadata
      properties:
        "@context":
          type: array
          minItems: 2
          maxItems: 2
          items:
            anyOf:
              - type: string
              - type: object
                additionalProperties:
                  type: string
          description: "Exactly two entries: the context URL
            `https://opendpp-node.eu/contexts/dpp/v1` and an inline term map
            covering the 9 fixed DPP terms (`DigitalProductPassport`,
            `economicOperator`, `manufacturingFacility`, `metadata`,
            `digitalSeal`, `signingPublicKey`, `status`, `archivedAt`,
            `retentionUntil`) plus one generated term per metadata key
            (`https://opendpp-node.eu/contexts/dpp/v1#<key>`)."
        "@type":
          type: string
          const: DigitalProductPassport
        "@id":
          type: string
          format: uri
          description: The passport's canonical GS1 Digital Link URI (same value as
            `digitalLinkUri`).
        id:
          type: string
          description: Server-assigned passport UUID.
        productId:
          type: string
          description: "Caller-supplied product identifier: a GTIN-14 (`^[0-9]{14}$` with
            valid GS1 modulo-10 check digit), a GRAI
            (`^[0-9]{14}[A-Za-z0-9]{0,16}$`), or a free-form SKU."
        digitalLinkUri:
          type: string
          format: uri
          description: "SKU/type-level GS1 Digital Link URI:
            `{origin}/{01|8003}/{productId}` (AI-21 carries the passport UUID at
            SKU level; individual units carry their physical serial instead)."
        digitalSeal:
          type:
            - string
            - "null"
          description: "ADVANCED electronic seal: base64 ECDSA prime256v1 (P-256)
            signature over the Merkle root of the key-sorted metadata. `null`
            when the passport has not been sealed."
        signingPublicKey:
          type:
            - string
            - "null"
          description: PEM public key that verifies `digitalSeal`. `null` when unsealed.
        status:
          type: string
          enum:
            - DRAFT
            - ACTIVE
            - RECALLED
            - DECOMMISSIONED
          description: Passport lifecycle status (serialized as `ACTIVE` when unset).
            `DRAFT` is only ever visible to owner-tier callers — public/grant
            resolution of a draft returns 404.
        archivedAt:
          type:
            - string
            - "null"
          format: date-time
          description: Soft-delete marker (owner off-boarded / decommissioned). Archived
            passports remain publicly resolvable (ESPR persistence duty).
        retentionUntil:
          type:
            - string
            - "null"
          format: date-time
          description: Minimum-availability deadline; the passport is never purged before
            this instant.
        proof:
          anyOf:
            - $ref: "#/components/schemas/MerkleTreeAttestationProof"
            - type: "null"
          description: Present (non-null) only when the passport is sealed (`digitalSeal`
            set).
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        economicOperator:
          anyOf:
            - $ref: "#/components/schemas/EconomicOperatorNode"
            - type: "null"
          description: The economic operator (manufacturer/importer/retailer) responsible
            for the product. Public in all tiers.
        manufacturingFacility:
          anyOf:
            - $ref: "#/components/schemas/PublicFacilityNode"
            - type: "null"
          description: GLN-backed Unique Facility Identifier node (EN 18219), or `null`
            when no facility is linked. GLN, name, activity and country are
            public; street-address fields appear only in owner-tier responses.
        metadata:
          type: object
          additionalProperties: true
          description: "The ESPR category metadata, tier-masked: keys above the caller's
            tier hold the literal string `[REDACTED - Privileged Access
            Required]` instead of their value."
    MerkleTreeAttestationProof:
      type: object
      description: "OpenDPP's own proof type — an ADVANCED electronic seal: an ECDSA
        prime256v1 signature over a SHA-256 Merkle root of the key-sorted
        metadata (one leaf per top-level metadata key). Deliberately NOT a W3C
        DataIntegrityProof / `ecdsa-jcs-2019` Verifiable Credential (no RFC 8785
        JCS canonicalization). Verifiable offline: rebuild the Merkle root from
        `metadata` — substituting each `redactedLeaves` hash for its
        placeholder-masked key, and EXCLUDING any placeholder-masked key that
        has no `redactedLeaves` entry (such a key was never present in the
        sealed metadata; the serializer injects the owner-only placeholder
        unconditionally) — then verify `signatureValue` with `publicKeyPem`; the
        `x5c` chain validates against the platform seal CA (`GET
        /.well-known/opendpp-seal-ca.pem`) and the `rfc3161` token via `openssl
        ts -verify`."
      required:
        - "@type"
        - type
        - signatureAlgorithm
        - created
        - proofPurpose
        - verificationMethod
        - signatureValue
        - publicKeyPem
        - merkleRoot
      properties:
        "@type":
          type: array
          items:
            type: string
            const: MerkleTreeAttestationProof
          description: Always `["MerkleTreeAttestationProof"]`.
        type:
          type: string
          const: MerkleTreeAttestationProof
        signatureAlgorithm:
          type: string
          const: ECDSA-P256-SHA256-over-MerkleRoot
        created:
          type: string
          format: date-time
          description: Mirrors the passport's `updatedAt`.
        proofPurpose:
          type: string
          const: assertionMethod
        verificationMethod:
          type: string
          format: uri
          description: "`https://opendpp-node.eu/passport/{passportId}#key-1`."
        signatureValue:
          type: string
          description: Base64 ECDSA P-256/SHA-256 signature over the hex Merkle root
            string (same value as the document's `digitalSeal`).
        publicKeyPem:
          type: string
          description: PEM public key for verification (same value as the document's
            `signingPublicKey`).
        x5c:
          type: array
          items:
            type: string
          description: OPTIONAL (omitted when no chain was recorded at seal time). X.509
            chain as base64 DER (no PEM armor), leaf first, binding the signing
            key to the tenant's legal identity; issued by the platform seal CA.
            Denormalised at seal time, so later key/cert rotations never
            retroactively change a proof.
        rfc3161:
          type: object
          description: OPTIONAL (omitted when timestamping was off/unavailable at seal
            time). RFC 3161 trusted timestamp over SHA-256(merkleRoot) — an
            independent existed-at anchor from the configured TSA.
          required:
            - genTime
            - token
          properties:
            genTime:
              type:
                - string
                - "null"
              format: date-time
              description: TSA generation time.
            token:
              type: string
              description: Base64 DER RFC 3161 TimeStampToken; verifies offline via `openssl
                ts -verify`.
        merkleRoot:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: Hex SHA-256 Merkle root over the key-sorted metadata leaves.
        redactedLeaves:
          type: object
          additionalProperties:
            type: string
            pattern: ^[0-9a-f]{64}$
          description: OPTIONAL — present only when at least one masked key actually
            exists in the underlying sealed metadata. Maps each such metadata
            key to its TRUE hex leaf hash, so the Merkle root can be
            reconstructed from the redacted document. A masked key that was
            never present in the metadata (the owner-only key is
            placeholder-injected unconditionally for non-owner tiers) yields NO
            entry here — verifiers must exclude placeholder-valued keys without
            an entry when rebuilding the tree.
    EconomicOperatorNode:
      type: object
      description: Embedded economic-operator JSON-LD node (public in all tiers).
      required:
        - "@type"
        - id
        - name
        - regId
      properties:
        "@type":
          type: string
          const: EconomicOperator
        id:
          type: string
        name:
          type: string
        regId:
          type: string
          description: EORI number or official business-registry identifier (unique
            platform-wide), e.g. `EU-DEFAULT-001`.
        role:
          type: string
          description: Operator role in the supply chain, e.g. `MANUFACTURER`, `IMPORTER`,
            `RETAILER`. Always present in detail/resolution responses; absent
            from `GET /api/v1/passports` list items.
    PublicFacilityNode:
      type: object
      description: Embedded manufacturing-facility JSON-LD node — the GS1 GLN-backed
        Unique Facility Identifier (UFI, EN 18219). The five listed fields are
        public; `streetAddress`/`city`/`postalCode` appear ONLY in owner-tier
        responses (never via legitimate-interest grants).
      required:
        - "@type"
        - id
        - gln
        - name
        - activity
        - country
      properties:
        "@type":
          type: string
          const: Facility
        id:
          type: string
        gln:
          type: string
          pattern: ^[0-9]{13}$
          description: GS1 GLN-13 with a valid modulo-10 check digit.
        name:
          type: string
        activity:
          type:
            - string
            - "null"
          description: What the facility does in the chain, e.g. `cell assembly`.
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code.
        streetAddress:
          type:
            - string
            - "null"
          description: Owner tier only — omitted from public and grant-tier responses.
        city:
          type:
            - string
            - "null"
          description: Owner tier only.
        postalCode:
          type:
            - string
            - "null"
          description: Owner tier only.
    AasEnvironment:
      type: object
      description: "An Asset Administration Shell (AAS) v3.0 environment export of the
        passport, served as `application/aas+json`. Three top-level keys:
        `assetAdministrationShells` (asset identity —
        `urn:opendpp:aas:{passportId}` /
        `urn:opendpp:asset:{operatorId}:{productId}`, GS1 GLN-qualified specific
        asset ids), `submodels`, and `conceptDescriptions` (semantic concept
        records from the admin-curated registry, `urn:opendpp:concept:…`; empty
        array when the registry is empty). Submodels: a
        `GeneralProductInformation` submodel
        (`urn:opendpp:submodel:general:{passportId}`), a `ComplianceMetadata`
        submodel (`urn:opendpp:submodel:compliance`) mapping the passport
        metadata through the concept registry, an IDTA Digital Nameplate
        submodel (idShort `Nameplate`) whenever manufacturer/product identity is
        available — carrying `ManufacturerName` /
        `ManufacturerProductDesignation` for downstream index discoverability —
        one or more additive per-category submodel views (ESPR-category views
        such as CarbonFootprint / TechnicalData, id prefix
        `urn:opendpp:submodel:category:`), and — whenever the issuing tenant's
        signing key is provisioned (the normal case) — an
        `eidasVerificationSeal` submodel
        (`urn:opendpp:submodel:security-seal:{passportId}`) carrying
        `digitalSealHash`, `cryptographicSignature`, `pemPublicKey` and an
        optional `x509CertificateChain`. The seal submodel is present for EVERY
        access tier. Role filtering strips restricted and commercial owner-only
        elements from the `ComplianceMetadata` submodel before sending: owner
        credentials are filtered by their API-key role, grant holders by the
        `legitimate_interest` tier, anonymous callers by the `public` tier.
        Submodel internals are intentionally not enumerated in this
        specification."
      additionalProperties: true
    PublicBatteryUnitJsonLd:
      type: object
      description: "Public JSON-LD document for one individual serialised battery unit
        (EU Battery Regulation). The listed required keys are always present.
        EXACTLY ONE of two tier-dependent groups is added: anonymous (public)
        responses carry `restrictedData` (Annex XIII(2)-(4) notice) and OMIT
        `currentState`/`dynamicData` entirely; owner/grant (privileged)
        responses carry `currentState` (latest measurement or `null`) and
        `dynamicData` (up to 500 events, newest first) and omit
        `restrictedData`. The embedded `ofModel` passport is masked by the
        caller's tier like `GET /passport/{id}`."
      required:
        - "@context"
        - "@type"
        - "@id"
        - id
        - serialNumber
        - digitalLinkUri
        - status
        - manufacturedAt
        - repurposedFrom
        - successorUnits
        - ofModel
        - createdAt
        - updatedAt
      properties:
        "@context":
          type: array
          minItems: 2
          maxItems: 2
          items:
            anyOf:
              - type: string
              - type: object
                additionalProperties:
                  type: string
          description: The context URL `https://opendpp-node.eu/contexts/dpp/v1` plus a
            fixed inline term map for the battery-unit terms.
        "@type":
          type: string
          const: BatteryUnit
        "@id":
          type: string
          format: uri
          description: The unit's GS1 Digital Link URI (AI-21 = the real physical serial).
        id:
          type: string
        serialNumber:
          type: string
          pattern: ^[A-Za-z0-9._-]{1,20}$
          description: The physical battery serial (the real GS1 AI-21 value; unique
            within its SKU/type passport).
        digitalLinkUri:
          type: string
          format: uri
        status:
          type: string
          enum:
            - IN_SERVICE
            - DECOMMISSIONED
            - RECALLED
            - REPURPOSED
            - REMANUFACTURED
            - REUSED
            - WASTE
            - RECYCLED
          description: Annex XIII battery-status vocabulary. A `RECYCLED` (or ceased) unit
            is never served as a 200 — its URL answers 410 with the tombstone
            document instead.
        manufacturedAt:
          type:
            - string
            - "null"
          format: date-time
        repurposedFrom:
          anyOf:
            - $ref: "#/components/schemas/BatteryUnitLineageRef"
            - type: "null"
          description: "Lineage: the original unit this repurposed/remanufactured battery
            came from. The link itself is public."
        successorUnits:
          type: array
          items:
            $ref: "#/components/schemas/BatteryUnitLineageRef"
          description: Units re-placed on the market under a new passport derived from
            this one (empty array when none).
        ofModel:
          $ref: "#/components/schemas/PublicPassportJsonLd"
          description: The SKU/type-level passport this physical unit is an instance of,
            masked by the caller's tier.
        restrictedData:
          $ref: "#/components/schemas/BatteryUnitRestrictedDataNotice"
          description: Present ONLY in anonymous (public-tier) responses.
        currentState:
          anyOf:
            - $ref: "#/components/schemas/BatteryUnitCurrentState"
            - type: "null"
          description: "Present ONLY in owner/grant-tier responses: the most recent
            recorded measurement, or `null` when the unit has no events."
        dynamicData:
          type: array
          maxItems: 500
          items:
            $ref: "#/components/schemas/BatteryUnitEventNode"
          description: "Present ONLY in owner/grant-tier responses: append-only telemetry
            history, newest first, capped at the 500 most recent events."
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    BatteryUnitLineageRef:
      type: object
      description: Public lineage pointer between battery units.
      required:
        - unitId
        - serialNumber
        - digitalLinkUri
        - unitUrl
      properties:
        unitId:
          type: string
        serialNumber:
          type: string
        digitalLinkUri:
          type: string
          format: uri
        unitUrl:
          type: string
          description: "Relative public unit URL: `/unit/{unitId}`."
    BatteryUnitCurrentState:
      type: object
      description: Latest recorded measurement of the unit (owner/grant tiers only).
        All measurement fields are `null` when the latest event did not carry
        them.
      required:
        - stateOfHealth
        - cycleCount
        - remainingCapacityAh
        - temperatureC
        - recordedAt
      properties:
        stateOfHealth:
          type:
            - number
            - "null"
          description: State of health, percent (0-100).
        cycleCount:
          type:
            - integer
            - "null"
          description: Cumulative full-equivalent charge cycles.
        remainingCapacityAh:
          type:
            - number
            - "null"
          description: Measured remaining capacity in ampere-hours.
        temperatureC:
          type:
            - number
            - "null"
          description: Observed temperature in degrees Celsius.
        recordedAt:
          type: string
          format: date-time
          description: When the measurement was taken (client-supplied).
    BatteryUnitEventNode:
      type: object
      description: One append-only telemetry event (owner/grant tiers only).
      required:
        - "@type"
        - eventType
        - stateOfHealth
        - cycleCount
        - remainingCapacityAh
        - temperatureC
        - payload
        - recordedAt
      properties:
        "@type":
          type: string
          const: BatteryUnitEvent
        eventType:
          type: string
          enum:
            - SOH_MEASUREMENT
            - CHARGE_CYCLE
            - STATUS_CHANGE
            - NEGATIVE_EVENT
            - OTHER
        stateOfHealth:
          type:
            - number
            - "null"
          description: Percent, 0-100.
        cycleCount:
          type:
            - integer
            - "null"
        remainingCapacityAh:
          type:
            - number
            - "null"
        temperatureC:
          type:
            - number
            - "null"
        payload:
          type:
            - object
            - array
            - "null"
          additionalProperties: true
          description: Free-form additional telemetry/context supplied at ingestion.
            Ingestion accepts any JSON object OR array, so both shapes can
            appear here.
        recordedAt:
          type: string
          format: date-time
          description: When the measurement was taken (client-supplied at ingestion).
    BatteryUnitRestrictedDataNotice:
      type: object
      description: Marker replacing per-unit telemetry in anonymous (public-tier)
        responses, with a pointer for requesting legitimate-interest access
        (Annex XIII(2)-(4)).
      required:
        - reason
        - reference
        - description
        - howToRequest
      properties:
        reason:
          type: string
          const: LEGITIMATE_INTEREST_REQUIRED
        reference:
          type: string
          const: Regulation (EU) 2023/1542, Annex XIII(2)-(4)
        description:
          type: string
          const: Per-unit dynamic data (state of health, cycle counts, negative events,
            temperature) is accessible only to persons with a legitimate
            interest and to authorities.
        howToRequest:
          type: string
          description: Relative URL `/request-access?unit={unitId}` where a
            legitimate-interest grant can be requested.
    BatteryUnitTombstoneJsonLd:
      type: object
      description: "Tombstone (HTTP 410): once a battery is recycled its passport has
        ceased to exist. This minimal record confirms the unit existed, that it
        was recycled and when, plus the (still living) model-passport link.
        Grants and owner credentials do not override the tombstone on the public
        URL; the underlying data is retained internally for the statutory
        retention window."
      required:
        - "@context"
        - "@type"
        - "@id"
        - id
        - serialNumber
        - status
        - ceasedAt
        - notice
        - ofModelUrl
      properties:
        "@context":
          type: array
          minItems: 2
          maxItems: 2
          items:
            anyOf:
              - type: string
              - type: object
                additionalProperties:
                  type: string
        "@type":
          type: string
          const: BatteryUnit
        "@id":
          type: string
          format: uri
        id:
          type: string
        serialNumber:
          type: string
        status:
          type: string
          const: RECYCLED
        ceasedAt:
          type:
            - string
            - "null"
          format: date-time
          description: When the unit's passport ceased to exist (stamped when the status
            transitioned to RECYCLED).
        notice:
          type: string
          const: This battery has been recycled. Its battery passport has ceased to exist
            (Regulation (EU) 2023/1542, Art. 77(8)).
        ofModelUrl:
          type:
            - string
            - "null"
          description: "Relative URL of the still-living SKU/type passport:
            `/passport/{passportId}`."
    SectorJsonSchemaDocument:
      type: object
      description: A JSON Schema **draft-07** document describing the ESPR `metadata`
        payload for one product category, served as `application/schema+json`.
        Each known field is annotated server-side with a plain-English
        `description` from the platform's field-help registry (annotations are
        AJV-ignored; validation behavior is identical to the raw schema). The
        same schema (without annotations) validates `metadata` on `POST
        /api/v1/passports` and the validate-only endpoints.
      additionalProperties: true
      required:
        - $schema
        - title
        - type
        - required
        - properties
      properties:
        $schema:
          type: string
          const: http://json-schema.org/draft-07/schema#
        title:
          type: string
        type:
          type: string
          const: object
        required:
          type: array
          items:
            type: string
        properties:
          type: object
          additionalProperties: true
    SectorVocabularyContext:
      type: object
      description: "Per-category JSON-LD vocabulary context, returned by `GET
        /api/v1/schemas/{category}` when `Accept` contains
        `application/ld+json`. Fixed shape: `@vocab` is
        `https://w3id.org/opendpp/schemas/{category}#` plus mappings for `id`,
        `type`, `category`, `materialComposition`, `originCountry`,
        `facilityDetails` and `regulatoryCompliance`."
      required:
        - "@context"
      properties:
        "@context":
          type: object
          additionalProperties:
            type: string
    DppJsonLdContextDocument:
      type: object
      description: "The fixed W3C JSON-LD context document served by `GET
        /context/v1`: maps `DigitalProductPassport`, `economicOperator`,
        `metadata`, `digitalSeal`, `signingPublicKey` and `proof` to
        `https://opendpp-node.eu/ns/dpp#…` IRIs, and `createdAt`/`updatedAt` to
        schema.org `dateCreated`/`dateModified`."
      required:
        - "@context"
      properties:
        "@context":
          type: object
          additionalProperties:
            type: string
    DppVocabContextDocument:
      type: object
      description: The canonical resolvable JSON-LD context served by `GET
        /contexts/dpp/v1` — the context every public passport and battery-unit
        document references in its `@context`. Declares `@vocab` (so unknown
        terms expand under the OpenDPP namespace) plus core term mappings;
        `@version` is the numeric JSON-LD 1.1 marker, so `@context` values are a
        mix of strings and that number.
      required:
        - "@context"
      properties:
        "@context":
          type: object
          description: "Term map: `@vocab` + `@version` (1.1) + the core DPP/unit term →
            `dpp:` CURIE mappings."
    HealthStatus:
      type: object
      description: Health-check body of `GET /health`. Carries the running build
        identity (`apiVersion`/`commit`/`builtAt`) in addition to the liveness
        fields.
      required:
        - status
        - service
        - timestamp
        - apiVersion
        - commit
        - builtAt
      properties:
        status:
          type: string
          const: OK
        service:
          type: string
          const: OpenDPP B2B Enterprise Engine
        timestamp:
          type: string
          format: date-time
          description: Current server time, ISO 8601 UTC with milliseconds.
        apiVersion:
          type: string
          description: SemVer of the public API contract currently served (equals the
            OpenAPI document's `info.version`; its MAJOR equals the `/api/v1`
            URL major).
          examples:
            - 1.0.0
        commit:
          type: string
          description: Short git commit SHA of the running build, or `"unknown"` when a
            build did not inject it.
          examples:
            - a7a96d0
            - unknown
        builtAt:
          type: string
          description: Build/deploy timestamp (ISO 8601 UTC), or `"unknown"` when a build
            did not inject it.
          examples:
            - 2026-06-12T09:30:00Z
            - unknown
    ServiceVersion:
      type: object
      description: Running API contract version and source build identity, returned by
        `GET /api/v1/version`. The `apiVersion` MAJOR is the safe thing to pin
        an integration or generated SDK to.
      required:
        - apiVersion
        - commit
        - builtAt
      properties:
        apiVersion:
          type: string
          description: SemVer of the public API contract currently served (equals the
            OpenAPI document's `info.version`; its MAJOR equals the `/api/v1`
            URL major).
          examples:
            - 1.0.0
        commit:
          type: string
          description: Short git commit SHA of the running build, or `"unknown"` when a
            build did not inject it.
          examples:
            - a7a96d0
            - unknown
        builtAt:
          type: string
          description: Build/deploy timestamp (ISO 8601 UTC), or `"unknown"` when a build
            did not inject it.
          examples:
            - 2026-06-12T09:30:00Z
            - unknown
    MaterialVocabularyRow:
      type: object
      description: One entry of the platform-curated material vocabulary. Entries are
        unique per (`kind`, `name`).
      required:
        - id
        - name
        - kind
        - casNumber
        - description
      properties:
        id:
          type: string
        name:
          type: string
          description: Canonical display name, e.g. "Organic Cotton" or "Lithium Iron
            Phosphate (LFP)".
        kind:
          type: string
          enum:
            - material
            - fiber
            - chemistry
            - substance
            - hazard
            - crm
          description: Vocabulary kind. `crm` = critical raw material.
        casNumber:
          type:
            - string
            - "null"
          description: Optional CAS registry number (chemicals/substances); null when not
            applicable.
        description:
          type:
            - string
            - "null"
          description: Optional short note shown in the picker; null when unset.
    MaterialVocabularyListResponse:
      type: object
      description: "Envelope of `GET /api/v1/materials`. Caveat: unlike most
        authenticated endpoints there is NO `success` field."
      required:
        - materials
      properties:
        materials:
          type: array
          description: Active vocabulary entries, ordered by `kind` ascending then `name`
            ascending, capped at `limit` (max 1000).
          items:
            $ref: "#/components/schemas/MaterialVocabularyRow"
    UntpEventCredential:
      type: object
      description: 'A UNTP/EPCIS 2.0 traceability event wrapped as a VC-shaped
        credential. The only hard structural requirement is `credentialSubject`;
        the `proof` MUST be a conformant W3C `DataIntegrityProof` (`cryptosuite:
        "ecdsa-jcs-2019"`) and a missing, non-conformant, or unverifiable proof
        is rejected with the 400 `Cryptographic Verification Failed` body. Extra
        properties are permitted — the signature covers `sha256(JCS(proof
        options)) ‖ sha256(JCS(credential without proof))` (RFC 8785 JCS
        canonicalization).'
      required:
        - credentialSubject
        - proof
      properties:
        "@context":
          type: array
          items:
            type: string
        id:
          type: string
          description: Credential id (e.g. `urn:uuid:...`). NOT used as the stored event
            id — the event primary key is always server-generated.
        type:
          type: array
          items:
            type: string
        issuer:
          type: string
          description: Issuer DID. Unless a trusted x5c chain is embedded, the
            verification key is resolved by EXACT match of the DID's trailing
            `:`-segment against registered tenant subdomains/company names —
            e.g. `did:web:opendpp-node.eu:demo` resolves the workspace with
            subdomain `demo`. For operator-scoped API keys the issuer DID must
            ALSO contain the bound operator's registration id somewhere in the
            string (the issuer is checked in preference to
            `credentialSubject.responsibleOperatorDid`), e.g.
            `did:web:opendpp-node.eu:EU-DEFAULT-001:demo`. Stored verbatim as
            the event's `issuerDid`.
        issuanceDate:
          type: string
          format: date-time
          description: Fallback for the stored `eventTime` when
            `credentialSubject.eventTime` is absent.
        credentialSubject:
          $ref: "#/components/schemas/UntpEventCredentialSubject"
        proof:
          $ref: "#/components/schemas/UntpEventProof"
    UntpEventCredentialSubject:
      type: object
      description: "The EPCIS event payload. `eventType` is effectively required: it
        is persisted into a server-side enum, and a missing or unknown value is
        rejected at the persistence layer (surfacing as the 500 `Database
        Persistence Failed` body, not a 400)."
      required:
        - eventType
      properties:
        id:
          type: string
          description: EPC identifier of the subject (e.g.
            `urn:epc:id:sgtin:0950110153.0003.SN-2026-000123`). When `epcList`
            is not supplied as an array, the stored EPC list defaults to `[id]`
            (or `[]` if absent).
        eventType:
          type: string
          enum:
            - ObjectEvent
            - AggregationEvent
            - TransformationEvent
            - AssociationEvent
          description: EPCIS 2.0 event type (server-side enum).
        action:
          type: string
          enum:
            - ADD
            - OBSERVE
            - DELETE
          description: EPCIS action (server-side enum). Optional — but MUST be absent (or
            null) on `TransformationEvent`, otherwise 400 `Schema Validation
            Error`. An unknown value is rejected at the persistence layer (500).
        bizStep:
          type: string
          default: urn:epcglobal:cbv:bizstep:receiving
          description: CBV business step URI. Defaults to
            `urn:epcglobal:cbv:bizstep:receiving`.
        disposition:
          type: string
          default: urn:epcglobal:cbv:disp:in_progress
          description: CBV disposition URI. Defaults to
            `urn:epcglobal:cbv:disp:in_progress`.
        readPoint:
          type: string
          description: Where the event was observed (e.g. `geo:41.1496,-8.6109`). When
            absent and `originLocation` is present, defaults to
            `geo:<latitude>,<longitude>`.
        bizLocation:
          type: string
          description: Business location (SGLN URI, DID, or free identifier). When absent,
            defaults to `responsibleOperatorDid`.
        eventTime:
          type: string
          format: date-time
          description: When the event occurred (anything `new Date()` parses). Defaults to
            the credential `issuanceDate`, else the server clock.
        epcList:
          type: array
          items:
            type: string
          description: EPC URIs observed by the event. A non-array value is replaced with
            `[credentialSubject.id]` (or `[]`).
        parentEpc:
          type: string
          description: Parent EPC for AggregationEvent.
        childEpcs:
          type: array
          items:
            type: string
          description: Child EPCs for AggregationEvent (stored as JSON verbatim).
        inputEpcList:
          type: array
          items:
            type: string
          description: Input EPCs for TransformationEvent (stored as JSON verbatim).
        outputEpcList:
          type: array
          items:
            type: string
          description: Output EPCs for TransformationEvent (stored as JSON verbatim).
        originLocation:
          type: object
          required:
            - latitude
            - longitude
          properties:
            latitude:
              type: number
            longitude:
              type: number
            eudrPlotId:
              type: string
          description: "Geographic origin; used only to derive a default `readPoint`
            (`geo:<lat>,<lng>`) when `readPoint` is absent.
            `latitude`/`longitude` are required here for correct usage but NOT
            enforced server-side: an `originLocation` missing them is not
            rejected — the derived `readPoint` is then silently persisted as the
            malformed literal `geo:undefined,undefined`."
        responsibleOperatorDid:
          type: string
          description: DID of the responsible economic operator. Fallback for
            `bizLocation`, and (only when `issuer` is absent) for the
            operator-scope check on operator-scoped API keys.
    UntpEventProof:
      type: object
      description: 'Credential proof. MUST be a conformant W3C `DataIntegrityProof`
        with `cryptosuite: "ecdsa-jcs-2019"` and a multibase base58btc (`z…`)
        `proofValue`. Verified (ECDSA P-256, IEEE-P1363 raw r‖s) over
        `sha256(JCS(proof options)) ‖ sha256(JCS(credential without proof))` —
        RFC 8785 JCS canonicalization, a conformant W3C Data Integrity suite.'
      required:
        - type
        - cryptosuite
        - proofValue
      properties:
        type:
          type: string
          const: DataIntegrityProof
          description: MUST be `DataIntegrityProof`.
        cryptosuite:
          type: string
          const: ecdsa-jcs-2019
          description: MUST be `ecdsa-jcs-2019` (RFC 8785 JCS).
        created:
          type: string
          format: date-time
        proofPurpose:
          type: string
          description: e.g. `assertionMethod`.
        verificationMethod:
          oneOf:
            - type: string
              description: Public-key identifier / DID URL.
            - $ref: "#/components/schemas/UntpVerificationMethod"
          description: Either a key-identifier string or an embedded object carrying an
            `x5c` certificate chain.
        proofValue:
          type: string
          description: Multibase base58btc (`z…`) ecdsa-jcs-2019 signature. Stored
            verbatim with the event.
    UntpVerificationMethod:
      type: object
      description: Embedded verification-method object. The `x5c` chain (base64 DER,
        leaf first) is honoured ONLY when the node has trust anchors configured,
        every certificate is currently valid, each link verifies against the
        next, the top is anchored, and the leaf attests the credential issuer —
        otherwise it is ignored and the registered tenant key is used instead.
      properties:
        id:
          type: string
        type:
          type: string
        controller:
          type: string
        x5c:
          type: array
          items:
            type: string
          description: X.509 certificate chain, base64 DER, leaf first.
    TraceEventRegistered:
      type: object
      description: '201 envelope of POST /api/v1/events. Note: `status: "success"`
        (string), not the usual `success: true` boolean.'
      required:
        - status
        - eventId
        - untpVerified
      properties:
        status:
          type: string
          const: success
        eventId:
          type: string
          description: Server-generated event id. Use it with `GET
            /api/v1/events/{id}/lineage`.
        untpVerified:
          type: boolean
          const: true
    TraceLineageNode:
      type: object
      description: One node of the recursive upstream lineage DAG. All keys are always
        present; `location`, `readPoint` and `issuerDid` are null when unset.
        `location` mirrors the stored `bizLocation`. A shared ancestor reached
        through multiple downstream paths appears once under each path (the DAG
        is expanded into a tree).
      required:
        - eventId
        - eventType
        - bizStep
        - disposition
        - eventTime
        - epcs
        - location
        - readPoint
        - isUntpCompliant
        - issuerDid
        - parents
      properties:
        eventId:
          type: string
        eventType:
          type: string
          enum:
            - ObjectEvent
            - AggregationEvent
            - TransformationEvent
            - AssociationEvent
        bizStep:
          type: string
        disposition:
          type: string
        eventTime:
          type: string
          format: date-time
        epcs:
          type: array
          items:
            type: string
          description: EPC URIs parsed from the stored EPC list (degrades to `[]` when
            unparseable).
        location:
          type:
            - string
            - "null"
          description: The event's stored `bizLocation`.
        readPoint:
          type:
            - string
            - "null"
        isUntpCompliant:
          type: boolean
        issuerDid:
          type:
            - string
            - "null"
        parents:
          type: array
          items:
            $ref: "#/components/schemas/TraceLineageNode"
          description: Upstream parent events (recursive). Empty array at the origin of
            the chain.
    TraceLineageResponse:
      description: The upstream pedigree of a traceability event, as a recursive graph
        of the events it derives from.
      type: object
      required:
        - success
        - lineage
      properties:
        success:
          type: boolean
          const: true
        lineage:
          $ref: "#/components/schemas/TraceLineageNode"
    SealVerifyRequest:
      type: object
      description: "Verification request. Only `payload` is strictly required:
        `signature` and `publicKey` are extracted from `payload.proof`
        (proofValue/signatureValue, publicKeyPem, or the x5c leaf SPKI) when
        omitted, and the request fails 400 only if either is still missing after
        extraction."
      required:
        - payload
      properties:
        payload:
          type: object
          description: The sealed passport document to verify — typically the JSON-LD
            passport document exactly as resolved from the public endpoints, or
            any `{passportId, productId, metadata, operator}` payload. Only the
            fields below are interpreted; all other properties are preserved and
            participate in the whole-payload signature fallback.
          properties:
            metadata:
              type: object
              description: The sealed metadata object — the SHA-256 Merkle tree is rebuilt
                over its top-level properties (every leaf recomputed from the
                actual values; caller-supplied redacted-leaf hashes are not
                accepted). When the `metadata` key is entirely ABSENT, the whole
                `payload` object is treated as the metadata for the Merkle
                phase; a present-but-non-object value skips the Merkle phase
                (only the whole-payload fallback runs).
            operator:
              type: object
              description: Declared economic operator. A present `regId` triggers the
                fail-closed operator-binding gate.
              properties:
                name:
                  type: string
                regId:
                  type: string
                  description: "Operator registration id (e.g. EORI-style `EU-DEFAULT-001`). Must
                    resolve to a registered Economic Operator bound to the
                    signing tenant, or verification fails (`verified: false`)."
            economicOperator:
              type: object
              description: Alternative location for the declared operator id —
                `economicOperator.regId` is checked when `operator.regId` is
                absent.
              properties:
                regId:
                  type: string
            proof:
              type: object
              description: Embedded W3C-style proof block. Sources for the signature, public
                key, certificate chain and RFC 3161 token when the top-level
                fields are omitted.
              properties:
                type:
                  type: string
                proofValue:
                  type: string
                  description: Base64 ECDSA seal — used as `signature` when the top-level field is
                    absent.
                signatureValue:
                  type: string
                  description: Legacy alias for `proofValue` (checked second).
                publicKeyPem:
                  type: string
                  description: PEM (SPKI) public key — used as `publicKey` when the top-level
                    field is absent.
                x5c:
                  type: array
                  items:
                    type: string
                  description: X.509 certificate chain, base64 DER, LEAF FIRST. Enables the
                    `certificate` report; the leaf's SPKI also serves as the
                    public key when none is otherwise supplied.
                rfc3161:
                  type: object
                  properties:
                    token:
                      type: string
                      description: RFC 3161 TimeStampToken (base64 DER CMS ContentInfo) — enables the
                        `timestamp` report.
          additionalProperties: true
        signature:
          type: string
          description: Base64 ECDSA (P-256 / SHA-256) seal signature. Optional when
            `payload.proof.proofValue` (or `signatureValue`) is present.
        publicKey:
          type: string
          description: PEM (SPKI) public key of the sealing tenant. Optional when
            `payload.proof.publicKeyPem` or an `x5c` chain is present. CRLF line
            endings are normalized before matching.
    SealVerifyResponse:
      type: object
      description: "Always HTTP 200 once the request is well-formed. `verified: false`
        covers both cryptographic failure and the two registration/binding
        policy failures — the policy failures add a `message` and OMIT
        `certificate`/`timestamp` even when an x5c chain or RFC 3161 token was
        supplied."
      required:
        - success
        - verified
      properties:
        success:
          type: boolean
          const: true
        verified:
          type: boolean
        message:
          type: string
          description: "Present only on the two policy failures: unregistered public key,
            or a declared operator not bound to the signing tenant."
          enum:
            - "Cryptographic verification failed: The public key used to seal
              this passport is not registered to any authorized economic
              operator tenant on this node."
            - "Cryptographic verification failed: The economic operator declared
              in this passport is not a registered operator bound to the signing
              tenant."
        certificate:
          $ref: "#/components/schemas/SealCertificateReport"
        timestamp:
          $ref: "#/components/schemas/SealTimestampReport"
    SealCertificateReport:
      type: object
      description: "Present only for x5c-carrying proofs on a `verified: true` outcome
        whose chain is TRUSTED — `chainValid` AND `keyMatchesProof` both true
        (the two policy `verified: false` responses AND any untrusted-chain
        outcome omit it): the certified legal identity of the seal creator. An
        untrusted chain is never surfaced, so an emitted report always has
        `chainValid: true`."
      required:
        - chainValid
      properties:
        subject:
          type: string
          description: Leaf-certificate subject (multi-line RDN string as produced by
            Node's X509Certificate, e.g. `CN=OpenDPP Demo Eco Industries Seal`).
        issuer:
          type: string
          description: Leaf-certificate issuer RDN string.
        validFrom:
          type: string
          description: X.509 textual date, e.g. `Jan 10 00:00:00 2026 GMT` — NOT ISO 8601.
        validTo:
          type: string
          description: X.509 textual date — NOT ISO 8601.
        chainValid:
          type: boolean
          description: True only when every chain link signature-verifies, every
            certificate is within its validity window, AND the top of the chain
            is anchored to this node's seal CA (fingerprint match or signature
            under the CA key).
        keyMatchesProof:
          type: boolean
          description: True when the leaf SPKI equals the supplied `publicKey`
            (whitespace-insensitive), or when no explicit public key was
            supplied (the leaf key was used).
        error:
          type: string
          const: Unparseable x5c certificate chain
          description: Present only when the chain could not be parsed.
    SealTimestampReport:
      type: object
      description: "Present only when `payload.proof.rfc3161.token` was supplied AND
        verification proceeds past the key-registration and operator-binding
        gates (the two policy `verified: false` responses omit it). Reports
        presence, the TSA-asserted genTime from the token's TSTInfo, and — when
        the node has a TSA trust anchor configured — `timeAuthenticated`: the
        result of verifying the token's CMS SignedData signature over its
        TSTInfo and chaining the signer certificate to that anchor."
      required:
        - present
        - genTime
      properties:
        present:
          type: boolean
          const: true
        genTime:
          type:
            - string
            - "null"
          format: date-time
          description: TSA-asserted generation time (ISO 8601), or null when the token's
            TSTInfo could not be parsed.
        timeAuthenticated:
          type: boolean
          description: "True only when the token's RFC 3161 CMS SignedData signature
            verifies over its TSTInfo AND the signer passes full trust-path
            validation to the node's configured TSA trust anchor: the signer is
            an end-entity timestamping certificate (a CRITICAL
            `id-kp-timeStamping` EKU, not a CA) that is VALID at the asserted
            `genTime` and chains through CA-constrained, genTime-valid
            intermediates to the anchor (itself a CA valid at `genTime`). False
            when the signature fails, when the path is not policy-valid, and
            when no TSA CA is configured (the asserted `genTime` is then
            unauthenticated). This is the node's own cryptographic check and
            does not replace a verifier's independent `openssl ts -verify`."
        note:
          type: string
          const: token present but TSTInfo could not be parsed
          description: Present only when `genTime` is null.
    EpcisDocument:
      type: object
      description: A GS1 EPCIS 2.0 document (JSON/JSON-LD). This shape is indicative —
        the OFFICIAL GS1 EPCIS 2.0.1 JSON Schema (vendored on the node, $id
        https://ref.gs1.org/standards/epcis/2.0.1/epcis-json-schema.json) is
        authoritative and is what capture validates against.
      required:
        - "@context"
        - type
        - schemaVersion
        - creationDate
        - epcisBody
      properties:
        "@context":
          description: JSON-LD context — include
            https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld.
          oneOf:
            - type: string
            - type: array
              items: {}
        type:
          type: string
          enum:
            - EPCISDocument
        schemaVersion:
          type: string
          example: "2.0"
        creationDate:
          type: string
          format: date-time
        epcisBody:
          type: object
          required:
            - eventList
          properties:
            eventList:
              type: array
              items:
                type: object
                description: One EPCIS 2.0 event (ObjectEvent, AggregationEvent,
                  TransformationEvent, AssociationEvent — per the official
                  schema).
                additionalProperties: true
      additionalProperties: true
    EpcisCaptureResponse:
      description: "Per-event outcome of capturing an EPCIS 2.0 document, with
        partial-success semantics: some events may be stored while others fail."
      type: object
      required:
        - status
        - captured
        - results
        - errors
      properties:
        status:
          type: string
          enum:
            - success
        captured:
          type: integer
          description: How many events were persisted.
        results:
          type: array
          items:
            type: object
            required:
              - index
              - eventId
              - eventType
            properties:
              index:
                type: integer
                description: Position of the event in the submitted eventList.
              eventId:
                type: string
                description: Server-generated row id (UUID) — the authoritative event identity
                  on this node.
              eventType:
                type: string
              ignoredFields:
                type: array
                items:
                  type: string
                description: Recognized EPCIS fields present on the event that this node does
                  not persist — disclosed, never silently dropped.
        errors:
          type: array
          items:
            type: object
            required:
              - index
              - message
            properties:
              index:
                type: integer
              message:
                type: string
    DidWebDocument:
      type: object
      description: A tenant's `did:web` DID document (public-key material only).
        Verification methods are `JsonWebKey2020` entries with stable
        `#key-<index>` ids; current and retired keys are both listed so
        pre-rotation credentials still verify.
      required:
        - "@context"
        - id
        - verificationMethod
        - assertionMethod
        - authentication
      properties:
        "@context":
          type: array
          items:
            type: string
          description: "JSON-LD contexts: the DID core context plus the JWS-2020 suite."
        id:
          type: string
          description: The workspace DID, `did:web:opendpp-node.eu:tenants:{tenantId}`.
        name:
          type: string
          description: The issuer's authoritative legal name (present when the workspace
            has a company name).
        verificationMethod:
          type: array
          description: Public verification keys.
          items:
            type: object
            required:
              - id
              - type
              - controller
              - publicKeyJwk
            properties:
              id:
                type: string
                description: "`<did>#key-<index>` — matches the credential `kid`."
              type:
                type: string
                enum:
                  - JsonWebKey2020
              controller:
                type: string
              publicKeyJwk:
                type: object
                description: "The public key as a JWK (EC P-256, `alg: ES256`, `use: sig`).
                  Public material only."
        assertionMethod:
          type: array
          items:
            type: string
          description: Verification-method ids authorized to assert credentials (current +
            retired keys).
        authentication:
          type: array
          items:
            type: string
    WebhookEventFilter:
      type: string
      enum:
        - passport.ingested
        - passport.updated
        - passport.sealed
        - passport.recalled
        - passport.status_updated
        - "*"
      description: Subscribable event filter values. `*` matches every emitted event.
        `passport.status_updated` (decommission/reactivate) and
        `passport.updated` (in-place edit of a published passport) are now
        first-class subscribable filters.
    WebhookEnvelope:
      type: object
      description: The signed body of every webhook delivery. `data` is the public
        (redacted) JSON-LD passport document; `type` is the concrete event name
        (also in the `X-OpenDPP-Event` header); `id` is the stable delivery id
        (also in the `X-OpenDPP-Delivery` header) and is CONSTANT across all
        retries of the same event, so deduplicate on it for exactly-once
        processing; `created` is the event time (stable across retries, distinct
        from the per-attempt `X-OpenDPP-Timestamp`).
      required:
        - id
        - type
        - created
        - data
      properties:
        id:
          type: string
          description: Stable delivery id; mirrors the `X-OpenDPP-Delivery` header;
            constant across retries.
        type:
          type: string
          enum:
            - passport.ingested
            - passport.updated
            - passport.sealed
            - passport.recalled
            - passport.status_updated
          description: Concrete event type (also in the `X-OpenDPP-Event` header). Never
            the `*` filter value.
        created:
          type: string
          format: date-time
          description: Event creation/enqueue time (stable across retries).
        data:
          $ref: "#/components/schemas/PublicPassportJsonLd"
          description: The public (redacted) JSON-LD passport document — the same shape
            resolvers return.
    WebhookSubscriptionCreateRequest:
      description: An endpoint to receive webhook deliveries, and the event types it
        subscribes to.
      type: object
      required:
        - url
        - events
      properties:
        url:
          type: string
          format: uri
          description: "Absolute http(s) endpoint URL of your receiver (e.g. a PLM/ERP
            integration endpoint). DNS-resolved and SSRF-guarded at
            registration: malformed URLs, loopback, private (RFC 1918/CGNAT),
            link-local/cloud-metadata, multicast, and equivalent IPv6 ranges are
            rejected with 400. Redirects are never followed at delivery time."
        events:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/WebhookEventFilter"
          description: Non-empty list of event filters. Any value outside the allowed set
            is rejected with 400.
    WebhookSubscriptionRow:
      type: object
      description: A webhook subscription row with the HMAC signing `secret` stripped
        (it is shown exactly once, in the 201 create response).
      required:
        - id
        - tenantId
        - url
        - events
        - isActive
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          description: Subscription id.
        tenantId:
          type: string
          description: Owning workspace (tenant) id.
        url:
          type: string
          format: uri
          description: Receiver endpoint URL.
        events:
          type: array
          items:
            $ref: "#/components/schemas/WebhookEventFilter"
          description: Event filters this subscription matches (validated at creation).
        isActive:
          type: boolean
          description: "`true` while the subscription receives deliveries; only active
            subscriptions are delivered to. Toggle with `PATCH
            /api/v1/webhooks/subscriptions/{id}` (`isActive`) to pause/resume
            without deleting."
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    WebhookSubscriptionWithSecret:
      description: The full subscription row as returned ONLY by the 201 create
        response — includes the HMAC-SHA256 signing secret.
      allOf:
        - $ref: "#/components/schemas/WebhookSubscriptionRow"
        - type: object
          required:
            - secret
          properties:
            secret:
              type: string
              pattern: ^whsec_[0-9a-f]{32}$
              description: Server-generated HMAC-SHA256 signing key (`whsec_` + 32 lowercase
                hex chars). Shown ONCE — here and in the `rotate-secret`
                response only; the list endpoint strips it. The FULL string,
                including the `whsec_` prefix, is the HMAC key for delivery
                signatures. Rotate it with `POST
                /api/v1/webhooks/subscriptions/{id}/rotate-secret`.
    WebhookSubscriptionCreateResponse:
      description: Confirmation that a webhook subscription was created, carrying the
        stored subscription.
      type: object
      required:
        - success
        - message
        - subscription
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          const: Webhook subscription registered successfully
        subscription:
          $ref: "#/components/schemas/WebhookSubscriptionWithSecret"
    WebhookSubscriptionListResponse:
      description: The calling workspace's webhook subscriptions.
      type: object
      required:
        - success
        - subscriptions
      properties:
        success:
          type: boolean
          const: true
        subscriptions:
          type: array
          items:
            $ref: "#/components/schemas/WebhookSubscriptionRow"
    WebhookSubscriptionDeleteResponse:
      description: Confirmation that a webhook subscription was deleted.
      type: object
      required:
        - success
        - message
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          const: Webhook subscription successfully deleted
    WebhookSubscriptionUpdateRequest:
      type: object
      description: All fields optional; include only what you want to change. The
        secret is not editable here.
      properties:
        url:
          type: string
          format: uri
          description: New receiver URL. Re-validated by the SSRF guard.
        events:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/WebhookEventFilter"
          description: Replacement (non-empty) event-filter set.
        isActive:
          type: boolean
          description: Pause (`false`) or resume (`true`) deliveries.
    WebhookSubscriptionUpdateResponse:
      description: The webhook subscription as stored after the update.
      type: object
      required:
        - success
        - subscription
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
          description: Present when a change was applied (`"Webhook subscription
            updated"`); absent when the body had no recognized fields.
        subscription:
          $ref: "#/components/schemas/WebhookSubscriptionRow"
    WebhookSecretRotateResponse:
      description: Confirmation that a subscription's HMAC signing secret was rotated;
        the new secret is returned once and cannot be retrieved again.
      type: object
      required:
        - success
        - message
        - subscription
      properties:
        success:
          type: boolean
          const: true
        message:
          type: string
        subscription:
          $ref: "#/components/schemas/WebhookSubscriptionWithSecret"
    WebhookDeliveryRow:
      type: object
      description: One outbox delivery record (event-level). The payload is not included.
      required:
        - id
        - event
        - status
        - retryCount
        - lastAttempt
        - nextRetryAt
        - errorMessage
        - createdAt
      properties:
        id:
          type: string
          description: Outbox record id.
        event:
          type: string
          description: Event type, e.g. `passport.sealed`.
        status:
          type: string
          enum:
            - PENDING
            - DELIVERED
            - FAILED
          description: Overall delivery state. FAILED after 5 exhausted attempts
            (dead-lettered).
        retryCount:
          type: integer
          description: Failed attempts so far (0–5).
        lastAttempt:
          type:
            - string
            - "null"
          format: date-time
          description: Timestamp of the most recent attempt, or null if never attempted.
        nextRetryAt:
          type:
            - string
            - "null"
          format: date-time
          description: When the next retry is eligible (null if delivered or dead-lettered).
        errorMessage:
          type:
            - string
            - "null"
          description: Joined per-endpoint error text from the last failed attempt, or null.
        createdAt:
          type: string
          format: date-time
    WebhookDeliveriesResponse:
      description: Recent webhook delivery attempts for a subscription, newest first,
        for debugging endpoint failures.
      type: object
      required:
        - success
        - count
        - deliveries
      properties:
        success:
          type: boolean
          const: true
        count:
          type: integer
        deliveries:
          type: array
          items:
            $ref: "#/components/schemas/WebhookDeliveryRow"
    WebhookTestResult:
      description: The outcome of delivering a signed sample event to a subscription's
        endpoint right now.
      type: object
      required:
        - success
        - event
        - url
        - delivered
        - statusCode
        - error
      properties:
        success:
          type: boolean
          const: true
          description: The request was processed (NOT whether the receiver accepted it —
            see `delivered`).
        event:
          type: string
          description: The event type sent.
        url:
          type: string
          format: uri
          description: The receiver URL the test was sent to.
        delivered:
          type: boolean
          description: "`true` if the receiver returned a 2xx within the 5s timeout."
        statusCode:
          type:
            - integer
            - "null"
          description: The receiver's HTTP status, or null on a transport/SSRF error.
        error:
          type:
            - string
            - "null"
          description: Transport/SSRF error message when delivery failed, else null.
  responses:
    Unauthorized:
      description: "Missing, invalid, revoked or expired credentials. Send a valid
        `Authorization: Bearer op_dpp_token_…` header."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            success: false
            error: Unauthorized
            message: Missing or invalid authentication. Expected Bearer API key, JWT, or
              active session cookie.
    PaymentRequired:
      description: "The write is blocked by billing — the workspace subscription is
        lapsed / its grace period expired (reads are unaffected), OR (on
        passport-creating writes) the workspace has reached its plan's
        published-passport cap (`code: \"passport_quota_exceeded\"` + `quota` +
        `upgradeUrl`), OR a programmatic API-key write was attempted on a tier
        without API access (`code: \"api_access_required\"` + `upgradeUrl`; the
        dashboard/session path is unaffected). A lapsed-subscription block
        carries no `code`."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PassportQuotaError"
          examples:
            subscriptionLapsed:
              summary: Subscription lapsed
              value:
                success: false
                error: Payment Required
                message: Your workspace subscription status is 'past_due'. Write access is
                  suspended.
            passportQuotaExceeded:
              summary: Tier passport cap reached
              value:
                success: false
                error: Payment Required
                code: passport_quota_exceeded
                message: You have reached your plan's passport limit (250 of 250). Upgrade your
                  plan to add more product passports.
                quota:
                  tier: growth
                  activePassports: 250
                  passportLimit: 250
                upgradeUrl: /app/billing
            apiAccessRequired:
              summary: Programmatic API write not in plan
              value:
                success: false
                error: Payment Required
                code: api_access_required
                message: Your plan does not include programmatic API access. Upgrade to a plan
                  with API access to use API keys for this operation.
                upgradeUrl: /app/billing
    Forbidden:
      description: "Authenticated but not allowed: the key lacks the required
        permission, the request crosses workspaces, or an MFA-gated write was
        attempted without an MFA session."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            success: false
            error: Forbidden
            message: 'Insufficient permissions. Required: "passport:create".'
    NotFound:
      description: The resource does not exist or is not visible to the calling workspace.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            success: false
            error: Not Found
            message: Resource not found.
    OperatorNotFound:
      description: "The operator does not exist or is not bound to your workspace.
        Note: minimal envelope without an `error` key."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OperatorMinimalError"
          example:
            success: false
            message: Operator not found in your workspace.
    TooManyRequests:
      description: "Rate limit exceeded — either your key's per-minute plan budget (or
        the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit
        first. Inspect the `x-ratelimit-*` headers and retry after
        `Retry-After`. A `429` is never a credential problem: an invalid or
        revoked key returns `401`."
      headers:
        x-ratelimit-limit:
          description: Request ceiling for the current window.
          schema:
            type: integer
        x-ratelimit-remaining:
          description: Requests remaining in the current window.
          schema:
            type: integer
        x-ratelimit-reset:
          description: Seconds until the window resets.
          schema:
            type: integer
        retry-after:
          description: Seconds to wait before retrying.
          schema:
            type: integer
      content:
        application/json:
          schema:
            type: object
            description: Rate-limiter default body.
            required:
              - statusCode
              - error
              - message
            properties:
              statusCode:
                type: integer
              code:
                type: string
              error:
                type: string
              message:
                type: string
          example:
            statusCode: 429
            error: Too Many Requests
            message: Rate limit exceeded, retry in 1 minute
    PublicRateLimited:
      description: "Public-resolution rate limit exceeded (30 requests/min per IP; no
        rate-limit headers). With `Accept: text/html` an HTML page is returned
        instead."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: Too Many Requests
            message: Rate limit exceeded. Public passport resolutions are limited to 30
              requests per minute.
    NotAcceptable:
      description: The requested representation cannot be produced for this resource.
        Returned when a Verifiable Credential media type (`application/vc+jwt`,
        `application/vc+ld+json`, or `application/dc+sd-jwt`) is requested for a
        passport that has no manufacturing facility with a country of production
        — a conformant UNTP credential cannot be issued without it. Request the
        default JSON-LD (or another supported representation) instead.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: Not Acceptable
            message: "This passport cannot be represented as a UNTP Verifiable Credential:
              it has no manufacturing facility with a country of production."
    InternalError:
      description: Unexpected server error. Unhandled errors are normalized by the
        global error handler to this envelope with a generic message; some
        routes catch their own failures and return the same envelope with a
        route-specific message. Details are logged server-side and never
        returned.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            success: false
            error: Internal Server Error
            message: An unexpected error occurred
  parameters:
    PageParam:
      name: page
      in: query
      required: false
      description: 1-based page number (digits only).
      schema:
        type: integer
        minimum: 1
        default: 1
    LimitParam:
      name: limit
      in: query
      required: false
      description: Page size.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 10
    WebhookDeliveryHeader:
      name: X-OpenDPP-Delivery
      in: header
      required: true
      description: Stable delivery id (mirrors the envelope `id`). CONSTANT across
        retries of the same event — deduplicate on it for exactly-once
        processing.
      schema:
        type: string
    WebhookTimestampHeader:
      name: X-OpenDPP-Timestamp
      in: header
      required: true
      description: Unix epoch seconds (decimal string) minted at this delivery
        attempt; bound into the signature. Reject if skewed more than ~5 minutes
        from your clock.
      schema:
        type: string
        pattern: ^[0-9]+$
    WebhookSignatureHeader:
      name: X-OpenDPP-Signature
      in: header
      required: true
      description: Bare lowercase-hex HMAC-SHA256 of `<X-OpenDPP-Timestamp>.<raw
        request body>`, keyed with the full `whsec_…` subscription secret.
      schema:
        type: string
        pattern: ^[0-9a-f]{64}$
    WebhookUserAgentHeader:
      name: User-Agent
      in: header
      required: true
      schema:
        type: string
        const: OpenDPP-Webhook-Outbox/1.0
