{
  "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.\n\n## Authentication\nAuthenticate 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.\n\n## Tenancy\nTenant 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`).\n\n## Versioning & compatibility\nThis 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.\n\nWithin a major line:\n\n- **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.\n- **PATCH** is documentation only: wording, examples, descriptions. Nothing observable in the contract changes.\n\nThe 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.\n\n**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.\n\n## Errors\nAuthenticated 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.\n\nEvery 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.\n\n## Advisories: `warnings[]` & `notices[]`\nSuccess 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.\n\n## Rate limits\nTwo limits apply, and the one that bites first depends on how you call us.\n\n**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.\n\n**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.\n\nPublic passport resolution is additionally limited to **30 requests/min per IP** (no headers). The public validator is limited to **10 requests/min per IP**.\n\nStay 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.\n\n## Sealing & verification\nPassport 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.\n\n## Public access tiers\nPublic 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.\n\n## Webhooks\nSubscribe 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.\n\nThis document is also served machine-readably at [`/openapi.json`](https://opendpp-node.eu/openapi.json) and [`/openapi.yaml`](https://opendpp-node.eu/openapi.yaml).\n\n## Open interoperability kit\nThe 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.\n\nProfile, localization and billing details are deliberately not exposed here — this is the integration view of the credential.\n\n**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).\n\n**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.\n\n**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.\n\n**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).\n\n**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).\n\n**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.\n\n**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).\n\n**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.\n\n**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.\n\n**Permission:** `battery:read`. Operator-scoped credentials may only read passports of their own Economic Operator (403). Units are returned as stored.\n\n**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.\n\n**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.\n\n**Permission:** `battery:read`. Operator-scoped credentials may only read units whose passport belongs to their Economic Operator (403).\n\n**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.\n\n**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).\n\n**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.\n\n**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).\n\n**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.\n\n**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.\n\n**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**.\n\n**Permission:** `battery:read`. Operator-scoped credentials may only read units whose passport belongs to their Economic Operator (403). Events are returned as stored.\n\n**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.\n\n**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`).\n\n**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.\n\n**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.\n\n**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`.\n\n**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**.\n\n**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.\n\n**Country:** `country` must match `^[A-Za-z]{2}$` after trimming and is stored uppercased.\n\n**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.\n\n`activity`, `streetAddress`, `city` and `postalCode` are trimmed; empty/whitespace values are stored as `null`.\n\n**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.\n\nEmits a `facility.created` audit event and an in-app notification.\n\n**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.\n\n**Permission:** `facility:read` (Bearer API key or session JWT/cookie).\n\n**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}`).\n\nThe 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).\n\n**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.\n\n**Permission:** `facility:read`.\n\n**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.\n\nReturns 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.)\n\n**404 body:** standard envelope with message `Facility <id> not found under your Tenant workspace`.\n\n**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).\n\n**Permission:** `facility:write`. Cookie sessions must send `X-CSRF-Token`; write permissions are subscription-gated (**402** when lapsed).\n\n**Field semantics (all optional):**\n- `name` — applied only when a non-empty string; an empty/whitespace or non-string value is silently ignored (`name` can never be cleared).\n- `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.\n- `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.\n\nAn empty body (or one with no recognized fields) is accepted: the response is **200** with the otherwise-unchanged row, though `updatedAt` is still bumped.\n\n**Operator-scoped keys:** updating a facility that belongs to a different Economic Operator returns **403**; facilities with `operatorId: null` are updatable.\n\nEmits a `facility.updated` audit event recording the changed fields.\n\n**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.\n\n**Permission:** `facility:write`. Cookie sessions must send `X-CSRF-Token`; write permissions are subscription-gated (**402** when lapsed).\n\n**Operator-scoped keys:** deleting a facility that belongs to a different Economic Operator returns **403**; facilities with `operatorId: null` are deletable.\n\nEmits a `facility.deleted` audit event and an in-app notification. **404 body:** standard envelope with message `Facility <id> not found under your Tenant workspace`.\n\n**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.\n\n**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`.\n\nPaginated 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.\n\n**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.\n\n**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`.\n\nScope semantics:\n- `UNIT` — `batteryUnitId` is required; the unit must belong to this workspace. The unit's parent `passportId` is recorded on the grant.\n- `PASSPORT` — `passportId` is required; the passport must belong to this workspace and must not be a `DRAFT` (drafts return 404).\n- `TENANT` — workspace-wide; no target id needed.\n\n`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`.\n\nString 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).\n\nThe 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`.\n\n**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.\n\n**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`.\n\nBehavioral caveats (no status precondition — only the kind is checked):\n- Works on a grant in **any** status: revoking a `PENDING` request withdraws it; revoking a `DENIED` grant flips it to `REVOKED`.\n- Re-revoking an already-`REVOKED` grant returns 200 again and preserves the original `revokedAt`.\n- `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.\n\n**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).\n\nEach 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`.\n\n**Permission:** `operator:read`. Requests authenticated with an **operator-scoped API key** see only their own operator.\n\n**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.\n\n**Permission:** `operator:create`. Cookie-session clients must send the `X-CSRF-Token` header (double-submit); Bearer clients (API key / JWT) are exempt.\n\n**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.\n\n**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 `[]`.\n\nSide effects: an `operator.created` audit event and an in-app notification are recorded.\n\n**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).\n\n**Permission:** `operator:read`. An **operator-scoped API key** may only fetch its own operator (`403` otherwise).\n\n**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.\n\n**Permission:** `operator:write`. Cookie-session clients must send `X-CSRF-Token`; Bearer clients are exempt.\n\n**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.\n\nWhen 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).\n\n**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):\n\n- **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`.\n- **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.\n- **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.\n\n**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).\n\n**Tenant-scoped:** this affects only **your** workspace's operator and passports — operators are not shared across workspaces.\n\nSide 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).\n\n**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).\n\nSafe to call on a non-archived operator — it simply restores any archived passports the operator may have (`restoredPassports` may be `0`). No request body.\n\n**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.\n\nSide 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).\n\n**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.\n\nWhat happens:\n- 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**.\n- The tenant's published `eidasPublicKey` is updated to the new public key (SPKI PEM), which is also returned in the response.\n- 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).\n\n**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.\n\n**Permission:** `key:write`. Cookie-session clients must send `X-CSRF-Token`; Bearer clients are exempt.\n\n**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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb2zJ9bQ4mB1S0aD3kXq8YV5nW7Hc\nPdLmA2tFx0RkNqUjEe6ZsK1vGyTwoCh3M4i5O8rJlD9fXaB0nSgQp7Y2uw==\n-----END PUBLIC KEY-----\n"
                }
              }
            }
          },
          "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.\n\n**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**).\n\n**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.\n\n**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`.\n\n**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.\n\n**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}`.\n\n**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).\n\n**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.\n\n**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.\n\n**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.\n\n**Permission:** `passport:read` (read-only — no subscription/402 gate).\n\n**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.\n\n**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.\n\n**Serialization caveats:**\n- 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]\"`.\n- `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.\n- 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.\n\n**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.\n\n**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.\n\n**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.\n\n**Behavioral caveats:**\n- `operatorId` is accepted by the body schema but **ignored** by the handler.\n- 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.\n- `friendlyMessage` localization via `?lang=` / `Accept-Language` (28 languages, default `en`); category-validity errors (`metadata.category` missing or unknown) carry no `friendlyMessage`.\n- 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.\n\n**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*.\n\n**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.\n\n**Behavioral caveats:**\n- No tenant context: `operatorId` is accepted but ignored.\n- The 200 body always carries `errors: []`; `warnings` is omitted entirely when there are none (same omission on the 400 Validation Failed body).\n- Error/warning `friendlyMessage` localization via `?lang=` / `Accept-Language` (28 languages, default `en`); category-validity errors carry no `friendlyMessage`.\n- 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.\n\n**Permission:** `passport:create` (Bearer API key or session JWT + CSRF for cookie sessions; subscription gating → **402**).\n\n**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}`).\n\n**Per-row behavior (differences from `POST /api/v1/passports`):**\n- No `draft` support: every inserted row is created with `status: \"ACTIVE\"`. No `enrichment` support.\n- A valid GTIN-14/GRAI `productId` is **not** auto-copied into `metadata.gtin`/`metadata.grai` (unlike single ingestion).\n- 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.\n- Duplicate `(productId, operatorId)` rows, unknown facilities, and per-row DB failures become `errors[]` strings (prefixed `[SKU: <productId>]`), never a request-level failure.\n- Each successfully inserted row **transactionally enqueues a `passport.ingested` webhook event** (public redacted JSON-LD payload).\n- 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.\n\nNote 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.\n\n**Permission:** `passport:create` (Bearer API key or session JWT + CSRF for cookie sessions; subscription gating → **402**).\n\n**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**.\n\n**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[]`).\n\n**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).\n\n**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.\n\n**Caveats:**\n- **NO webhook is emitted** — unlike `POST /api/v1/passports` and `/bulk`, AAS ingestion never enqueues `passport.ingested` (or any other event).\n- 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).\n- 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.\n\n**Permission:** `passport:read` (read-only — no subscription/402 gate). Operator scoping + the non-archived filter match the passports list.\n\n**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.\n\n**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.\n\n**Permission:** `passport:read` (read-only — no subscription/402 gate).\n\n**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.\n\n**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).\n\nEvery successful resolution records an anonymized-IP access audit entry.\n\n**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\"`).\n\n**Permission:** `passport:update` (write — subscription gating applies, see 402). Cookie sessions must send `X-CSRF-Token` (double-submit); Bearer/API-key clients are exempt.\n\n**Lookup:** by passport **UUID only** — `productId` aliasing is NOT supported on this endpoint. The passport must belong to an operator bound to your workspace.\n\n**Draft semantics (`draft` flag):**\n- `draft: true` **skips ESPR validation entirely** and forces `status: \"DRAFT\"` — note this also demotes an already-published (ACTIVE/RECALLED/DECOMMISSIONED) passport back to DRAFT.\n- `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).\n\n**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).\n\n**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.\").\n\n**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).\n\n**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.\n\n**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.\n\n**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.\n\nPublished 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.\n\n**Permission:** `passport:update` (write — subscription gating applies, see 402). Cookie sessions must send `X-CSRF-Token`; Bearer/API-key clients are exempt.\n\n**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.\n\n**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).\n\nA `passport.sealed` webhook is enqueued transactionally with the update (payload: the public-redacted JSON-LD document including the full `proof` block).\n\n**Permission:** `passport:seal` (write — subscription gating applies, see 402). Cookie sessions must send `X-CSRF-Token`; Bearer/API-key clients are exempt.\n\n**Lookup:** passport **UUID or `productId`** (UUID tried first), restricted to the passport's **owning tenant**.\n\n**Behavioral caveats:**\n- 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.\n- Re-sealing an already-sealed passport is allowed and **overwrites** the previous seal/timestamp.\n- Once sealed, in-place metadata edits are refused (403 on `PUT /api/v1/passports/{id}`).\n- Requires the tenant's signing key pair to exist — otherwise 400.\n- 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).\n\n**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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1u2v3w4x5y6z7A8B9C0D1E2F3G4H\n5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3a4b5c6d7e8f9g0h1i2j3k4l5m6n\n-----END PUBLIC KEY-----\n",
                  "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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1u2v3w4x5y6z7A8B9C0D1E2F3G4H\n5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3a4b5c6d7e8f9g0h1i2j3k4l5m6n\n-----END PUBLIC KEY-----\n",
                    "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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1u2v3w4x5y6z7A8B9C0D1E2F3G4H\n5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3a4b5c6d7e8f9g0h1i2j3k4l5m6n\n-----END PUBLIC KEY-----\n",
                      "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>`).\n\n**Permission:** `passport:update` (write — subscription gating applies, see 402). Cookie sessions must send `X-CSRF-Token`; Bearer/API-key clients are exempt.\n\n**Lookup:** passport **UUID or `productId`** (UUID tried first), scoped to operators bound to your workspace.\n\n**Effects:**\n- `DECOMMISSIONED` — sets `retentionUntil = now + the configured retention period` (default 15 years), starting the minimum-availability retention clock. The passport stays publicly resolvable.\n- `ACTIVE` (reactivation) — clears `retentionUntil` **and** `archivedAt`.\n- `RECALLED` — marks the product recalled.\n- 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).\n\n**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.\n\n**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.\n\n**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}`).\n\n**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.\n\n**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:\n- **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.\n- **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`.\n- **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`.\n\nDRAFT 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.\n\n**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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7sB3kFq2nXp9wLm4Rv8tYc1dZh6j\nKe0aGu5iSx3oNl2bPw9rT7vCmD4fHg8qWy1zEjU6kA0sIxLpO5tMnBvR3Q==\n-----END PUBLIC KEY-----\n",
                  "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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7sB3kFq2nXp9wLm4Rv8tYc1dZh6j\nKe0aGu5iSx3oNl2bPw9rT7vCmD4fHg8qWy1zEjU6kA0sIxLpO5tMnBvR3Q==\n-----END PUBLIC KEY-----\n",
                    "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).\n\nContent 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.\n\nThe 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.\n\n**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):\n\n1. 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).\n2. 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}`.\n3. Otherwise → `404` (content-negotiated).\n\nThe 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).\n\nNo 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.\n\nAn 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}`).\n\n**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`.\n\n**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).\n\n**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}`).\n\nEvery 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.\n\nNo 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.\n\n**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-----\nMIIBszCCAVmgAwIBAgIUQ2FDZXJ0RXhhbXBsZURhdGFCYXNlNjQwCgYIKoZIzj0E\nAwIwIDEeMBwGA1UEAwwVT3BlbkRQUCBTZWFsIENBIChEZW1vKQ==\n-----END CERTIFICATE-----\n"
              }
            }
          },
          "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.\n\n**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`.\n\nThe 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.\n\nNo 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`).\n\n(The separate `/context/v1` serves an older, fixed term-only list and is **not** the context emitted documents point to.)\n\nNo 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.\n\nNo 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.\n\n**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**.\n\nPublic + stateless (pure arithmetic; no tenant data). No authentication required.\n\n**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.\n\n**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.\n\n**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.\n\n**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).\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\n**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`.\n\n**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`.\n\n**Permission:** `passport:read` (read-only — no subscription/402 gate, and NOT subject to the programmatic API-write entitlement).\n\n**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.\n\n**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.\n\n**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).\n\n**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.\n\n**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.\n\n**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.\n\n**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`.\n\n**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.\n\n**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.`.\n\n**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.\n\n**Envelope caveat:** the 200 body is `{ \"materials\": [...] }` — there is **no `success` field** on this endpoint.\n\n**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.\n\n**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.\n\n**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.\n\n**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`.\n\n**Validation pipeline (in order):**\n1. *Structural* — the body must be an object containing `credentialSubject`, otherwise 400 `Bad Request`.\n2. *EPCIS rule* — `action` is strictly forbidden on `TransformationEvent` (any non-null value → 400 `Schema Validation Error`).\n3. *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`.\n4. *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>)\"`.\n\n**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.\n\n**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.\n\n**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.\n\n**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`.\n\n**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.\n\n**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[]`.\n\n**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.\n\n**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).\n\n**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).\n\n**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`.\n\n**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`.\n\n**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.\n\n**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.\"}`.\n\n**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.\n\n**Verification pipeline (in order):**\n1. **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.\n2. **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.\n3. **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.\n4. **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`.\n5. **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.\n\n**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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEexampleDemoTenantSealPublicKey\n0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO=\n-----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.\n\nUse 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`).\n\nNo 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`.\n\nNo 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.\n\n**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`).\n\n**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.\n\n**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.\n\n**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.\n\n**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).\n\n**Permission:** `webhook:read` (read permissions are not subscription-gated, so no `402`).\n\nThe 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.\n\n**Permission:** `webhook:write` (cookie sessions must send `X-CSRF-Token`; write permissions are subscription-gated, `402`).\n\nThe 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**.\n\n**Permission:** `webhook:write`. Cookie sessions must send `X-CSRF-Token`; write permissions are subscription-gated (**402** when lapsed).\n\n**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.\n\nUpdate your receiver to verify signatures with the new secret as soon as you rotate — deliveries in flight use whichever secret was current when signed.\n\n**Permission:** `webhook:write`. Cookie sessions must send `X-CSRF-Token`; write permissions are subscription-gated (**402** when lapsed).\n\n**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.\n\nFilter with `?status=PENDING|DELIVERED|FAILED` and cap with `?limit=` (1–200, default 50; a non-numeric value falls back to the default).\n\n**Permission:** `webhook:read`.\n\n**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`).\n\n**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 `*`.\n\n**Delivery contract** (sender `User-Agent: OpenDPP-Webhook-Outbox/1.0`):\n- 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.\n- **Success = any HTTP 2xx** returned within the **5-second** timeout. Redirects are **never followed** — a 3xx counts as failure. The response body is ignored.\n- **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.\n- **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.\n- **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 `*`.\n\n**Delivery contract** (sender `User-Agent: OpenDPP-Webhook-Outbox/1.0`):\n- 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`.\n- **Success = any HTTP 2xx** within the **5-second** timeout. Redirects are **never followed** (3xx = failure). Response body ignored.\n- **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.\n- **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.\n- **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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEx7Qb1k2mP4cV9sJh3nT6fL0wR8dZ\n5yA1uE7gXiK2oB4qNvC6tS9jM8hW0aFrD3pUzGmYLk5eHxOI1cR7vJbT2g==\n-----END PUBLIC KEY-----\n",
                  "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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEx7Qb1k2mP4cV9sJh3nT6fL0wR8dZ\n5yA1uE7gXiK2oB4qNvC6tS9jM8hW0aFrD3pUzGmYLk5eHxOI1cR7vJbT2g==\n-----END PUBLIC KEY-----\n",
                    "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 `*`.\n\n**Delivery contract** (sender `User-Agent: OpenDPP-Webhook-Outbox/1.0`):\n- 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.\n- **Success = any HTTP 2xx** within the **5-second** timeout. Redirects are **never followed** (3xx = failure). Response body ignored.\n- **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.\n- **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.\n- **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 `*`).\n\n**Delivery contract** (sender `User-Agent: OpenDPP-Webhook-Outbox/1.0`):\n- 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.\n- **Success = any HTTP 2xx** within the **5-second** timeout. Redirects are **never followed** (3xx = failure). Response body ignored.\n- **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.\n- **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.\n- **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 `*`.\n\n**Delivery contract** (sender `User-Agent: OpenDPP-Webhook-Outbox/1.0`):\n- 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.\n- **Success = any HTTP 2xx** within the **5-second** timeout. Redirects are **never followed** (3xx = failure). Response body ignored.\n- **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.\n- **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.\n- **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"
        }
      }
    }
  }
}