openapi: 3.1.0
info:
  title: Odal Node API
  version: 0.14.0
  description: |
    **Sovereign Digital Product Passport Infrastructure**

    The Odal API is split across two deployable services:

    - **Node** (`http://localhost:8001`) — the MVP service that bundles vault,
      identity, and integrator on a single port with sub-path routing:
      `/vault/*`, `/identity/*`, `/integrator/*`.
    - **Resolver** (`http://localhost:8003`) — standalone public resolver for
      QR code scans and supply-chain integrations. Unauthenticated.

    All authenticated vault endpoints are versioned under `/vault/api/v1/`.
    Path parameters use OpenAPI 3.1 `{id}` brace syntax (aligned with
    Axum 0.8 routing).

    Authentication uses `Authorization: Bearer odal_sk_...` API keys (SHA-256
    hashed, prefix-indexed) or HTTP Basic auth for local development.

    Regulation references:
    - EU Ecodesign for Sustainable Products Regulation (ESPR) 2024/1781
    - EU Battery Regulation 2023/1542
    - W3C DID Core Spec 1.0 / W3C VC Data Model 2.0
  contact:
    name: Odal Support
    email: contact@odal-node.io
    url: https://docs.odal-node.io
  license:
    name: BSL-1.1
    url: https://mariadb.com/bsl11/
  x-legal-entity: Odal Node
servers:
  - url: http://localhost:8001
    description: Local development — odal-node (vault + identity public + integrator)
  - url: http://localhost:8002
    description: Local development — odal-identity (standalone; hosts the mTLS internal signing surface)
  - url: http://localhost:8003
    description: Local development — odal-resolver (public)
tags:
  - name: DPP Management
    description: Create, read, update, list, and audit Digital Product Passports.
  - name: DPP Lifecycle
    description: Lifecycle transitions — publish, suspend, retire, end-of-life, and transfer of responsibility.
  - name: Evidence Dossiers
    description: Signed, self-contained evidence dossiers — generate, fetch, and verify (stored or uploaded) offline, with zero trust in the issuing node.
  - name: Registry
    description: EU-registry registration state — per-passport and operator-wide, plus the operator's verified-registry standing.
  - name: Scan Telemetry
    description: Aggregate, privacy-safe resolution counts — per-passport and operator-wide rollups.
  - name: Operator
    description: Operator configuration (branding, legal info, retention policy).
  - name: Facilities
    description: Manufacturing/processing facilities (ESPR Annex III) stamped onto new passports.
  - name: Operator Identifiers
    description: Economic-operator identifiers (ESPR Art. 13) stamped onto new passports.
  - name: API Keys
    description: API key management — create, list, revoke.
  - name: Webhooks
    description: Signed outbound event delivery — subscribe, list, remove, test.
  - name: Plugins
    description: Signed product-group plugin hot-install — verify, persist, hot-swap (admin-only).
  - name: Ruleset
    description: Compliance Current — the signed, versioned ruleset channel. Re-read it and hot-swap a verified bundle without a node restart (admin-only).
  - name: Node
    description: Node setup state, and what credential the caller presented. `/whoami` reports only what the request already carried; it is not a lookup.
  - name: Public (Vault)
    description: Unauthenticated vault endpoints for inter-service communication.
  - name: Credentialed Access
    description: Audience-scoped passport reads authenticated by a verifiable credential rather than an API key — repairers, market-surveillance authorities. Deliberately outside both `/public` (a public URL whose body varies by caller breaks caching and the meaning of `publicJwsSignature`) and `/api/v1` (API keys are the operator's own machine access; a repairer or authority holds a credential and no key).
  - name: Vault (internal)
    description: mTLS service-to-service telemetry ingestion (resolver → vault only).
  - name: Integrator
    description: CSV/XLSX bulk import — product groups, their versioned JSON Schemas, templates, upload, and async job polling.
  - name: Identity
    description: Public did:web identity — DID document, health, readiness. On the fused node these are served under `/identity/*`. The standalone identity service (:8002) serves them at the root — `/health`, `/ready`, `/.well-known/did.json`.
  - name: Identity (internal)
    description: mTLS service-to-service signing/rotation. **Not exposed by the fused node**, which signs in-process — reachable only on the standalone identity service, and only from a client presenting `CN=odal-vault`.
  - name: Public Resolver
    description: Unauthenticated public endpoints for QR scan resolution.
  - name: Health
    description: Health and readiness probes, plus the vault's build metadata — every deployable's unauthenticated liveness surface in one place.
paths:
  /vault/api/v1/dpp:
    post:
      operationId: createDpp
      summary: Create a DPP
      description: |
        Create a new Digital Product Passport in `draft` status.
        Only `productName` (non-blank) and `manufacturer` (with `name`
        and `address`) are required. All other fields can be supplied
        later via `PUT` before publishing.

        Returns the full passport record with a server-assigned UUID v7 `id`.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePassportRequest'
            example:
              productName: EcoCell Pro 48V
              productGroup: battery
              manufacturer:
                name: EcoTech GmbH
                address: Hauptstraße 1, 10115 Berlin
              co2ePerUnit: 4.2
              batchId: BATCH-2026-04-001
              productGroupData:
                productGroup: battery
                productIdentifier:
                  scheme: gs1
                  gtin: '09506000134352'
                batteryChemistry: LFP
                nominalVoltageV: 48
                nominalCapacityAh: 100
                expectedLifetimeCycles: 3000
                co2ePerUnitKg: 45.2
      responses:
        '201':
          description: DPP created in draft status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotentRequestInFlight'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/dpp/validate:
    post:
      operationId: validateDpp
      summary: Dry-run a passport body without creating it
      description: |
        Runs exactly the validation `POST /vault/api/v1/dpp` runs, and persists
        nothing. The same code path serves both, so a preview and the real create
        cannot disagree.

        **Two verdicts, because create and publish deliberately differ.** Create is
        lenient about a product group with no resolvable JSON Schema — a draft is allowed
        to be incomplete — while publish fails closed on it, since a signed
        passport must have passed a real schema check. A body can therefore be
        creatable but not yet clear the publish-time schema gate, and that gap is
        reported rather than hidden until publish is attempted.

        A body that create **would reject** gets back the identical `422` create
        would have returned, not a paraphrase of it. A body create would accept
        returns `200` with the two verdicts.

        **`productGroupDataValid` is not a publish verdict.** It reports one of publish's
        preconditions — the product group-data gates — and publish applies others this
        route does not run:

        - the **registry identity** requirement (a default facility and a primary
          operator identifier), which needs operator state this route never reads;
        - the **category-mandatory content** gate, which is reachable only by
          attempting the lifecycle transition, so it cannot be previewed;
        - the **compliance** gate, which needs a `placedOnMarketDate` and a stored
          passport.

        A `productGroupDataValid: true` therefore means "this body clears the schema
        gate", never "publish will succeed". The field was called `publishValid`
        and was renamed because that name promised the latter.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePassportRequest'
      responses:
        '200':
          description: |
            The body is creatable. `productGroupDataValid` says whether its product group data
            would also clear the publish-time schema gate.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidateResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/dpps:
    get:
      operationId: listDpps
      summary: List DPPs
      description: |
        Paginated list of DPPs for the authenticated operator.
        Supports filtering by status, free-text search across
        `productName`, `batchId`, and `manufacturer.name`, and an exact
        `facilityId` match (ESPR Annex III). A grouping filter, never an
        isolation boundary.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: q
          in: query
          schema:
            type: string
          description: Free-text search across productName, batchId, manufacturer.name
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/PassportStatus'
        - name: facilityId
          in: query
          schema:
            type: string
          description: Exact match on the facility identifier stamped on the passport (see GET /facilities).
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: skip
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Paginated list of DPPs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /vault/api/v1/dpp/by-identity:
    get:
      operationId: findDppByIdentity
      summary: Find a DPP by exact compound identity
      description: |
        Look up a passport by exact (product group, product identifier, batch)
        match, across `draft` and `active` statuses. Backs the import
        delta-matcher — not intended as a general-purpose search (use `GET /dpps`
        for that). `batchId` omitted matches only passports with no batch set.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: productGroup
          in: query
          required: true
          schema:
            type: string
            example: battery
        - name: identifier
          in: query
          required: true
          description: |
            The product identifier's value under whichever EN 18219 clause 5
            scheme issued it: the 14-digit GTIN for scheme 1, the identification
            link for scheme 2, the DID for scheme 3.
          schema:
            type: string
            example: '09506000134352'
        - name: batchId
          in: query
          required: false
          schema:
            type: string
      responses:
        '200':
          description: The matching DPP record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/dpp/{dppId}:
    get:
      operationId: getDpp
      summary: Get a DPP (write-side)
      description: |
        Retrieve the full DPP record including drafts.
        Scoped to the authenticated operator. For the public read
        (post-publish), use the resolver's `GET /dpp/{dppId}`.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: Full DPP record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      operationId: updateDpp
      summary: Update a DPP
      description: |
        JSON merge-patch on a `draft` or `active` DPP.
        The request body is a free-form JSON object; only supplied
        fields are changed. Returns `409 Conflict` if the DPP is
        `suspended` or `retired`.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Free-form merge-patch. Supply only the fields being changed.
      responses:
        '200':
          description: Updated DPP record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/dpp/{dppId}/history:
    get:
      operationId: getDppHistory
      summary: Get DPP audit history
      description: |
        Returns the chronological audit trail for a passport: creation,
        status transitions, field updates, etc. Unbounded — returns the full
        trail with no pagination or limit.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: List of audit entries.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PassportAuditEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/dpp/{dppId}/versions:
    get:
      operationId: getDppVersions
      summary: List a DPP's archived versions, or reconstitute one
      description: |
        Returns every archived version of a passport, oldest first — or, with
        `asOf`, the single version that was current at that instant.

        ✅ EN 18221:2026 clause 4.2: an authenticated, authorised reader can
        retrieve the passport as it stood at any given moment.

        🚨 **Not `POST /dpp/{dppId}/retire`.** That moves a passport to the terminal
        `retired` lifecycle status after its retention period has elapsed. This is
        the standard's sense of the word — historical versions of a passport that is
        still live — and the two are unrelated.

        Archiving begins at the **first change** to a passport, as the clause
        requires, so a passport that has never changed has no archived versions and
        returns an empty list. That is not a gap: the live record is the state at
        every moment before the first change.

        Each version is the complete record, so a reader reconstitutes rather than
        replays. Versions are append-only and are kept for the passport's lifetime.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
        - name: asOf
          in: query
          required: false
          description: |
            An RFC 3339 instant. Returns the single version that was current then,
            rather than the whole list.

            `404` when no archived version covers that moment, in two distinct cases
            that the response body tells apart:

            - the passport had not changed by then, or the moment is after its most
              recent change. The **live** record is the state at that time, which
              `GET /dpp/{dppId}` already serves; this route does not return it, so
              that "which version is this" stays answerable.
            - the moment is **before the passport was created**, where nothing was
              its state — not an archived version, and not the live record either.

            🚨 The second case is a `404` rather than the initial version. Asking for
            a version at a time before the record existed is a question with no true
            answer, and answering with the initial one would assert that the passport
            existed then.
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: |-
            The archived versions, oldest first. With `asOf`, the one that was current then, as a one-element array.

            Always an array, with or without `asOf`: one response shape is simpler to consume than two, and a client that parses an array does not have to remember which query it sent to know what it is holding.

            An **empty** array is reachable only without `asOf`, and it says the passport has never changed — archiving begins at the first change, so there is nothing historical to return and the live record is the state at every moment so far. With `asOf`, a moment no archived version covers is a `404` rather than an empty array, because the answer there is the live record and this route deliberately does not serve it.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PassportVersion'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: '`asOf` was given and is not an RFC 3339 timestamp.'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /vault/api/v1/dpp/{dppId}/lint:
    post:
      operationId: relintDpp
      summary: Re-check plausibility-lint findings
      description: |
        Recomputes the plausibility lint and persists the refreshed `lintResult`
        (pack version, findings, assessed-at timestamp). Findings are non-binding —
        arithmetic and physical-plausibility checks distinct from binding
        compliance rules — and never gate publish or any other transition.

        Two sets of checks run. The `dpp-rules` plausibility **pack** runs against
        the DPP's current product group data, and `packVersion` names its version.
        The **envelope** checks run against fields every product group carries, so
        they apply to a DPP with no product group data at all: today that is
        `lineage.life_status_unsupported`, which reports a `lifeStatus` that the
        record's own `derivedFrom` edges do not support — a unit claiming
        `remanufactured` whose only edge says `repurposing`, or an `original` that
        claims a predecessor at all.

        The status is stored and then checked rather than derived from the edges.
        Regulation (EU) 2023/1542 Art. 77(7) permits several predecessors with
        nothing forcing them to share an operation, so a unit built from one
        repurposed and one remanufactured predecessor has no unambiguous derived
        status; the check asks that **at least one** edge support the claim.

        Works regardless of DPP status, including `active` (published):
        re-checking does not retroactively affect the passport's JWS
        signature, which is frozen over whatever `lintResult` looked like at
        publish time. No request body is required.

        The response also reports **`publishReadiness`**: whether this passport
        would clear the category mandatory-content gate and the product-group
        data/schema gates. That is the gate that most often refuses a battery, and
        asking it here is the only way to learn the answer without attempting the
        publish.

        `publishReadiness.passportScope` answers a different question from the
        blockers: whether Art. 77(1) requires a battery passport for this record at
        all. The Regulation defines five battery categories and that article reaches
        three — LMT, electric-vehicle, and industrial above 2 kWh — so a portable or
        SLI passport is voluntary. This node applies the content gate either way,
        which for a voluntary passport is stricter than the article requires; the
        `note` says so rather than leaving the caller to wonder why they are being
        asked.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: |
            Lint findings refreshed. Returns the full passport record, plus
            `publishReadiness` — whether this passport would clear the publish gates
            that can be answered without attempting the transition.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PassportResponse'
                  - type: object
                    required:
                      - publishReadiness
                    properties:
                      publishReadiness:
                        $ref: '#/components/schemas/PublishReadiness'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/dpp/{dppId}/verify-tree:
    get:
      operationId: verifyDppTree
      summary: Recursively verify a passport's component tree (BOM)
      description: |
        Walks the passport's `componentRefs` breadth-first, fetching each node
        and checking its public JWS against the pinned hash. Fails closed on
        every ambiguity, bounded by a depth cap and a total-node cap; the report
        names the path from the root to any broken node.

        Integrity only: this proves each node's signed public view is unchanged
        (hash pin), not the cryptographic validity of the signature.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: The component-tree verification report.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TreeReport'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/dpp/{dppId}/seal:
    get:
      operationId: getDppSeal
      summary: Fetch the passport's eIDAS qualified electronic seal
      description: |
        Returns the qualified seal a QTSP applied to this passport, together with
        the compact JWS it was taken over and that JWS's SHA-256 digest.

        The seal has its own route because it is stripped from every audience
        view, public included: it covers the **full**-payload `jwsSignature`, so
        attaching it to a redacted body would hand the reader a proof that
        verifies against nothing they received.

        **This node does not validate the seal.** A detached CAdES must be
        checked by an independent AdES validator against the EU Trusted List. A
        verdict from the node that bought the seal would attest nothing, so none
        is offered.

        `coverage` answers a narrower question that the node *can* answer, from
        its own records: `sealedPayloadHash` is the digest it asked the backend
        to seal, so a passport re-published after sealing shows as `superseded`
        without any AdES tooling. That is a record of what was requested, not
        proof of what the CAdES covers — the validator's extracted message
        digest is the cross-check. A `superseded` seal remains valid for the
        signature it does cover; a seal over the new signature has not landed
        yet.

        `404` when the passport has no seal — it may be unpublished, its seal may
        still be queued, or the node may have no QTSP configured. An unsealed
        passport has no seal resource rather than an empty one.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: The qualified seal and the signature it attests to.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SealResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/dpp/{dppId}/seal/repair:
    post:
      operationId: repairDppSeal
      summary: Re-seal a passport whose stored seal does not verify
      description: |
        Queues a replacement seal for a passport whose stored seal is broken, and
        refuses unless it demonstrably is.

        **This buys a second seal for a digest already paid for.** The node's own
        repair sweep deliberately cannot do that — it only queues passports
        carrying *no seal at all*, which is what lets it run unattended without
        spending money twice. A broken seal is the case where spending it again is
        right: the row was paid for and carries nothing, so the passport is
        published and, in substance, unsealed. That is a decision for whoever pays,
        taken per passport, which is why it is a route rather than a background
        loop.

        **The seal is opened and checked at the moment of the request**, never read
        from the audit's list. A stale finding would buy a seal for a passport that
        has since been repaired or re-published.

        Not idempotency-keyed, and does not need to be: a seal row is keyed by
        `(passportId, payloadHash)`, so a retried request re-arms a row that is
        already queued, which is a no-op. A second repair after the replacement
        lands is refused, because the new seal verifies.

        The replacement covers the passport's **current** signature. Where the
        passport was re-published since the broken seal was made, that signature has
        never been sealed, so nothing is re-bought and `action` is `queued` rather
        than `rearmed`.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: A replacement seal has been queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SealRepairResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            No passport with that id. Distinct from `422`, which means the passport
            exists and is not in a state this repairs.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '422':
          description: |
            The passport exists and there is nothing here to repair. The detail says
            which: the seal verifies; it is intact but covers a superseded signature,
            whose replacement is already queued by the re-publish that caused it;
            there is no seal at all, which the node's own sweep covers at no extra
            cost; the seal could not be read, so it cannot be shown to be broken;
            this node has no sealing backend, so a queued repair would never drain;
            or the passport carries a seal and no signature, which should not occur
            and for which a replacement would cover nothing.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /vault/api/v1/seal:
    get:
      operationId: getSealSummary
      summary: Operator-wide sealing state
      description: |
        How many published passports carry no seal, plus the outbox totals
        behind that number.

        `unsealedPublished` is the headline; the three row counts are context.
        They answer different questions and can legitimately disagree: the
        counts describe outbox **rows**, while the obligation is about
        **passports**. Enqueueing happens after the publish commits, so a crash
        in that window publishes a passport that no row will ever cover —
        `pending: 0, exhausted: 0` is therefore consistent with any number of
        unsealed passports, and a summary built on rows alone would report all
        clear. A repair sweep queues those passports on its next pass.

        A passport whose seal covers a *superseded* signature is not counted
        here — it carries a seal, and that seal remains a valid attestation of
        the signature it was bought for. `GET /vault/api/v1/dpp/{dppId}/seal`
        reports that case per passport as `coverage`.

        When `sealingConfigured` is `false` no seal provider is selected, so
        every count is `0` because this node has no outbox — not because it has
        nothing outstanding.
      tags:
        - DPP Management
      security:
        - BearerApiKey: []
        - BasicAuth: []
      responses:
        '200':
          description: Operator-wide sealing state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SealSummaryResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /vault/api/v1/dpp/{dppId}/publish:
    post:
      operationId: publishDpp
      summary: Publish a DPP
      description: |
        Transition a DPP from `draft` to `active`.

        On success:
        1. `qrCodeUrl` is set to the passport's GS1 Digital Link carrier —
           `{resolverBase}/01/{gtin}/21/{serial}` for a trade item, else
           `{resolverBase}/dpp/{id}`
        2. The identity service signs the VC payload with Ed25519 (best-effort;
           signing failure does not block publish — `jwsSignature` may be null)
        3. `retentionLocked` is set to `true` permanently
        4. `status` = `active`, `publishedAt` = now
        5. A `dpp.published` NATS event is emitted (fire-after-commit)

        No request body is required.
      tags:
        - DPP Lifecycle
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: DPP published successfully. Returns the full passport record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/dpp/{dppId}/amend:
    post:
      operationId: amendDpp
      summary: Amend a published DPP by issuing a successor
      description: |
        Correct a published DPP. A published passport cannot be edited in place —
        its signatures commit to its bytes and the retention guard refuses the
        write — so this issues a **new** passport instead.

        The successor is a copy of `dppId` with the `patch` applied, carrying
        `supersedesId` back to `dppId` and `version` incremented. It goes through
        the same gates as any first publish: schema validation, product-group
        validation, mandatory-content checks, signing, and registry sync. When it
        publishes successfully, `dppId` moves to the terminal `superseded` state.

        **The response body is a different passport from the one in the path.** It
        is the successor, with its own `id`. Clients holding the old id keep a valid
        reference: a superseded passport stays resolvable, keeps its signatures and
        its seal, and reports `superseded` as its status.

        Only an `active` DPP can be amended. A draft is edited in place with
        `PUT /vault/api/v1/dpp/{dppId}`; a suspended, retired, superseded or
        deactivated DPP cannot be superseded.

        The schema version is **inherited**, not advanced to the product group's
        current one. An amendment corrects content; migrating a passport to a newer
        schema is a separate act. This also keeps the successor's disclosure classes
        identical to the predecessor's, since those are read from the schema
        version's own annotations.

        Emits `dpp.passport.published` for the successor and
        `dpp.passport.superseded` for the predecessor.
      tags:
        - DPP Lifecycle
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AmendRequest'
      responses:
        '201':
          description: |
            Successor published and predecessor superseded. Returns the **successor**
            — a different record from the one named in the path, with its own `id`,
            `version` incremented, and `supersedesId` set to `dppId`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/dpp/{dppId}/supersede:
    post:
      operationId: supersedeDpp
      summary: Retire a DPP in favour of a newer one
      description: |
        Marks the DPP in the path `superseded`, retiring it in favour of the
        replacement named in `supersededBy`.

        **The link is checked here, not written.** The successor must already carry
        `supersedesId` pointing back at this passport, set when it was created —
        `supersedesId` is a protected field and no field patch can add it later. A
        successor that does not declare the link is refused with `422`.

        That ordering is deliberate. Writing the link during this transition would
        leave, on a failure between the two writes, a retired passport with nothing
        pointing at its replacement — the one state a reader cannot recover from.
        Requiring the link to exist first makes that unreachable.

        **Both must already be published.** A draft successor retiring a live
        passport would leave the product with no servable record, and a
        not-yet-published successor may still fail its own gates. A predecessor that
        is not published has nothing to retire.

        Terminal and irreversible: a superseded passport accepts no further
        transitions, and the successor is what a reader follows forward.

        It keeps serving on the public tier. Superseding replaces the *record*, not
        the goods: products made under the old specification are still in the field
        carrying data carriers that resolve to it, and ESPR Art. 9(2)(i) asks the
        passport to remain available for at least the product's expected lifetime.
      tags:
        - DPP Lifecycle
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SupersedeRequest'
      responses:
        '200':
          description: DPP superseded. Returns the retired passport's record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/dpp/{dppId}/suspend:
    post:
      operationId: suspendDpp
      summary: Suspend a DPP
      description: |
        Transition an `active` DPP to `suspended`.
        The DPP becomes non-resolvable. The JWS signature is preserved.
        Emits a `dpp.suspended` NATS event.

        An optional request body with a `reason` field can be provided.
      tags:
        - DPP Lifecycle
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SuspendRequest'
      responses:
        '200':
          description: DPP suspended. Returns the full passport record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
  /vault/api/v1/dpp/{dppId}/retire:
    post:
      operationId: retireDpp
      summary: Retire a DPP
      description: |
        Transition a DPP to `retired`. This is irreversible.
        Retired DPPs are immutable and retained for regulatory record-keeping.
        No request body is required.

        🚨 **Not archiving.** EN 18221 clause 4.2 archiving is the retention of
        historical versions of a passport that is still live, and it is served by
        `GET /dpp/{dppId}/versions`. This route ends a record's publication life.
      tags:
        - DPP Lifecycle
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: DPP retired. Returns the full passport record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/dpp/{dppId}/eol:
    post:
      operationId: declareDppEol
      summary: Declare a DPP end-of-life
      description: |
        Transition a `published` or `suspended` DPP to `deactivated`
        (terminal). The record is retained, never deleted — the passport
        outlives the product. Destruction (`reason.kind: destroyed`) is only
        lawful with a recognised derogation from the unsold-goods destruction
        ban (ESPR Art. 25 delegated act).
      tags:
        - DPP Lifecycle
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EolRequest'
      responses:
        '200':
          description: DPP deactivated. Returns the full passport record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
  /vault/api/v1/dpp/{dppId}/transfer/initiate:
    post:
      operationId: initiateDppTransfer
      summary: Initiate a transfer of responsibility
      description: |
        The outgoing operator signs a pending handover onto the passport's
        transfer chain. Only a `published` DPP can be transferred. In the
        managed single-node model the caller supplies both the outgoing and
        incoming operator; the node signs on the outgoing operator's behalf.
      tags:
        - DPP Lifecycle
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransferInitiateRequest'
      responses:
        '200':
          description: Transfer initiated (pending acceptance).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransferRecord'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/dpp/{dppId}/transfer/accept:
    post:
      operationId: acceptDppTransfer
      summary: Accept a pending transfer of responsibility
      description: |
        The incoming operator's signature completes a pending handover: the
        outgoing operator's signature is verified before the node countersigns
        on the incoming operator's behalf, and the incoming operator becomes
        the passport's current responsible operator.
      tags:
        - DPP Lifecycle
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: Transfer completed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransferRecord'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            This DPP has no transfer chain at all — no handover has ever been
            initiated for it. A chain that exists but carries nothing pending is a
            `422`, not this.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '422':
          description: |
            There is no pending handover to accept, the pending record carries no
            outgoing signature, or that signature failed verification.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /vault/api/v1/dpp/{dppId}/transfer/reject:
    post:
      operationId: rejectDppTransfer
      summary: Reject a pending transfer of responsibility
      description: |
        End the pending handover as **refused**. The record becomes terminal and can
        never complete, and the passport's transfer chain is free to carry a new
        handover.

        Paired with cancel, this is one of the two ways out of a handover the
        counterparty never acted on. A chain refuses a new transfer while any
        record is still pending, so without these routes a passport whose
        counterparty went silent could never be transferred again.

        **This records an outcome, not who caused it.** A node serves one operator,
        and the only credentials it accepts are that operator's — the incoming
        operator holds no key here and cannot call this route. So reject and cancel
        are both initiated by *this* node's operator; they differ in the outcome
        written to the record and in the states core permits them from, not in who
        made the call. Choose reject to record that the counterparty declined.
      tags:
        - DPP Lifecycle
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: Transfer rejected.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransferRecord'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            This DPP has no transfer chain at all — no handover has ever been
            initiated for it. A chain that exists but carries nothing pending is a
            `422`, not this.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '422':
          description: |
            There is no pending handover to reject, or the record's state does not
            permit it. Core allows a reject only from `Initiated`.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /vault/api/v1/dpp/{dppId}/transfer/cancel:
    post:
      operationId: cancelDppTransfer
      summary: Cancel a pending transfer of responsibility
      description: |
        End the pending handover as **withdrawn**, before it completes. The record
        becomes terminal and the passport's transfer chain is free to carry a new
        handover.

        **This records an outcome, not who caused it** — see reject, which is the
        same call with a different outcome written to the record.

        Core permits a cancel from one state more than a reject: `Accepted` as well
        as `Initiated`. That is not reachable over this API today, because accepting
        completes the record in the same call, so no stored record is ever left in
        `Accepted`. Cancel is nonetheless the right call for a handover this
        operator is withdrawing, and the broader rule is core's, not a promise this
        route makes.
      tags:
        - DPP Lifecycle
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: Transfer cancelled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransferRecord'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            This DPP has no transfer chain at all — no handover has ever been
            initiated for it. A chain that exists but carries nothing pending is a
            `422`, not this.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '422':
          description: |
            There is no pending handover to cancel, or the record's state does not
            permit it. Core allows a cancel from `Initiated` or `Accepted`.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /vault/api/v1/dpp/{dppId}/evidence:
    post:
      operationId: generateDppEvidence
      summary: Generate and store a signed evidence dossier
      description: |
        Assembles a self-contained, signed dossier proving a passport's full
        proof chain — both JWS signatures, DID document snapshots, the
        hash-chained audit trail, and (when present) the transfer chain and
        end-of-life record — and persists it. See
        `docs/architecture/EVIDENCE-DOSSIER.md` for the complete format
        specification.

        Authenticated tier only — the dossier's `fullView` member carries
        full-view (non-redacted) passport data. Requires the passport to have
        been published at least once; a draft has no signature to export.
      tags:
        - Evidence Dossiers
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '201':
          description: The stored dossier record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EvidenceDossierRecord'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    get:
      operationId: listDppEvidence
      summary: List stored evidence dossiers for a passport
      description: |
        Every evidence dossier generated for this passport, newest first.

        Summaries only — each row carries the dossier's id, when it was generated
        and what it covers, but not the document itself. Dossiers embed full-view
        passport data and both signature chains, so they are large and are fetched
        one at a time through `GET /api/v1/evidence/{id}`.

        A passport can have several: a dossier is a snapshot of the proof chain at
        the moment it was generated, so one taken before a transfer and one taken
        after are both valid and describe different states. Nothing here is
        superseded by anything else here, which is why the list is not filtered
        down to a latest.

        Empty for a passport that has never been published — there is no signature
        to attest to before then.
      tags:
        - Evidence Dossiers
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: Stored dossier summaries.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/EvidenceDossierSummary'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/evidence/{id}:
    get:
      operationId: getEvidenceDossier
      summary: Fetch one stored dossier's document
      description: Returns the dossier document itself — the same shape `POST .../evidence` returns on generation, not the summary wrapper the list endpoint shows.
      tags:
        - Evidence Dossiers
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The evidence dossier document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EvidenceDossier'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/evidence/{id}/verify:
    post:
      operationId: verifyStoredEvidenceDossier
      summary: Verify a stored dossier
      description: |
        An integrity check against the stored dossier's own signatures and
        hash chains. A tamper is still a `200` response — the report's
        `checks` name which check failed.
      tags:
        - Evidence Dossiers
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The verification report.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationReport'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/evidence/verify:
    post:
      operationId: verifyUploadedEvidenceDossier
      summary: Verify an uploaded dossier document
      description: Same checks as the stored-dossier verify endpoint, run against an uploaded document instead.
      tags:
        - Evidence Dossiers
      security:
        - BearerApiKey: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EvidenceDossier'
      responses:
        '200':
          description: The verification report.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationReport'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          description: Not a valid dossier — malformed JSON or an unrecognised field.
  /vault/api/v1/dpp/{dppId}/registry:
    get:
      operationId: getDppRegistryStatus
      summary: EU-registry state for one passport
      description: |
        What the EU registry knows about this passport: its queued registration,
        any handover notifications, and who is responsible for it now.

        Registration is the legal obligation the rest of the system exists to
        discharge, and this is where an operator sees whether it has been —
        the state otherwise lives only in outbox tables, metrics and log lines.

        **Absent is not zero.** A deployment without the registry queues reports
        `configured: false` and omits the detail, rather than a row of zeros that
        would read as "everything is registered".
      tags:
        - Registry
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: Registry state for the passport.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportRegistryView'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/registry:
    get:
      operationId: getRegistryRollup
      summary: Operator-wide EU-registry rollup
      description: |
        Registration and handover queue totals for this operator, plus its
        verified-registry standing.

        Verification is reported whether or not the queues are configured: verified
        status lapses at the latest three years after verification (sooner if the
        electronic identification means expire), and a lapsed operator cannot
        register or amend anything until it verifies again.

        **Absent is not zero** — see the per-passport route.
      tags:
        - Registry
      security:
        - BearerApiKey: []
        - BasicAuth: []
      responses:
        '200':
          description: Registry rollup for the operator.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegistryRollupView'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /vault/api/v1/dpp/{dppId}/stats:
    get:
      operationId: getDppScanStats
      summary: Per-passport scan telemetry
      description: |
        Aggregate, privacy-safe resolution counts for one passport over a
        trailing window. Scans and QR-image renders are reported as separate
        fields and are never summed — a render is label production, not a
        resolution. Nothing about the scanner (IP, agent, session) is collected
        or returned; the counters carry no such fields. Returns zeros for a
        passport that has never been scanned.
      tags:
        - Scan Telemetry
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
        - name: days
          in: query
          required: false
          description: Trailing window in days (default 30, clamped to 1..=730).
          schema:
            type: integer
            minimum: 1
            maximum: 730
            default: 30
      responses:
        '200':
          description: Aggregate scan counts for the passport.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportScanStats'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /vault/api/v1/stats:
    get:
      operationId: getOperatorScanStats
      summary: Operator-wide scan telemetry rollup
      description: |
        Aggregate resolution counts across all of the operator's passports over
        a trailing window — the "your passports were resolved N times" figure.
        Scans and QR-image renders are separate; nothing about the scanner is
        collected.
      tags:
        - Scan Telemetry
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: days
          in: query
          required: false
          description: Trailing window in days (default 30, clamped to 1..=730).
          schema:
            type: integer
            minimum: 1
            maximum: 730
            default: 30
      responses:
        '200':
          description: Operator-wide aggregate scan counts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OperatorScanStats'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /vault/api/v1/operator:
    get:
      operationId: getOperatorConfig
      summary: Get operator configuration
      description: |
        Returns the calling operator's configuration (branding, legal info,
        retention settings). If no config has been saved yet, returns an
        empty default config rather than 404.
      tags:
        - Operator
      security:
        - BearerApiKey: []
        - BasicAuth: []
      responses:
        '200':
          description: Operator configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OperatorConfig'
        '401':
          $ref: '#/components/responses/Unauthorized'
    patch:
      operationId: updateOperatorConfig
      summary: Update operator configuration
      description: |
        Merge-patch the operator's configuration. Only supplied fields
        are changed.

        Requires an admin-scoped key. Reading the configuration back does not —
        `GET` is open to any authenticated caller, since a passport's issuer is
        public information the moment one is published.
      tags:
        - Operator
      security:
        - BearerApiKey: []
        - BasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateOperatorConfig'
      responses:
        '200':
          description: Updated operator configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OperatorConfig'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /vault/api/v1/facilities:
    get:
      operationId: listFacilities
      summary: List facilities
      description: |
        Lists the operator's facilities (ESPR Annex III). The `isDefault`
        facility is stamped onto new passports. Requires an admin-scoped key.
      tags:
        - Facilities
      security:
        - BearerApiKey: []
        - BasicAuth: []
      responses:
        '200':
          description: List of facilities.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Facility'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: addFacility
      summary: Add a facility
      description: |
        Add a facility. The identifier is validated by scheme — a `gln` must
        pass the GS1 mod-10 check digit. Requires an admin-scoped key.

        Facilities are retired, never deleted, so a duplicate created by a retried
        request cannot be cleaned up afterwards. Send an `Idempotency-Key`.
      tags:
        - Facilities
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateFacilityRequest'
      responses:
        '201':
          description: Facility created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Facility'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotentRequestInFlight'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/facilities/{id}:
    delete:
      operationId: removeFacility
      summary: Remove a facility
      description: 'Retires the facility (soft-delete): the row is kept as Annex III provenance for passports that already stamped its identifier — never hard-deleted. Requires an admin-scoped key.'
      tags:
        - Facilities
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Facility removed.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/facilities/{id}/default:
    post:
      operationId: setDefaultFacility
      summary: Set the default facility
      description: Makes this facility the sole default, stamped onto new passports. Requires an admin-scoped key.
      tags:
        - Facilities
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Default facility set.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/facilities/{id}/audit:
    get:
      operationId: getFacilityAudit
      summary: Facility audit trail
      description: |
        Append-only mutation history for one facility (added, retired,
        set-default), oldest first. Requires an admin-scoped key.
      tags:
        - Facilities
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: List of audit entries for this facility.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/RegistryIdentityAuditEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /vault/api/v1/operator-identifiers:
    get:
      operationId: listOperatorIdentifiers
      summary: List operator identifiers
      description: |
        Lists the operator's economic-operator identifiers (ESPR Art. 13). The
        `isPrimary` identifier is stamped onto new passports. Admin scope required.
      tags:
        - Operator Identifiers
      security:
        - BearerApiKey: []
        - BasicAuth: []
      responses:
        '200':
          description: List of operator identifiers.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/OperatorIdentifier'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: addOperatorIdentifier
      summary: Add an operator identifier
      description: |
        Add an economic-operator identifier. Validated by scheme — LEI uses
        ISO 7064 MOD 97-10; DUNS is 9 digits; EORI/VAT require a country prefix.
        Requires an admin-scoped key.

        Operator identifiers are retired, never deleted, so a duplicate created by
        a retried request cannot be cleaned up afterwards. Send an
        `Idempotency-Key`.
      tags:
        - Operator Identifiers
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOperatorIdentifierRequest'
      responses:
        '201':
          description: Operator identifier created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OperatorIdentifier'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotentRequestInFlight'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/operator-identifiers/{id}:
    delete:
      operationId: removeOperatorIdentifier
      summary: Remove an operator identifier
      description: 'Retires the identifier (soft-delete): the row is kept as Art. 13 provenance for passports that already stamped its value — never hard-deleted. Requires an admin-scoped key.'
      tags:
        - Operator Identifiers
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Operator identifier removed.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/operator-identifiers/{id}/primary:
    post:
      operationId: setPrimaryOperatorIdentifier
      summary: Set the primary operator identifier
      description: Makes this identifier the sole primary, stamped onto new passports. Requires an admin-scoped key.
      tags:
        - Operator Identifiers
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Primary operator identifier set.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/operator-identifiers/{id}/audit:
    get:
      operationId: getOperatorIdentifierAudit
      summary: Operator-identifier audit trail
      description: |
        Append-only mutation history for one operator identifier (added,
        retired, set-primary), oldest first. Requires an admin-scoped key.
      tags:
        - Operator Identifiers
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: List of audit entries for this operator identifier.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/RegistryIdentityAuditEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /vault/api/v1/api-keys:
    get:
      operationId: listApiKeys
      summary: List active API keys
      description: |
        Lists all active API keys for this deployment. Only the key prefix
        is returned — the full secret is shown once at creation time.

        Requires an admin-scoped key.
      tags:
        - API Keys
      security:
        - BearerApiKey: []
        - BasicAuth: []
      responses:
        '200':
          description: List of API keys.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ApiKey'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: createApiKey
      summary: Create a new API key
      description: |
        Generate a new API key. The response includes the full plain-text
        `secret` — this is the only time it will be shown. Store it securely.

        **A replayed request does not return the secret.** This route accepts an
        `Idempotency-Key`, but the secret is never stored, so a retry that finds a
        completed key answers with the key record and
        `"secretAlreadyDelivered": true` in place of `secret`. That is the honest
        answer: the credential exists and was handed to the first attempt. If that
        response was lost, revoke the key and create another.

        Requires an admin-scoped key — including the first one, which is minted
        with the operator's Basic credential.
      tags:
        - API Keys
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateApiKeyRequest'
      responses:
        '201':
          description: API key created. The `secret` field is shown ONCE.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedApiKeyResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotentRequestInFlight'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/api-keys/{id}:
    delete:
      operationId: revokeApiKey
      summary: Revoke an API key
      description: |
        Soft-revokes a key. The record remains for audit but `isActive`
        is set to false. Subsequent auth attempts with this key will fail.

        **A key cannot revoke itself.** Revoking the credential this very request
        authenticated with would lock the caller — and everything sharing that key
        — out of every authenticated route, so it is refused with `409`.
        Authenticate with a different key, or with the operator's Basic credential,
        which carries no key id and is therefore the lockout-recovery path.

        Requires an admin-scoped key.
      tags:
        - API Keys
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Key revoked.
        '400':
          description: Invalid key ID format.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            The key named in the path is the one this request authenticated with.
            Revoking it would lock the caller out of every authenticated route, so
            it is refused rather than performed.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
              example:
                type: https://problems.odal-node.io/conflict
                title: Conflict
                status: 409
                detail: Cannot revoke the API key you are currently authenticating with. Authenticate with a different key (or admin credentials), then revoke this one.
  /vault/api/v1/credentials:
    post:
      operationId: issueCredential
      summary: Issue an access credential
      description: |
        Mints a DPP access credential signed with this node's own key, and returns
        it as a compact VC-JWT the holder presents in the `X-DPP-Credential` header
        on `GET /vault/credential/dpp/{dppId}`.

        **Only a legitimate interest, never an authority.** An operator naming its
        own authorised repairer attests something no one else can — membership of
        that network is a fact the operator alone holds, and no EU register of
        authorised repairers exists to hold it instead. An operator naming itself a
        market surveillance authority attests nothing, because the standing being
        claimed is conferred by a member state. The three authority roles are
        refused with `422`.

        **Issuing is not trusting.** A credential minted here is honoured by *this*
        node only if the node also trusts its own DID (`CREDENTIAL_ISSUERS_SELF`).
        An operator running more than one node ordinarily mints on one and honours
        it on another, so issuance is available regardless of that switch.

        **Revocation is expiry.** This node fetches W3C status lists but publishes
        none, so a credential it mints carries no `credentialStatus` and cannot be
        withdrawn before it lapses. `validForDays` is therefore capped rather than
        open-ended, and defaults to less than the cap.

        **Retry with an `Idempotency-Key`.** Issuance is the sharpest case in the
        keyed set: the node stores no record of what it signed and publishes no
        status list, so a duplicate cannot be found afterwards and could not be
        withdrawn if it were. A retry under the same key replays the original
        response — including `credentialJws`, which is what stops the retry from
        minting a *second* live credential.

        Requires an admin-scoped key.
      tags:
        - Access
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IssueCredentialRequest'
      responses:
        '201':
          description: Credential issued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IssuedCredential'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotentRequestInFlight'
        '422':
          description: |
            The request cannot produce a usable credential — an authority role, a
            `holderDid` that is not a DID, a `country` that is not alpha-2, or a
            `validForDays` outside 1–90.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '501':
          description: |
            This deployment reaches no key store, so it cannot sign. The standalone
            vault binary answers this; the fused node does not.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /vault/api/v1/unsold-goods:
    get:
      operationId: listUnsoldGoods
      summary: List unsold-goods disclosure lines
      description: |
        Every recorded ESPR Art. 24 disclosure line, newest first, optionally
        narrowed to one financial year.

        Admin rather than write: these are the operator's own annual figures, and
        reading them back is an administrative act rather than part of producing
        passports.
      tags:
        - Unsold goods
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: reportingPeriod
          in: query
          required: false
          description: Financial year as `YYYY`. Omit for every period.
          schema:
            type: string
            pattern: ^[0-9]{4}$
      responses:
        '200':
          description: The disclosure lines.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/UnsoldGoodsEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: '`reportingPeriod` is not a four-digit year.'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
    post:
      operationId: recordUnsoldGoods
      summary: Record an unsold-goods disclosure line
      description: |
        Records one line of the ESPR Art. 24 disclosure of unsold consumer products
        discarded in a financial year.

        **This is not a passport.** There is no digital product passport anywhere in
        Art. 24 or Art. 25: the subject is an *operator over a financial year*, the
        medium is the operator's own website, and the trigger is discarding unsold
        stock — none of which is a product placed on the market. These routes
        therefore sit beside the other operator-scoped ones, and no `unsold-goods`
        product group carries passports.

        **Destruction must be justified.** Art. 25 prohibits destroying unsold
        consumer products listed in Annex VII from 19 July 2026, so
        `destination: exemptDestruction` requires `destructionJustification` and is
        refused with `422` without it. The converse is refused too: a justification
        on any other destination describes nothing.

        **Annex VII scope is not decided here.** That is a CN-code prefix test, and
        the goods in a disclosure line do not arrive carrying CN codes.
        `productCategory` is the operator's own categorisation for Art. 24(1)(a).

        **Retry with an `Idempotency-Key`.** This route serves no `DELETE`, so a
        duplicate line is permanent — and Art. 24 is disclosed publicly for a
        financial year, so a discard counted twice overstates what the operator
        actually did.

        Requires a write-scoped key; reading the lines back requires admin.
      tags:
        - Unsold goods
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUnsoldGoodsEntry'
      responses:
        '201':
          description: The recorded line.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UnsoldGoodsEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotentRequestInFlight'
        '422':
          description: |
            The line cannot be recorded — a `reportingPeriod` that is not a
            four-digit year, a negative count or weight, a `countryOfDisposal` that
            is not alpha-2, a destruction with no justification, or a justification
            on something that was not destroyed.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /vault/api/v1/webhooks:
    get:
      operationId: listWebhooks
      summary: List webhook subscriptions
      description: |
        Lists webhook subscriptions (active and retired). The signing secret is
        never returned here — only in the create response. Admin-scoped.
      tags:
        - Webhooks
      security:
        - BearerApiKey: []
        - BasicAuth: []
      responses:
        '200':
          description: Subscriptions (secret redacted).
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/WebhookSubscription'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: createWebhook
      summary: Create a webhook subscription
      description: |
        Register a receiver URL (must be https and resolve to a public host,
        unless the node sets WEBHOOK_ALLOW_PRIVATE_TARGETS). The response includes
        the `secret` — shown ONCE — used to verify the `X-Odal-Signature` header
        on every delivery. Admin-scoped.

        **A replayed request does not return the secret.** This route accepts an
        `Idempotency-Key`, but the signing secret is never stored, so a retry that
        finds a completed key answers with the subscription and
        `"secretAlreadyDelivered": true` in place of `secret`. If the first
        response was lost, delete the subscription and create another.
      tags:
        - Webhooks
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
      responses:
        '201':
          description: Subscription created. The `secret` field is shown ONCE.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedWebhookResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotentRequestInFlight'
        '422':
          $ref: '#/components/responses/ValidationError'
  /vault/api/v1/webhooks/{id}:
    delete:
      operationId: removeWebhook
      summary: Remove a webhook subscription
      description: |
        Soft-removes a subscription (`active` set to false). Deliveries already
        queued still drain; no new events are enqueued. Admin-scoped.
      tags:
        - Webhooks
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Subscription removed.
        '400':
          description: Invalid subscription id.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/webhooks/{id}/test:
    post:
      operationId: testWebhook
      summary: Send a test delivery
      description: |
        Enqueues a synthetic `dpp.webhook.test` delivery to the subscription so a
        receiver can be proven end-to-end. Admin-scoped.
      tags:
        - Webhooks
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '202':
          description: Test delivery enqueued.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /vault/api/v1/plugins:
    post:
      operationId: installPlugin
      summary: Install a signed product group plugin
      description: |
        Verify, persist, and hot-swap a signed product group plugin — no node restart.

        The node verifies the uploaded artifact's detached signature against its
        pinned publisher key, gates the plugin's declared ABI, instantiate-smokes
        the module, persists it (so a restart re-loads it), and atomically swaps
        it into service. Any rejection is fail-closed — the previously installed
        plugin keeps serving. Admin-scoped.

        Both a portable `.wasm` (compiled on the node) and a precompiled `.cwasm`
        (loaded only if it matches this node's engine) are accepted.
      tags:
        - Plugins
      security:
        - BearerApiKey: []
        - BasicAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - wasm
                - sig
              properties:
                wasm:
                  type: string
                  format: binary
                  description: |
                    The `.wasm` or precompiled `.cwasm` plugin artifact. Its filename determines the product group when `productGroup` is omitted (`product-group-<key>.wasm`) and whether it is treated as precompiled (`.cwasm`).
                sig:
                  type: string
                  format: binary
                  description: Detached Ed25519 signature over SHA-256 of the artifact bytes.
                productGroup:
                  type: string
                  description: Product group key; derived from the filename if omitted.
                  example: battery
      responses:
        '201':
          description: Plugin verified, persisted, and now serving.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstalledPlugin'
        '400':
          description: Malformed multipart body (missing `wasm`/`sig`, or the product group could not be determined).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: A non-admin credential attempted to install a plugin.
        '409':
          $ref: '#/components/responses/IdempotentRequestInFlight'
        '422':
          description: The artifact was rejected — bad signature, incompatible ABI, or a non-instantiable/incompatible module.
        '501':
          description: This node has no plugin host configured; runtime install is unavailable.
  /vault/api/v1/ruleset/reload:
    post:
      operationId: reloadRuleset
      summary: Re-read the signed ruleset channel and hot-swap a verified bundle
      description: |
        Adopt a newly published compliance ruleset — no node restart.

        The node re-reads its configured channel, verifies the bundle's manifest
        signature against the pinned publisher key, checks the content hash, refuses
        a bundle whose rules do not take effect yet or that is older than the one
        already in force, and only then swaps it in atomically. Requests in flight
        keep serving throughout. Any rejection is fail-closed — the ruleset already
        in force keeps validating. Admin-scoped.

        The node also polls the channel on its own (`RULESET_POLL_INTERVAL_SECS`),
        so this route is how an operator says "take it now", not the only way a swap
        happens.

        Re-reading a channel that has not changed is a **success** with
        `changed: false`, not an error.
      tags:
        - Ruleset
      security:
        - BearerApiKey: []
        - BasicAuth: []
      responses:
        '200':
          description: The channel was read. `changed` says whether the ruleset in force moved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RulesetReload'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: A non-admin credential attempted to reload the ruleset.
        '422':
          description: 'The bundle was refused and the ruleset in force is unchanged. The problem `code` says which refusal: `RULESET_REJECTED` (bad signature, content-hash mismatch, or a malformed manifest — distrust the bytes), `RULESET_NOT_YET_EFFECTIVE` (authentic but its rules start later — leave it staged and re-offer it once the date arrives), or `RULESET_SUPERSEDED` (authentic but older than what is running — the rollback refusal; something served a stale bundle).'
        '501':
          description: This node has no signed ruleset channel configured; it is running its compiled-in baseline and there is nothing to re-read.
        '503':
          description: The channel could not be read at all — the bundle file is missing or unreadable, or what arrived was not a bundle. Transport-level; retry once the drop is fixed.
  /vault/api/v1/node/state:
    get:
      operationId: getNodeState
      summary: Node setup state
      description: |
        Reports whether the node has been claimed (at least one active API key)
        and whether the operator identity is complete enough to publish. Used by
        `odal bootstrap` to stay idempotent.
      tags:
        - Node
      security:
        - BearerApiKey: []
        - BasicAuth: []
      responses:
        '200':
          description: Node setup state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NodeState'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /vault/api/v1/whoami:
    get:
      operationId: whoami
      summary: Identity and scope of the presented credential
      description: |
        Echoes back what the caller's own credential is. A client cannot otherwise
        discover what it is allowed to do — a `read` key learns it is read-only by
        having a write rejected, which cannot be checked ahead of time.

        Reports only what the caller already presented; it reveals nothing about
        any other key. `keyId` is the key's row id, never the token, and is absent
        for local Basic auth, which has no key row.

        Available to every scope, including `read`.
      tags:
        - Node
      security:
        - BearerApiKey: []
        - BasicAuth: []
      responses:
        '200':
          description: The caller's identity and scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WhoamiResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /vault/public/dpp/{dppId}:
    get:
      operationId: publicGetDpp
      summary: Public read of a published DPP
      description: |
        Unauthenticated read of a published passport. Used by the resolver
        service internally. Returns 404 for drafts, 410 for suspended.

        A passport that has been superseded serves the record that replaced it,
        so the body's `id` is not always `dppId` — see the 200 below.
      tags:
        - Public (Vault)
      security: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
        - name: schema_view
          in: query
          required: false
          description: Request a read-time schema-upcast view. Set to a newer product group schema version (e.g. `2.0.0`); the response becomes `{ passport, schemaView }` — the canonical signed passport plus the derived view with lens provenance. The original is never re-signed.
          schema:
            type: string
          example: 2.0.0
      responses:
        '200':
          description: |-
            Published passport record. With `?schema_view`, the body is instead `{ passport, schemaView }`.

            🚨 **The body's `id` is not always the `dppId` that was asked for.** A passport in the `superseded` status serves the published record that replaced it. ESPR Art. 9(1) has the data carrier link to *the* passport for the product, and a carrier is printed on a physical thing and cannot be recalled — so this door has to keep landing on whichever record is current, which is what `/01/{gtin}` beside it has always done.

            What is served is the successor's own signed public view, and its `supersedesId` names the passport that was asked for: the pointer is part of a document the reader can verify rather than a claim beside one. The chain is followed rather than stepped once, so `A → B → C` serves `C` when `A` is scanned.

            A superseded passport whose successor is **not published yet** keeps serving its own frozen view, as does one retired or deactivated with no replacement at all. In that view `status` reads as it did at publish, because the body is the payload `publicJwsSignature` was computed over.

            When — and only when — the body is a different record from the one asked for, the response carries `Content-Location` naming where that record lives (RFC 9110 §8.7). Its **presence** is the signal: a client can tell "this is your record" from "this is the record that replaced it" without comparing `id` against what it sent. It is a relative reference, so it resolves correctly wherever this API is mounted.
          headers:
            Content-Location:
              description: Present only on a superseded read, naming the record actually served, relative to the request URI (e.g. `./0b6b…`). Absent on an ordinary read.
              required: false
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          description: Not found or not published.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '410':
          description: Passport has been suspended.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '422':
          description: The requested `schema_view` is unavailable (no lens path, a downcast, or the passport has no product group data).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /vault/public/dpp/by-gtin/{gtin}:
    get:
      operationId: publicGetDppByGtin
      summary: Public read of a published DPP by its printed GS1 label
      description: |
        Unauthenticated read of the published passport a GS1 label names — the
        same signed public view the by-id route serves, found by the label printed
        on the product instead of by passport id. The resolver's
        `/01/{gtin}[/10/{batch}][/21/{serial}]` routes forward the label here.

        **The label's qualifiers select the passport.** One GTIN can have a
        passport for the model, one per batch and one per unit, so the GTIN alone
        does not name one:

        - `serial` (AI 21) names the passport whose carrier serial it is, at any
          level. `batch` is not consulted once a serial is given.
        - `batch` (AI 10) alone names the batch-level passport for that lot.
        - Neither names the model-level passport, or a passport with no batch and
          no serial.

        A label keeps working after its passport is amended: the successor is
        served, not the superseded record.

        **A withdrawn passport answers `410`, not `404`.** That distinction is the
        point of this route rather than a detail of it: `404` means no passport for
        this label was ever published here, while `410` means one was and has since
        been suspended. Only the second is a recall signal, and a consumer who
        scanned a product needs to be able to tell them apart. `422` is a
        structurally invalid GTIN-14 — a bad check digit or wrong length — which is
        a malformed request rather than an answer about any product.

        Only the passport's `public` audience view is returned, carrying the
        publish-time `publicJwsSignature` that covers exactly those bytes.
        Restricted fields are not present at all.
      tags:
        - Public (Vault)
      security: []
      parameters:
        - name: gtin
          in: path
          required: true
          schema:
            type: string
            description: GS1 GTIN-14 (14 digits, mod-10 check digit).
            example: '09506000134352'
        - name: batch
          in: query
          required: false
          description: The label's AI 10 value, the batch or lot.
          schema:
            type: string
          example: LOT-2026-A
        - name: serial
          in: query
          required: false
          description: The label's AI 21 value, the serial.
          schema:
            type: string
          example: 3a9c1e0b7d2f4a6c8e10
        - name: schema_view
          in: query
          required: false
          description: Request a read-time schema-upcast view (see the by-id read). The response becomes `{ passport, schemaView }`.
          schema:
            type: string
          example: 2.0.0
      responses:
        '200':
          description: Published passport record. With `?schema_view`, the body is instead `{ passport, schemaView }`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          $ref: '#/components/responses/Gone'
        '422':
          description: The GTIN is not a valid GTIN-14, or the requested `schema_view` is unavailable.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /vault/credential/dpp/{dppId}:
    get:
      operationId: readDppByCredential
      summary: Audience-scoped read of a published DPP
      description: |
        Reads a published passport filtered to the caller's audience. No
        `X-DPP-Credential` header returns the same signed public view as
        `/public/dpp/{dppId}`. A verified credential returns the passport
        filtered to that audience's disclosure classes (ESPR Art. 77(2)),
        carrying the proof computed over that view. Credentialed reads are
        recorded to the passport's audit trail; anonymous reads are not.

        Returns the public view (not an error) when credential verification
        is not configured on this node.
      tags:
        - Credentialed Access
      security: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
        - name: X-DPP-Credential
          in: header
          required: false
          description: A verifiable access credential. Absent means public access.
          schema:
            type: string
      responses:
        '200':
          description: Passport filtered to the resolved audience.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: The presented credential failed verification.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '404':
          description: Not found or not published.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '410':
          description: This passport has been suspended.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /vault/internal/scan-batch:
    post:
      operationId: ingestScanBatch
      summary: Flush a scan-telemetry batch (internal, mTLS)
      description: |
        The mTLS-gated sink the public resolver flushes its in-memory
        aggregate scan/QR-render counters to (`CN=odal-resolver` only). The
        resolver holds no operator API key and no database of its own.

        **Send an `Idempotency-Key`.** The ingest is additive
        (`count = count + delta`), so a window re-sent after a lost acknowledgement
        is counted twice. The resolver holds a failed batch and re-sends it
        byte-for-byte under a stable key; without one, a read timeout on a request
        the node already committed silently inflates an operator's resolution
        counts.
      tags:
        - Vault (internal)
      security:
        - MutualTLS: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScanBatch'
      responses:
        '204':
          description: Batch ingested.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotentRequestInFlight'
  /integrator/api/v1/product-groups:
    get:
      operationId: listProductGroups
      summary: List product groups and their passport obligations
      description: |
        Whether a digital product passport is required for each product group this
        node knows of, from what date, under which acts, and whether this build can
        actually make a binding determination. Unauthenticated.

        "Knows of" is wider than "has a schema for". A product group reached by an
        act while carrying no catalog descriptor is listed too, with a `null`
        `title` — that group has no schema, plugin or template to be discovered from,
        so this is the only place it can be asked about.

        **Every date is served with its `basis`.** Most of the catalog is undated,
        and of the dates that exist some trace to an adopted text (`sourced`) and
        some are a reading (`assumed`). A date without its basis would present a
        qualified reading as an unqualified claim, so `basis` is always present
        wherever a date or a retention period is.

        `required` and `determinable` are different questions and are reported
        separately. An obligation can exist while the implementing acts that define
        the technical requirements do not, in which case nothing is bindingly
        determinable yet however clearly the duty is written.

        Schema versions are not restated here — see `/integrator/api/v1/schemas`,
        which is their one home.
      tags:
        - Integrator
      security: []
      responses:
        '200':
          description: Every product group this build knows of.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductGroupObligationList'
  /integrator/api/v1/product-groups/{productGroup}:
    get:
      operationId: getProductGroupObligation
      summary: One product group's passport obligation
      description: |
        The passport obligation for a single product group. Unauthenticated.

        A key neither catalog knows is a `404` rather than an entry with an empty
        obligation — "this node knows nothing about it" and "no passport is
        required" are different answers, and the second would be a compliance claim
        the node is not entitled to make.

        A key an act reaches while no product group descriptor exists for it is
        **not** a `404`. The node holds a recorded binding for such a group, and
        refusing it would deny knowledge the node has. It is served with a `null`
        `title` and its obligation in full.
      tags:
        - Integrator
      security: []
      parameters:
        - name: productGroup
          in: path
          required: true
          description: The product group catalog key.
          schema:
            type: string
          example: toy
      responses:
        '200':
          description: The product group's obligation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductGroupObligation'
        '404':
          $ref: '#/components/responses/NotFound'
  /integrator/api/v1/schemas:
    get:
      operationId: listProductGroupSchemas
      summary: List product group schemas and their versions
      description: |
        Every product group with a JSON Schema, the version a new passport is validated
        against (`current`), and every version a stored passport may legitimately
        record (`versions`). Unauthenticated.
      tags:
        - Integrator
      security: []
      responses:
        '200':
          description: The available product group schemas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  schemas:
                    type: array
                    items:
                      type: object
                      properties:
                        productGroup:
                          type: string
                          example: battery
                        current:
                          type:
                            - string
                            - 'null'
                          example: 2.6.0
                        versions:
                          type: array
                          items:
                            type: string
                          example:
                            - 1.0.0
                            - 2.6.0
  /integrator/api/v1/schemas/{productGroup}:
    get:
      operationId: getCurrentProductGroupSchema
      summary: Fetch a product group's current JSON Schema
      description: |
        The schema a passport created today is validated against, resolved through
        the same registry the publish gate uses — never a copy, which would drift in
        the direction where a body passes here and fails at publish. Unauthenticated.

        Every `description` is omitted from the served document. Those fields make
        regulatory assertions that have not been verified against primary text, and
        serving them would turn developer-facing comments into a product surface.
        Everything that decides accept or reject — types, `enum`, `required`,
        `pattern`, bounds, `additionalProperties` — is served in full, so a client
        can pre-validate a body and get the verdict the create route would give.
      tags:
        - Integrator
      security: []
      parameters:
        - name: productGroup
          in: path
          required: true
          schema:
            type: string
            example: battery
      responses:
        '200':
          description: The product group's current JSON Schema.
          content:
            application/json:
              schema:
                type: object
        '404':
          description: No schema for this product group; the body names the known product groups.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /integrator/api/v1/schemas/{productGroup}/{version}:
    get:
      operationId: getPinnedProductGroupSchema
      summary: Fetch a pinned version of a product group's JSON Schema
      description: |
        A stored passport records the `schemaVersion` it was written under, so a
        client holding one needs that exact schema rather than whatever is current.
        The version may be given with or without a leading `v`. Unauthenticated.

        Descriptions are omitted, as on the current-schema route.
      tags:
        - Integrator
      security: []
      parameters:
        - name: productGroup
          in: path
          required: true
          schema:
            type: string
            example: battery
        - name: version
          in: path
          required: true
          schema:
            type: string
            example: 2.6.0
      responses:
        '200':
          description: The product group's JSON Schema at that version.
          content:
            application/json:
              schema:
                type: object
        '400':
          description: The version is not a semver string.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '404':
          description: No schema at that version; the body names what is available.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /integrator/api/v1/templates/{productGroup}:
    get:
      operationId: getImportTemplate
      summary: Download a CSV import template
      description: |
        Returns the canonical CSV import template for a product group. Most product
        groups are served under their own key; batteries are served **per category**
        as `battery-ev`, `battery-lmt` and `battery-industrial`.

        The served set is deliberately **not enumerated here.** It is derived from
        one table in the handler, and the `404` body lists it — so a template added
        there is named by the refusal without anyone remembering to edit this
        sentence. That table exists because this list previously had three homes
        that disagreed, and this description was one of them.

        **There is no bare `battery` template.** What a battery passport must carry
        is decided per category, and the state-of-health parameters are two disjoint
        sets under Annex VII Part A — an EV battery reports state of certified
        energy alone, while stationary and LMT batteries report a five-parameter
        list. One file cannot carry three obligations without offering columns some
        categories must not fill.

        **Three categories, not five.** Reg. (EU) 2023/1542 defines five battery
        categories, but Art. 77(1) gives a battery passport only to "each LMT
        battery, each industrial battery with a capacity greater than 2 kWh and each
        electric vehicle battery". Portable and SLI batteries bear no passport
        obligation, so there is no template for them. The industrial template is for
        batteries above the 2 kWh threshold.

        All three battery templates are **generated** from the same rules table the
        publish-time content gate reads, so a template cannot fall behind the
        obligation it exists to satisfy. Their rows import under the `battery`
        product group — the category is carried by the row's own `batteryType`
        column, not by a product group of its own.

        Structured data points use four flat-file conventions: repeating groups
        (`cathode_1_name`, `cathode_1_weightPct`, …), nested blocks
        (`dynamicPerformance_ratedCapacityAh`, …), a two-column range
        (`notInUseTemperatureMinC`/`MaxC`), and a semicolon-delimited list
        (`componentPartNumbers`).

        `?format=xlsx` returns 501 (download the CSV and open it in a spreadsheet
        app). Unauthenticated.
      tags:
        - Integrator
      security: []
      parameters:
        - name: productGroup
          in: path
          required: true
          description: |
            The **template key**, which since the battery split is no longer always
            a product group: `battery-ev`, `battery-lmt` and `battery-industrial`
            are keys served here, while `battery` is a product group served by
            nothing. Rows from all three import under the `battery` product group.

            The parameter keeps its name because renaming a path parameter renames
            the method argument in every generated client, and for the eight
            non-battery templates the two are still the same string.
          schema:
            type: string
            example: battery-ev
        - name: format
          in: query
          schema:
            type: string
            enum:
              - csv
              - xlsx
            default: csv
      responses:
        '200':
          description: CSV template (Content-Disposition attachment).
          content:
            text/csv:
              schema:
                type: string
        '404':
          description: No template for this product group.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '501':
          description: XLSX export not yet implemented.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
  /integrator/api/v1/import/{productGroup}:
    post:
      operationId: importFile
      summary: Bulk-import passports from a file
      description: |
        Upload a CSV or XLSX file to create draft passports for a product group
        (`battery`, `textile`, `steel`, `aluminium`, `tyre`). The caller's
        `Authorization: Bearer` token is validated and forwarded to the vault.

        A battery **template** key is accepted here too — `battery-ev`,
        `battery-lmt` and `battery-industrial` all import as `battery`, since the
        category is carried by the row's own `batteryType` column rather than being
        a product group of its own. An operator who downloads `battery-ev` can post
        it back to `battery-ev` without translating the name.

        - ≤ 100 valid rows → processed synchronously, `200` with results.
        - `> 100` valid rows → an async job is queued, `202` with a `jobId`.
        - `mode=dry_run` → validate only, `200` with the would-be results.

        Every import — dry-run or apply, sync or async — mints a job id and
        persists a row-addressed report retrievable via the job-status
        endpoint, even when this endpoint's own response is synchronous.
      tags:
        - Integrator
      security:
        - BearerApiKey: []
      parameters:
        - name: productGroup
          in: path
          required: true
          schema:
            type: string
            example: battery
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - file
              properties:
                file:
                  type: string
                  format: binary
                  description: CSV or XLSX file.
                mode:
                  type: string
                  description: '"dry_run" to validate without creating records; any other value (or omitted) means apply.'
      responses:
        '200':
          description: Synchronous import (or dry-run) results.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImportSyncResponse'
        '202':
          description: Async import job accepted — poll the job-status endpoint.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImportAsyncResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Unknown product group.
        '409':
          $ref: '#/components/responses/IdempotentRequestInFlight'
        '422':
          $ref: '#/components/responses/ValidationError'
  /integrator/api/v1/imports/{job_id}:
    get:
      operationId: getImportJobStatus
      summary: Poll an async import job
      description: |
        Returns the status and progress of an async import job. Requires the
        same bearer auth as the import endpoint (validated against the vault).
      tags:
        - Integrator
      security:
        - BearerApiKey: []
      parameters:
        - name: job_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Job status and progress.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobStatusResponse'
        '400':
          description: Invalid job ID format.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /identity/.well-known/did.json:
    get:
      operationId: getDidDocument
      summary: did:web DID document
      description: |
        Serves the operator's `did:web` DID document. Public, unauthenticated.
      tags:
        - Identity
      security: []
      responses:
        '200':
          description: The DID document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DidDocument'
  /internal/sign:
    servers:
      - url: http://localhost:8002
        description: odal-identity standalone (mTLS internal)
    post:
      operationId: internalSign
      summary: Sign a payload (internal, mTLS)
      description: |
        Signs a base64-encoded canonical-JSON payload with the operator's
        Ed25519 key (auto-provisioned on first use). Returns a compact JWS.
        Service-to-service only — gated by mTLS (`CN=odal-vault`).
      tags:
        - Identity (internal)
      security:
        - MutualTLS: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InternalSignRequest'
      responses:
        '200':
          description: Signed payload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InternalSignResponse'
        '400':
          description: Payload is not valid base64 or not valid JSON.
        '422':
          $ref: '#/components/responses/ValidationError'
  /internal/verify:
    servers:
      - url: http://localhost:8002
        description: odal-identity standalone (mTLS internal)
    post:
      operationId: internalVerify
      summary: Verify a JWS this service issued (internal, mTLS)
      description: |
        Checks a compact JWS against the named operator's key *and* confirms
        it was signed over the given payload — a validly-signed JWS for
        different content does not pass. Never errors on a signature that
        simply fails to verify; that is `{ "valid": false }`, not a fault.
        Service-to-service only — gated by mTLS (`CN=odal-vault`).
      tags:
        - Identity (internal)
      security:
        - MutualTLS: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InternalVerifyRequest'
      responses:
        '200':
          description: 'Verification result. Always `200` — an unverifiable signature is `{ "valid": false }`, not an error status.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InternalVerifyResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
  /internal/keys/rotate:
    servers:
      - url: http://localhost:8002
        description: odal-identity standalone (mTLS internal)
    post:
      operationId: internalRotateKey
      summary: Rotate an operator signing key (internal, mTLS)
      description: |
        Archives the current key (so prior signatures still verify), generates
        a new primary key, and rebuilds the DID document. Service-to-service
        only — gated by mTLS (`CN=odal-vault`).
      tags:
        - Identity (internal)
      security:
        - MutualTLS: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InternalRotateKeyRequest'
      responses:
        '200':
          description: Key rotated; returns the new fingerprint and DID document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InternalRotateKeyResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
  /dpp/{dppId}:
    get:
      operationId: resolveDpp
      summary: Resolve a public DPP
      description: |
        Resolve a published Digital Product Passport by ID. This endpoint
        is the target of QR code scans. No authentication required.

        Every representation is built from the **signed** public payload, not
        the live database row, so the body and the proof it carries agree by
        construction.

        Content negotiation via `Accept` header:
        - `application/json` / `application/ld+json` (default): JSON-LD
          passport data
        - `text/html`: the consumer-facing HTML passport page with operator
          branding (logo, colours)
        - `application/aas+json`: an IDTA Asset Administration Shell
          Environment (see below)

        An absent, empty, `*/*`, `application/*`, `application/json` or
        `application/ld+json` header all reach the JSON-LD default. Only a
        header naming something this route cannot produce gets `406`.

        Responses carry `Vary: Accept`.

        **Errors follow the door.** A request that reached the HTML view gets an
        HTML error page carrying the same status; every other view answers
        `application/problem+json`. So each error below has two representations,
        and which one arrives depends on the same `Accept` header that chose the
        success representation.

        **Serving is conditional on verification, so verification failures are
        statuses of this route.** The resolver holds no passport data — it fetches
        from the vault's public tier and verifies the public signature against the
        operator's DID before building any representation. A passport it cannot
        verify is never served unmarked: `409` when the signature does not check
        out, `503` when the DID document could not be reached to try.
      tags:
        - Public Resolver
      security: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: DPP resolved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PassportResponse'
            text/html:
              schema:
                type: string
                description: Consumer-facing HTML passport page.
            application/aas+json:
              schema:
                type: object
                description: |
                  An IDTA Asset Administration Shell `Environment` — shells
                  and submodels in one self-contained document.

                  `conceptDescriptions` is **absent**, not empty. This node
                  coins no concept descriptions, and the metamodel constrains
                  that member to `minItems: 1`, so an empty array would make
                  the whole document invalid.

                  **Public tier only.** The passport is filtered through the
                  disclosure seam before any AAS mapper sees it, so this door
                  never carries a field the JSON-LD door would withhold.
                  Restricted and conformity-tier data require a credentialed
                  channel and a different projection.

                  **Schema-valid, not conformance-certified.** Every
                  Environment is validated in `dpp-core`'s CI against IDTA's
                  published AAS JSON Schemas for metamodel **3.0, 3.1 and
                  3.2**, and must satisfy all three — no single revision is
                  the strictest, so the intersection is the only target that
                  means "loadable whichever revision your toolchain
                  implements".

                  That establishes metamodel validity only: it is not a claim
                  of IDTA conformance, and it asserts nothing about whether a
                  submodel matches a published submodel template. Note also
                  that no AAS JSON Schema sets `additionalProperties`, so
                  schema validity alone cannot rule out a member the metamodel
                  does not define; `dpp-core` gates that separately.

                  **Unsigned, and it says so in a header.** This is a derived
                  representation of the signed canonical public view, which is
                  what `application/ld+json` returns for this same URL. The
                  public proof covers that payload, not this serialisation of
                  it, so attaching the signature here would hand a verifier a
                  proof that fails against the bytes it arrived with.

                  Every `200` therefore carries:

                  ```
                  Link: <{resolverBase}/dpp/{dppId}>; rel="alternate"; type="application/ld+json"
                  ```

                  `alternate` rather than `canonical`: the two representations
                  share one URL and are separated only by `Accept`, so a
                  `canonical` relation would point this resource at itself.
                  Follow the link with that `Accept` to obtain the signed
                  payload and its proof.

                  `resolverBase` is per-deployment (`RESOLVER_BASE_URL`,
                  required, no default). Error responses carry no
                  `Link` — an error is not a representation of the passport.
        '404':
          description: |
            No published passport with this identifier, or the identifier is not a
            valid passport id — the resolver reports a malformed id as "not found"
            rather than as a bad request, since to a consumer holding a data
            carrier the two are the same answer.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
            text/html:
              schema:
                type: string
                description: Consumer-facing "not found" page.
        '406':
          $ref: '#/components/responses/NotAcceptable'
        '409':
          description: |
            The passport's public signature did not verify against the operator's
            DID, so it is not served. See the operation description: verification
            runs before any representation is built, and fails closed.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
            text/html:
              schema:
                type: string
                description: Consumer-facing error page.
        '410':
          description: |
            The passport exists and has been withdrawn from public view — it is
            suspended. Distinct from `404`: the identifier is confirmed real and
            service is being declined, which is the signal a consumer scanning a
            recalled product needs.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
            text/html:
              schema:
                type: string
                description: Consumer-facing withdrawal page.
        '500':
          description: |
            The HTML view could not be rendered. Only the HTML door produces this;
            the data doors have no rendering step to fail.
          content:
            text/html:
              schema:
                type: string
        '502':
          description: |
            The vault this resolver fronts could not be read. Deliberately not
            `404` — the identifier may be perfectly good, so a consumer should
            retry rather than conclude the product has no passport.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
            text/html:
              schema:
                type: string
                description: Consumer-facing error page.
        '503':
          description: |
            Verification could not be attempted: the operator's DID document was
            unreachable, unparseable, or carried no key matching the signature.
            Nothing has been established about the passport either way, which is
            why this is temporary where `409` is not.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
            text/html:
              schema:
                type: string
                description: Consumer-facing error page.
  /dpp/{dppId}/qr:
    get:
      operationId: getDppQrCode
      summary: Get QR code for a DPP
      description: |
        Returns a QR code image for the given DPP. The QR encodes the passport's
        GS1 Digital Link on the resolver's configured base
        (e.g. `https://id.odal-node.io/01/{gtin}/21/{serial}`).
        This endpoint is on the **resolver** service (port 8003).

        **Every response is `image/png`, including the failures.** This route is
        addressed by `<img src>`, so an error carries no problem document — the
        body is empty and the status line is the whole of the answer. A client that
        needs a readable reason should ask `GET /dpp/{dppId}`, which answers the
        same failures as `application/problem+json`.

        Fails closed on verification, like every other resolver route: the
        passport's public signature is checked against the operator's DID before a
        QR is drawn, so an unverifiable passport yields no image.
      tags:
        - Public Resolver
      security: []
      parameters:
        - name: dppId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/DppId'
      responses:
        '200':
          description: QR code image.
          content:
            image/png:
              schema:
                type: string
                format: binary
        '404':
          description: No published passport with this identifier. Empty body.
          content:
            image/png:
              schema:
                type: string
                format: binary
        '409':
          description: The passport's public signature did not verify against the operator's DID, so no QR was drawn. Empty body.
          content:
            image/png:
              schema:
                type: string
                format: binary
        '410':
          description: The passport has been withdrawn from public view — it is suspended. Empty body.
          content:
            image/png:
              schema:
                type: string
                format: binary
        '422':
          description: The passport carries no data carrier to encode — no GTIN, so no GS1 Digital Link exists to put in a QR code. Empty body.
          content:
            image/png:
              schema:
                type: string
                format: binary
        '500':
          description: QR encoding failed. Empty body.
          content:
            image/png:
              schema:
                type: string
                format: binary
        '502':
          description: The vault this resolver fronts could not be read. Empty body.
          content:
            image/png:
              schema:
                type: string
                format: binary
        '503':
          description: Verification could not be attempted — the operator's DID document was unreachable. Empty body.
          content:
            image/png:
              schema:
                type: string
                format: binary
  /01/{gtin}:
    get:
      operationId: resolveByGtin
      summary: GS1 Digital Link resolver
      description: |
        GS1-conformant resolver (GS1-CRSV1) keyed by GTIN-14 — the carrier this node
        prints for a model-level passport. Behaviour depends
        on the `linkType` query / `Accept` header:
        - default → `307` redirect to the HTML DPP page
        - `?linkType=gs1:pip` / `gs1:dpp` (and related) → `307` redirect to the DPP
        - `?linkType=linkset` or `Accept: application/linkset+json` → `200`
          RFC 9264 linkset
      tags:
        - Public Resolver
      security: []
      parameters:
        - name: gtin
          in: path
          required: true
          schema:
            type: string
            example: '09506000134352'
        - name: linkType
          in: query
          required: false
          schema:
            type: string
          description: e.g. linkset, gs1:pip, gs1:dpp
      responses:
        '200':
          description: RFC 9264 linkset (when a linkset is requested).
          content:
            application/linkset+json:
              schema:
                type: object
        '307':
          description: Redirect to the DPP page (`Location` header).
        '404':
          description: No published DPP for this GTIN, or unknown link type.
        '409':
          $ref: '#/components/responses/PassportSignatureUnverified'
        '410':
          $ref: '#/components/responses/Gone'
        '502':
          $ref: '#/components/responses/ResolverUpstreamFailure'
        '503':
          $ref: '#/components/responses/PassportVerificationUnavailable'
  /01/{gtin}/21/{serial}:
    get:
      operationId: resolveByGtinSerial
      summary: GS1 Digital Link resolver — GTIN + serial
      description: |
        The carrier this node prints for an item-level passport, and for one
        whose level is not stated. **The serial is the resolution key**: it is the
        passport's carrier serial, and it names one passport whatever its level.
        An amended passport's successor carries its predecessor's serial, so the
        label keeps resolving, to the current record. Same behaviour as
        `/01/{gtin}` otherwise (`linkType` / `Accept` negotiation).
      tags:
        - Public Resolver
      security: []
      parameters:
        - name: gtin
          in: path
          required: true
          schema:
            type: string
            example: '09506000134352'
        - name: serial
          in: path
          required: true
          schema:
            type: string
          description: AI 21, the carrier serial. Selects the passport.
        - name: linkType
          in: query
          required: false
          schema:
            type: string
          description: e.g. linkset, gs1:pip, gs1:dpp
      responses:
        '200':
          description: RFC 9264 linkset (when a linkset is requested).
          content:
            application/linkset+json:
              schema:
                type: object
        '307':
          description: Redirect to the DPP page (`Location` header).
        '404':
          description: No published DPP for this GTIN, or unknown link type.
        '409':
          $ref: '#/components/responses/PassportSignatureUnverified'
        '410':
          $ref: '#/components/responses/Gone'
        '502':
          $ref: '#/components/responses/ResolverUpstreamFailure'
        '503':
          $ref: '#/components/responses/PassportVerificationUnavailable'
  /01/{gtin}/10/{batch}:
    get:
      operationId: resolveByGtinBatch
      summary: GS1 Digital Link resolver — GTIN + batch/lot
      description: |
        The carrier this node prints for a batch-level passport. **The batch is a
        resolution key**: it resolves to the batch-level passport for that lot,
        which the GTIN alone cannot name once a product has a passport per batch.
        Same behaviour as `/01/{gtin}` otherwise (`linkType` / `Accept`
        negotiation).
      tags:
        - Public Resolver
      security: []
      parameters:
        - name: gtin
          in: path
          required: true
          schema:
            type: string
            example: '09506000134352'
        - name: batch
          in: path
          required: true
          schema:
            type: string
          description: AI 10, the batch or lot. Selects the batch-level passport.
        - name: linkType
          in: query
          required: false
          schema:
            type: string
          description: e.g. linkset, gs1:pip, gs1:dpp
      responses:
        '200':
          description: RFC 9264 linkset (when a linkset is requested).
          content:
            application/linkset+json:
              schema:
                type: object
        '307':
          description: Redirect to the DPP page (`Location` header).
        '404':
          description: No published DPP for this GTIN, or unknown link type.
        '409':
          $ref: '#/components/responses/PassportSignatureUnverified'
        '410':
          $ref: '#/components/responses/Gone'
        '502':
          $ref: '#/components/responses/ResolverUpstreamFailure'
        '503':
          $ref: '#/components/responses/PassportVerificationUnavailable'
  /01/{gtin}/10/{batch}/21/{serial}:
    get:
      operationId: resolveByGtinBatchSerial
      summary: GS1 Digital Link resolver — GTIN + batch/lot + serial
      description: |
        The shape this node printed for a batched passport before its carrier
        followed the passport's stated level, and one any conformant label may
        carry. **The serial is the resolution key**: it names one passport
        whatever its level, so the batch is not consulted. Same behaviour as
        `/01/{gtin}` otherwise (`linkType` / `Accept` negotiation).
      tags:
        - Public Resolver
      security: []
      parameters:
        - name: gtin
          in: path
          required: true
          schema:
            type: string
            example: '09506000134352'
        - name: batch
          in: path
          required: true
          schema:
            type: string
          description: AI 10. Not consulted once a serial is present.
        - name: serial
          in: path
          required: true
          schema:
            type: string
          description: AI 21, the carrier serial. Selects the passport.
        - name: linkType
          in: query
          required: false
          schema:
            type: string
          description: e.g. linkset, gs1:pip, gs1:dpp
      responses:
        '200':
          description: RFC 9264 linkset (when a linkset is requested).
          content:
            application/linkset+json:
              schema:
                type: object
        '307':
          description: Redirect to the DPP page (`Location` header).
        '404':
          description: No published DPP for this GTIN, or unknown link type.
        '409':
          $ref: '#/components/responses/PassportSignatureUnverified'
        '410':
          $ref: '#/components/responses/Gone'
        '502':
          $ref: '#/components/responses/ResolverUpstreamFailure'
        '503':
          $ref: '#/components/responses/PassportVerificationUnavailable'
  /vault/health:
    get:
      operationId: vaultHealth
      summary: Vault health check
      description: |
        Liveness: the vault process is running and serving. On the node (port 8001)
        this is mounted at `/vault/health`.

        It checks nothing beyond itself — no database, no object storage, no
        upstream. A `200` here means the process is up, not that it can do useful
        work; use `/vault/ready` for that. Wiring a restart policy to this endpoint
        and expecting it to catch a lost database will never restart anything.

        The body carries the service name, its version, and the `dpp-core` version
        it was built against, which is the quickest way to tell two deployments
        apart when their behaviour differs.
      tags:
        - Health
      security: []
      responses:
        '200':
          description: |
            The process is serving. Body carries `status`, `service`, `version` and
            `coreVersion`.
  /vault/ready:
    get:
      operationId: vaultReady
      summary: Vault readiness check
      description: |
        Readiness: the vault can reach the database it needs to answer requests. On
        the node (port 8001) this is mounted at `/vault/ready`.

        **This is the only readiness probe in the API that checks a dependency.**
        It pings PostgreSQL and answers `503` when that fails, so it is the one to
        put behind a load balancer or a deployment gate. The identity and resolver
        readiness probes answer `200` unconditionally, because neither has an
        external dependency to check.

        Every call records the ping latency and its outcome as metrics, so a
        scrape of this endpoint doubles as a continuous database-latency signal
        rather than only a yes/no.
      tags:
        - Health
      security: []
      responses:
        '200':
          description: The database answered; the vault can serve requests.
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /vault/api/v1/info:
    get:
      operationId: vaultInfo
      summary: Service info
      description: |
        What this build is and what it supports — for a client deciding which
        features to offer before it has authenticated.

        Carries four things: the vault's own `version`, the `dpp-core` version it
        was compiled against, the authentication methods it accepts, and the
        feature flags it reports. A dashboard reads this to know whether an
        endpoint is worth calling, rather than calling it and interpreting a `404`.

        Unauthenticated on purpose: a client needs to know how to authenticate
        before it can, and the values here are properties of the build rather than
        of the operator or their data.

        `coreVersion` is the useful one when two deployments disagree — the vault
        version alone does not tell you which regulatory rules were compiled in.
      tags:
        - Health
      security: []
      responses:
        '200':
          description: Build metadata, authentication methods and feature flags.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultInfo'
  /identity/health:
    get:
      operationId: identityHealth
      summary: Identity health check
      description: |
        Liveness: the identity service is running and serving. Mounted at
        `/identity/health` on the node (port 8001), and at `/health` when identity
        runs as its own deployable (port 8002).

        Checks nothing beyond itself. The signing key store is opened at startup, so
        a process that is serving has already loaded it — which is why the readiness
        probe beside this one has nothing further to verify.

        The body carries the service name and version.
      tags:
        - Health
      security: []
      responses:
        '200':
          description: The process is serving. Body carries `status`, `service` and `version`.
  /identity/ready:
    get:
      operationId: identityReady
      summary: Identity readiness check
      description: |
        Readiness: **always `200`**, and that is the honest answer rather than a
        stub.

        The identity service has no external dependency to check. Its signing key
        store is opened at startup, so a process that is serving at all has already
        loaded everything it needs — there is no state in which it is live but not
        ready. Reporting anything conditional here would invent a distinction the
        service does not have.

        So this is useful as a probe target that answers, not as a signal that
        anything was verified. `/vault/ready` is the probe that actually checks a
        dependency.
      tags:
        - Health
      security: []
      responses:
        '200':
          description: Serving. Nothing is checked, because there is nothing to check.
  /integrator/health:
    get:
      operationId: integratorHealth
      summary: Integrator health check
      description: |
        Liveness: the bulk-import service is running and serving. On the node
        (port 8001) this is mounted at `/integrator/health`.

        Checks nothing beyond itself, and in particular says nothing about whether
        an import currently in flight is progressing — an import is a job, and
        `GET /integrator/api/v1/imports/{job_id}` is what reports on one.

        The body carries the service name and version.
      tags:
        - Health
      security: []
      responses:
        '200':
          description: The process is serving. Body carries `status`, `service` and `version`.
  /health:
    get:
      operationId: resolverHealth
      summary: Resolver health check
      description: |
        Liveness: the public resolver is running and serving. This endpoint is on
        the **resolver** service (port 8003), which is a separate deployable from
        the node.

        Checks nothing beyond itself — not Redis, not the vault it reads passports
        from. A `200` means the process answers; it does not mean a scan will
        resolve.

        The node has its own `/health` on port 8001, and it is deliberately bare:
        it returns `{"status":"ok"}` and nothing else. The node's profile, per-port
        trust modes and ruleset version are **not** there — they are on the
        authenticated `GET /vault/api/v1/node/state`, because an unauthenticated
        endpoint that publishes a node's trust posture tells an unauthenticated
        reader more than it should. An external uptime probe pointed at either
        `/health` can assert liveness and nothing more.
      tags:
        - Health
      security: []
      responses:
        '200':
          description: The process is serving. Body carries `status`, `service` and `version`.
  /ready:
    get:
      operationId: resolverReady
      summary: Resolver readiness check
      description: |
        Readiness: **always `200`**, on the **resolver** service (port 8003).

        Like the identity service's readiness probe, this checks nothing, and that
        is the honest answer rather than a stub. The resolver's dependencies — the
        vault it reads passports from, and its Redis cache — are consulted per
        request and degrade per request: a cache miss falls through to the vault,
        and a vault it cannot reach produces a `502` on that read rather than a
        process that should be taken out of rotation.

        So there is no state in which the resolver is live but categorically not
        ready, and reporting one would invent a distinction it does not have.
        `/vault/ready` is the probe in this API that actually verifies a dependency.
      tags:
        - Health
      security: []
      responses:
        '200':
          description: Serving. Nothing is checked, because there is nothing to check.
components:
  securitySchemes:
    BearerApiKey:
      type: http
      scheme: bearer
      description: |
        Machine-to-machine access via API keys. Keys are prefixed with
        `odal_sk_` and sent as `Authorization: Bearer odal_sk_...`.

        Keys are stored as SHA-256 hashes in PostgreSQL with a 12-character
        prefix index for fast lookup. The plain-text key is only returned
        once at creation time via `POST /vault/api/v1/api-keys`.
    BasicAuth:
      type: http
      scheme: basic
      description: |
        The operator's own bootstrap credential, from the `ADMIN_USERNAME` and
        `ADMIN_PASSWORD` environment variables. It mints the first API key on a
        fresh node — before any key exists — and is the lockout-recovery path
        afterwards, since it carries no `keyId` and so can revoke any key.

        Active in every environment where both variables are set; there is no
        production gate. Leave them unset once an API key exists if you do not
        want the path available.

        Reached only via the `Basic` scheme. A `Bearer` token is never matched
        against it, even one carrying the same `base64(user:pass)` payload.
    MutualTLS:
      type: mutualTLS
      description: |
        Client-certificate (mTLS) authentication for service-to-service calls.
        The standalone identity service accepts the internal `/internal/*`
        endpoints only from a client presenting `CN=odal-vault`. The fused
        `dpp-node` never exposes these over the network — it signs in-process.
  schemas:
    Problem:
      type: object
      description: |
        RFC 7807 / RFC 9457 problem details. The shape
        `dpp-common::http_problem::Problem` produces, served as
        `application/problem+json`.

        `type` is derived from `title`, so each distinct `title` used across the
        codebase is a stable catalogue key that clients may depend on.
      required:
        - type
        - title
        - status
      properties:
        type:
          type: string
          format: uri
          description: Absolute URI identifying the problem type.
          example: https://problems.odal-node.io/not-found
        title:
          type: string
          description: Short human-readable summary of the problem type.
          example: Not Found
        status:
          type: integer
          description: The HTTP status code, mirroring the status line.
          example: 404
        detail:
          type: string
          description: Human-readable explanation for this specific occurrence.
          example: 'No schema for product group ''nosuchgroup''. Known product groups: aluminium, battery.'
        instance:
          type: string
          format: uri-reference
          description: URI reference identifying this specific occurrence.
        errors:
          type: array
          description: |
            The individual field failures behind this problem, when there are any.

            An RFC 7807 §3.2 extension member. `detail` carries the same failures
            joined with `"; "` and is unaffected, so a client written before this
            member existed keeps working; `errors` is what lets a newer one address a
            single field. Publishing a battery that is missing its Annex XIII content
            produces upwards of thirty entries here.

            Omitted entirely — never `[]` — on problems that are not about fields.
          items:
            $ref: '#/components/schemas/ProblemFieldError'
    ProblemFieldError:
      type: object
      description: |
        One field's failure inside a `Problem`.

        Present only on problems that are about specific members of the request
        document — a validation rejection, most often. `field` is a JSON Pointer into
        that document, so a client can address the offending member directly instead
        of parsing it back out of `detail`.
      required:
        - field
        - message
      properties:
        field:
          type: string
          description: |
            RFC 6901 JSON Pointer to the offending member. Empty when the failure is
            about the document as a whole rather than one of its fields.
          example: /productGroupData/batteryModelId
        message:
          type: string
          description: What is wrong with that member, in one sentence.
          example: '''batteryModelId'' is mandatory for a ''industrial'' battery and is absent'
    PassportResponse:
      type: object
      description: The canonical Digital Product Passport record. Fields marked required are emitted on every read; the rest are omitted when unset rather than sent as null, so a consumer must treat absence and null as the same thing.
      required:
        - id
        - productName
        - productGroup
        - manufacturer
        - materials
        - status
        - schemaVersion
        - createdAt
        - updatedAt
        - retentionLocked
        - version
      properties:
        id:
          $ref: '#/components/schemas/DppId'
        batchId:
          type:
            - string
            - 'null'
        serialNumber:
          type:
            - string
            - 'null'
          description: |-
            The serial of the physical unit this passport covers, as the manufacturer stamps or records it. Meaningful where `granularity` is `item` — "one passport per physical unit".
            **Not the serial in the resolver URL.** A GS1 Digital Link carrier is `/01/{gtin}/21/{serial}` and AI 21 is a serial number, but the value there is derived from this passport's own UUID to fit GS1's length cap. That value identifies the *record*; this identifies the *product*, and nothing reconciles the two.
            Absent means not stated — and today it is always absent: no route accepts one until an adopted delegated act makes a passport cover a single unit.
          example: NW-48V-000123
        productName:
          type: string
          example: EcoCell Pro 48V
        productGroup:
          type: string
          description: 'EU ESPR product group — the delegated-act bucket selecting the applicable schema and plugin. Deliberately an open string, not a closed enum: adding a product group is a catalog manifest plus a schema, not a release, and a product group this build does not know still round-trips its wire tag verbatim.'
          example: battery
        applicableInstruments:
          type: array
          description: |-
            The legal instruments this passport was issued under, fixed when the product was placed on the market and never recomputed afterwards.

            A set rather than a single value because acts accumulate: ESPR Art. 5(7) lets a group-specific delegated act supplement a horizontal one and the Regulation states no precedence rule, so the governing law is the union of the members' requirements.

            Not derivable from `productGroup`. A horizontal act can reach a product whose product group is not one the catalog models, so an entry may be asserted by the economic operator rather than resolved from the catalog — which `recorded` distinguishes. Read it as who asserted the entry, not as how much to trust it.
          items:
            $ref: '#/components/schemas/InstrumentRef'
        granularity:
          $ref: '#/components/schemas/Granularity'
          description: The level this passport describes. Omitted entirely while no adopted act has fixed one — see `Granularity` for why omitted is not `item`.
        manufacturer:
          $ref: '#/components/schemas/ManufacturerInfo'
        materials:
          type: array
          items:
            $ref: '#/components/schemas/MaterialEntry'
        co2ePerUnit:
          description: 'CO₂e per unit — manufacturer-supplied or engine-calculated. An object, not a bare number: a figure without its lifecycle stage and system boundary is not comparable to another product''s.'
          anyOf:
            - $ref: '#/components/schemas/CarbonFootprint'
            - type: 'null'
        repairabilityScore:
          description: Non-regulatory repairability heuristic — see the schema's own note.
          anyOf:
            - $ref: '#/components/schemas/RepairabilityScore'
            - type: 'null'
        complianceResult:
          allOf:
            - $ref: '#/components/schemas/ComplianceResult'
          description: The computed compliance determination, attached at create/update. Part of the signed payload and immutable after retention lock. Absent until a determination has been computed.
        lintResult:
          allOf:
            - $ref: '#/components/schemas/LintResult'
          description: Advisory plausibility findings. Absent until a lint pass has run, and recomputable after publish — unlike `complianceResult`.
        productGroupData:
          description: |-
            On an **authenticated full read**, explicitly `null` rather than omitted for a passport whose product group data has not been supplied yet, which is every draft created without it: the field has no `skip_serializing_if`.
            On a **redacted read** — the public routes, the resolver, and an audience-scoped credential read — the key is **absent** instead. A view should not carry a key whose value says "there is nothing here", so the redaction drops it. Read an absent key and a `null` as the same answer; neither is in `required`, and which one arrives depends on the route.
          anyOf:
            - $ref: '#/components/schemas/ProductGroupData'
            - type: 'null'
        status:
          $ref: '#/components/schemas/PassportStatus'
        qrCodeUrl:
          type:
            - string
            - 'null'
          format: uri
          description: 'The link the data carrier (QR) encodes, set on publish. For a GS1 identifier it is a GS1 Digital Link at the level the passport states: {resolverBase}/01/{gtin} for a model, {resolverBase}/01/{gtin}/10/{batch} for a batch, and {resolverBase}/01/{gtin}/21/{carrierSerial} for an item or an unstated level. Otherwise {resolverBase}/dpp/{id}. resolverBase is per-deployment (RESOLVER_BASE_URL, required, no default).'
        carrierSerial:
          type: string
          description: The serial printed in AI 21 of the passport's GS1 carrier, where one was attributed. Absent means the default, derived from the passport id. An amended passport carries its predecessor's, so the label on the object keeps resolving. Printed on the label, so public.
        jwsSignature:
          type:
            - string
            - 'null'
          description: Compact JWS (Ed25519) over the **full** canonical payload. Null until published.
        publicJwsSignature:
          type: string
          description: Compact JWS over the **public (redacted) view**, so anyone can verify the public passport independently — the resolver checks this on the unauthenticated public route. Set at publish; absent for drafts.
        disclosureSignatures:
          type: object
          additionalProperties:
            type: string
          description: 'Compact JWS signatures over the **non-public** redacted views, keyed by disclosure set (e.g. `public+restricted+individual`), never by audience name. Every audience receiving more than the public view needs a proof over *its* view: `publicJwsSignature` covers only the public payload and `jwsSignature` only the full one, so a reader handed a filtered body and either of those holds a signature that cannot verify against the bytes it received. Empty for drafts.'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        publishedAt:
          type:
            - string
            - 'null'
          format: date-time
        placedOnMarketDate:
          type: string
          format: date
          description: The date the product was placed on the EU market — the regulated triggering event fixing **which law governs it**, distinct from the three lifecycle dates above, none of which selects a rule. Staged EU obligations attach at placing on the market and do not move afterwards. Absence means the date was not declared; it is **not** licence to substitute the current date, and a determination depending on it has no answer.
        schemaVersion:
          type: string
          description: Semantic version of the **product group** schema `productGroupData` was validated against. Scoped to `productGroupData` only — the envelope fields have no equivalent version and never will, because they are shared by every product group's stored documents. The envelope's compatibility rule is additive only, permanently.
          example: 1.0.0
        retentionLocked:
          type: boolean
          description: |
            Set to `true` permanently on first publish. Retention-locked
            passports must remain accessible for the EU ESPR retention period.
        version:
          type: integer
          minimum: 1
          description: Monotonic version counter. `1` on first publish; incremented on the successor each time a new version supersedes this record.
        supersedesId:
          allOf:
            - $ref: '#/components/schemas/DppId'
          description: The passport this record supersedes. Absent for first versions.
        derivedFrom:
          type: array
          description: |-
            Cross-operator references to the predecessors this passport derives from (second-life successor linkage), each naming the operation that produced this unit from it.
            A list rather than the single `parentPassportRef` it replaces: Regulation (EU) 2023/1542 Art. 77(7) is plural on both sides — "linked to the battery passport or passports of the original battery or batteries" — so one second-life unit may derive from several predecessors.
          items:
            $ref: '#/components/schemas/DerivationRef'
        componentRefs:
          type: array
          description: |-
            Cross-operator references to the constituent passports this product is assembled from — its bill of materials, each qualified by how much and in what role. The inverse edge of `derivedFrom`: these point down to the constituents, those point up to the predecessors.
            Distinct from `materials`, which lists substances by weight. These point at other products that have passports of their own.
          items:
            $ref: '#/components/schemas/ComponentRef'
        lifeStatus:
          $ref: '#/components/schemas/LifeStatus'
        retentionUntil:
          type: string
          format: date-time
          description: Deadline by which this record must remain accessible, computed at publish from the product group's retention period. Regulation (EU) 2024/1781 Art. 9(2)(i) requires the delegated act to specify a period corresponding to at least the product's expected lifetime; Art. 11(e) restates it as an essential requirement, available including after the responsible operator's insolvency, liquidation or cessation of activity.
        productId:
          type: string
          format: uuid
          description: Opaque link to an internal product-template record. Not a legal identifier.
        commodityCode:
          type: string
          description: Customs tariff classification — HS-6, CN-8 or TARIC-10. Absent where the product group does not call for one; this node will not invent a classification it cannot derive.
          example: '85076000'
        operatorIdentifier:
          type: string
          description: 'EORI or national economic-operator identifier for the responsible party (Regulation (EU) 2024/1781 Annex III(k); issuance mechanics in Art. 12). **Frozen at publish — this is the operator that published the passport, not necessarily the one responsible for it now.** A transfer of responsibility does not rewrite it and cannot: published content is immutable and covered by the signature over it. For current responsibility, read the transfer chain.'
          example: DE123456789
        responsibleOperator:
          allOf:
            - $ref: '#/components/schemas/ResponsibleOperatorSnapshot'
          description: |-
            Who is answerable for this product and under which law (Regulation (EU) 2024/1781 Annex III(k)). Frozen at publish for the same reason as `operatorIdentifier`: published content is covered by the signature over it, so a later transfer mints a new version rather than rewriting this one.
            **Nothing writes this yet**, so it is always absent: the snapshot needs the operator's role and the legal basis that makes them answerable, and neither is operator configuration today.
        facility:
          allOf:
            - $ref: '#/components/schemas/FacilitySnapshot'
          description: Snapshot of the Annex III facility where this product was manufactured or processed, copied by value at create time.
        seal:
          allOf:
            - $ref: '#/components/schemas/SealedEnvelope'
          description: The eIDAS electronic seal applied to this passport. Absent until a seal has been applied; check its `placeholder` flag rather than inferring validity from presence.
    DppId:
      type: string
      format: uuid
      description: UUID v7 identifier assigned on creation. Embedded in QR codes and public URLs.
      example: 019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f
    PassportStatus:
      type: string
      enum:
        - draft
        - active
        - suspended
        - retired
        - superseded
        - deactivated
      description: |
        DPP lifecycle state. The domain model uses `Published` internally;
        the wire format uses `active`. Deserialization accepts both.
        - `draft`: under construction; not publicly resolvable
        - `active`: published and signed; publicly resolvable via QR
        - `suspended`: temporarily hidden (recall, dispute); JWS preserved
        - `retired`: post-retention; immutable; retained for regulatory record-keeping.
          Not archiving — see `GET /dpp/{dppId}/versions` for EN 18221 clause 4.2
        - `superseded`: replaced by a newer passport version; terminal
        - `deactivated`: end-of-life declared (recycled, destroyed under a
          derogation, exported, or lost); terminal. The typed reason is carried by
          the EOL event, not this field

        Valid transitions:
        draft → active, draft → retired,
        active → suspended, active → retired, active → superseded,
        active → deactivated,
        suspended → active, suspended → retired, suspended → deactivated.

        `retired`, `superseded` and `deactivated` are terminal.

        `archived` is refused on input. It was this status's wire value and now names
        only what EN 18221 clause 4.2 means by it — the retention of historical
        versions of a passport that is still live.
    Granularity:
      type: string
      enum:
        - model
        - batch
        - item
      description: |
        The level a passport is issued at.

        Set by the applicable delegated act (ESPR Art. 9(2)(d)), so it is a property
        of the law rather than a choice made here, and unset while no adopted act has
        fixed one — which is every product group today.

        Unset is not `item`: the EU registry registers batteries per item, but that is
        the registry's operational position, not a level any act has set.

        One schema serves both the level a passport records and the level an act
        fixes, because those are the same enumeration in the code. They were written
        out twice, which made them two things to drift and left neither checked
        against the code that emits them.
      example: item
    ManufacturerInfo:
      type: object
      required:
        - name
        - address
      properties:
        name:
          type: string
          example: EcoTech GmbH
        address:
          type: string
          example: Hauptstraße 1, 10115 Berlin
        registeredTradeName:
          type:
            - string
            - 'null'
          description: Registered trade name or trade mark, where it differs from `name`. Art. 27(6) names it separately from the legal name — "name, registered trade name or registered trade mark" — because the two routinely differ, and the one a reader holding the product sees is the trading one. Absent means not stated, including the common case where the legal name is the trading name.
          example: Nordwerk
        electronicAddress:
          type:
            - string
            - 'null'
          description: 'Electronic means of contact — an email address or a contact URL. Not `didWebUrl`: a DID document resolves keys, and nothing in it need be a mailbox or a form, so treating it as one would satisfy Art. 27(6) on paper while leaving a reader with no way to reach anybody.'
          example: compliance@nordwerk.example
        country:
          type:
            - string
            - 'null'
          pattern: ^[A-Z]{2}$
          description: 'ISO 3166-1 alpha-2 country of the manufacturer, upper case. Structural rather than descriptive: whether an operator is established in the Union separates a manufacturer from an importer or an authorised representative, and that decides who carries the passport obligation. Before this field existed the country was written into `address`, where nothing downstream could tell it apart from a postal address. Absent means not stated — never stateless.'
          example: DE
        didWebUrl:
          type: string
          format: uri
          description: did:web URL for the manufacturer
          example: https://ecotech.example.com/.well-known/did.json
    FacilitySnapshot:
      type: object
      description: 'Annex III facility details copied by value into the passport at create time. Self-contained on purpose: the signed passport stays a complete record even after the operator retires the facility from their mutable registry.'
      required:
        - scheme
        - value
        - name
        - country
      properties:
        scheme:
          type: string
          example: gln
        value:
          type: string
          example: '4012345000009'
        name:
          type: string
        country:
          type: string
          pattern: ^[A-Z]{2}$
          description: |
            ISO 3166-1 alpha-2 country code, upper case.

            Copied from a facility whose country was validated against the ISO
            3166-1 list when it was registered, so this is what the server emits
            rather than merely what it hopes for.
        address:
          type: string
    MaterialEntry:
      type: object
      required:
        - name
        - weightKg
      properties:
        name:
          type: string
          example: Lithium carbonate
        weightKg:
          type: number
          description: Weight in kilograms
          example: 12.5
        recycledPct:
          type: number
          minimum: 0
          maximum: 100
          description: Percentage of recycled content (0.0–100.0)
          example: 35
        countryOfOrigin:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2 country code
          example: DE
    ProductGroupData:
      type: object
      description: |
        Product group-specific data, **internally tagged** by a `productGroup`
        discriminator — e.g.
        `{ "productGroup": "battery", "productIdentifier": { "scheme": "gs1", "gtin": "…" }, "batteryChemistry": "LFP", … }`.
        The remaining fields are product group-specific and validated against the
        product group's versioned JSON schema. Product groups include `battery`, `textile`,
        `steel`, `aluminium`, `tyre`, `electronics`, and others.

        The tag is **open**. A product group this build does not model round-trips its
        tag and payload verbatim instead of failing to parse, so a group added to the
        catalog does not require a client release. That is why the server does not
        derive this shape with `#[serde(tag = …)]`, which would close the set at
        compile time.
      required:
        - productGroup
      discriminator:
        propertyName: productGroup
      properties:
        productGroup:
          type: string
          example: battery
      additionalProperties: true
      example:
        productGroup: battery
        productIdentifier:
          scheme: gs1
          gtin: '09506000134352'
        batteryChemistry: LFP
        nominalVoltageV: 48
        nominalCapacityAh: 100
        expectedLifetimeCycles: 3000
        co2ePerUnitKg: 45.2
    PassportRef:
      type: object
      required:
        - uri
        - publicJwsHash
      description: 'A cross-operator reference to another passport: where to fetch it, and the lowercase-hex SHA-256 of that passport''s public JWS, which pins the exact signed public view expected there.'
      properties:
        uri:
          type: string
          format: uri
          description: Resolvable https URI of the referenced passport.
          example: https://id.other-operator.example/dpp/0191b2c3-d4e5-7f80
        publicJwsHash:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: Lowercase hex SHA-256 of the referenced passport's public JWS.
    Quantity:
      type: object
      required:
        - value
      description: How much of a constituent an assembly contains.
      properties:
        value:
          type: number
          minimum: 0
          description: The amount, in `unit`. Refused on create and update when it is negative or not finite — a non-finite number cannot be serialised into the signed publish payload, so an unchecked one fails much later with an error that names serialisation rather than the field that caused it.
          example: 2
        unit:
          type:
            - string
            - 'null'
          description: The unit `value` is expressed in. A free string because the vocabulary is the product group's to define, not the passport envelope's. Absent where the declaring operator stated a bare count.
          example: kg
    ComponentRef:
      type: object
      required:
        - reference
      description: |-
        One entry in a bill of materials: a cross-operator reference to a constituent passport, qualified by how much of it the assembly contains and in what role.
        Readers must also accept the earlier shape this replaced — a bare `PassportRef` (`uri` and `publicJwsHash` at the top level, with no `reference` key). The two are disjoint, so accepting both requires no guessing. The tolerance is not optional: `componentRefs` is part of the **signed public view** that other operators' nodes fetch, those passports are signed and cannot be rewritten by anyone, and a reader that refuses the older shape reports a correct, unforgeable document as a malformed reference — which verification grades as an integrity violation. Nodes are independent per-operator deployments, so version skew is the steady state rather than a migration window. Writers emit only this shape.
      properties:
        reference:
          allOf:
            - $ref: '#/components/schemas/PassportRef'
          description: Where to fetch the constituent's passport, and the hash pinning its signed public view.
        quantity:
          allOf:
            - $ref: '#/components/schemas/Quantity'
          description: How much of this constituent the assembly contains. Absent where the declaring operator did not state one.
        role:
          type:
            - string
            - 'null'
          description: What part this constituent plays, in whatever terms the product group uses — "cell", "outer shell", "warp yarn". A free string because the vocabulary is the product group's to define.
          example: cell
    SecondLifeOperation:
      type: string
      description: |-
        Which second-life operation produced a unit from a predecessor, as Regulation (EU) 2023/1542 defines them.
        Required on every derivation edge: Art. 77(7) attaches different legal consequences to each operation, so an edge that does not say which one occurred records less than the article asks for.
      enum:
        - preparationForReuse
        - preparationForRepurposing
        - repurposing
        - remanufacturing
      x-enum-descriptions:
        preparationForReuse: Art. 3(29) — preparing for re-use as defined in Art. 3, point (16), of Directive 2008/98/EC.
        preparationForRepurposing: Art. 3(30) — a waste battery, or parts thereof, prepared so that it can be used for a different purpose than the one it was designed for.
        repurposing: Art. 3(31) — a battery that is not a waste battery, or parts thereof, used for a purpose other than the one it was designed for. Differs from `preparationForRepurposing` by the waste status of the input.
        remanufacturing: Art. 3(32) — disassembly and evaluation of all cells and modules, and re-use of a number of them, to restore capacity to at least 90 % of the original rated capacity, for the same purpose as originally designed.
    DerivationRef:
      type: object
      required:
        - reference
        - operation
      description: One predecessor a second-life unit derives from, and the operation that produced this unit from it.
      properties:
        reference:
          allOf:
            - $ref: '#/components/schemas/PassportRef'
          description: Where to fetch the predecessor's passport, and the hash pinning its signed public view.
        operation:
          $ref: '#/components/schemas/SecondLifeOperation'
    LifeStatus:
      type: string
      description: |-
        Where a unit sits in its product life — original, or the second-life operation that produced it, or waste. Regulation (EU) 2023/1542, Annex XIII point 4(c).
        Orthogonal to `status`, which is the passport record's own lifecycle (draft/published/…). A published passport can be `waste`; a draft cannot be `superseded`.
        Not derived from `derivedFrom`. Art. 77(7) permits several predecessors with nothing forcing them to share an operation, so a unit built from one repurposed and one remanufactured predecessor has no unambiguous derived status. The claim is stored and then checked: at least one derivation edge must support it.
      enum:
        - original
        - repurposed
        - re-used
        - remanufactured
        - waste
    CarbonFootprint:
      type: object
      description: A CO₂-equivalent figure with the LCA context needed to read it. A bare number is not comparable across products — the lifecycle stage and system boundary are what make two figures mean the same thing.
      required:
        - valueKg
      properties:
        valueKg:
          type: number
          description: CO₂-equivalent value in kg per functional unit.
          example: 45.2
        lifecycleStage:
          type: string
          description: LCA lifecycle stage this figure covers.
          enum:
            - cradle-to-gate
            - cradle-to-grave
            - cradle-to-cradle
            - gate-to-grave
            - other
        systemBoundary:
          type: string
          description: LCA system-boundary standard used.
          enum:
            - EN-15804
            - ISO-14044
            - GHG-protocol
            - other
        methodologyRef:
          type: string
          description: Citation for the methodology behind the figure.
        performanceClass:
          type: string
          maxLength: 8
          description: Performance class label as defined by the applicable delegated act (e.g. an A–G band). Free text because the banding is per product group.
          example: B
    RepairabilityScore:
      type: object
      description: Repairability as a **non-regulatory heuristic**. Deliberately not an EN 45554 or Regulation (EU) 2023/1669 index — those have prescribed methodologies this does not implement, and presenting a heuristic as either would misstate it.
      required:
        - overall
      properties:
        overall:
          type: number
          minimum: 0
          maximum: 10
          example: 7.5
        criteria:
          type: array
          description: Per-criterion breakdown. Empty when only the overall score is known.
          items:
            $ref: '#/components/schemas/RepairCriterion'
    RepairCriterion:
      type: object
      description: One weighted criterion contributing to a repairability score.
      required:
        - name
        - score
        - weight
      properties:
        name:
          type: string
          example: disassembly_depth
        score:
          type: number
        weight:
          type: number
    ComplianceResult:
      type: object
      description: The computed compliance determination. Part of the signed passport payload and immutable after retention lock. Absent until a determination has been computed — for example on a product group with no plugin loaded.
      required:
        - co2eScore
        - repairabilityIndex
        - recycledContentPct
        - complianceStatus
      properties:
        co2eScore:
          type:
            - number
            - 'null'
          description: Calculated or manufacturer-supplied CO₂e score in kg.
        repairabilityIndex:
          type:
            - number
            - 'null'
          description: Calculated or manufacturer-supplied repairability index (0.0–10.0).
        recycledContentPct:
          type:
            - number
            - 'null'
        complianceStatus:
          $ref: '#/components/schemas/ComplianceStatus'
        violations:
          type: array
          description: Binding findings — these block publish when the product group is in force. Empty for passthrough or not-assessed determinations.
          items:
            $ref: '#/components/schemas/ComplianceFinding'
        warnings:
          type: array
          description: Advisory findings — surfaced but never blocking (e.g. thresholds not yet in force).
          items:
            $ref: '#/components/schemas/ComplianceFinding'
        rulesetVersion:
          type: string
          description: Version of the resolved calculation ruleset, when one ran.
        assessedAt:
          type: string
          format: date-time
        receipt:
          type: object
          additionalProperties: true
          description: Calculation receipt (input hash, ruleset id and version, factor dataset version and table hash) for notified-body audit. Present only when a calculation actually ran.
    ComplianceStatus:
      type: string
      description: Overall compliance determination. `PASSTHROUGH_NO_VALIDATION` means no product group plugin was loaded and nothing was assessed — it is not a pass.
      enum:
        - PASSTHROUGH_NO_VALIDATION
        - COMPLIANT
        - NON_COMPLIANT
        - NOT_ASSESSED
        - NOT_IMPLEMENTED
    ComplianceFinding:
      type: object
      description: A single compliance finding. Severity is encoded by which array it lands in on `ComplianceResult` — `violations` bind, `warnings` advise — so there is no separate severity field.
      required:
        - code
        - message
      properties:
        code:
          type: string
          description: Stable machine-readable code.
          example: battery.recycled_content.cobalt_below_2031
        field:
          type: string
          description: JSON-pointer-style locator, or absent when the finding is not tied to a single field.
          example: /recycledContentCobaltPct
        message:
          type: string
    LintResult:
      type: object
      description: Non-binding plausibility findings — arithmetic and physical-plausibility checks, distinct from binding compliance rules. Never gates publish, and may be recomputed at any time after publish via `POST /dpp/{dppId}/lint` (unlike `complianceResult`, which is frozen into the signed payload).
      required:
        - packVersion
        - assessedAt
      properties:
        packVersion:
          type: string
          description: Version of the lint pack that produced these findings.
          example: 1.0.0
        findings:
          type: array
          items:
            $ref: '#/components/schemas/LintFinding'
        assessedAt:
          type: string
          format: date-time
    LintFinding:
      type: object
      required:
        - code
        - field
        - severity
        - message
      properties:
        code:
          type: string
          example: mass.balance
        field:
          type: string
          example: /materials
        severity:
          $ref: '#/components/schemas/LintSeverity'
        message:
          type: string
    LintSeverity:
      type: string
      description: Severity of a plausibility finding. Neither value blocks publish — lint is advisory by construction, unlike a compliance violation.
      enum:
        - WARNING
        - NOTICE
    InstrumentRef:
      type: object
      description: |
        One legal instrument recorded on a passport as applicable to it, and who
        asserted that it applies.

        Fixed when the product was placed on the market and never recomputed. Carries
        no status, deliberately: a passport freezes its applicable set at issuance, and
        re-deriving how far along an act is would misstate what governed the product at
        that moment. The obligation endpoint's `ReachingInstrument` answers the live
        question and does carry status.
      properties:
        instrument:
          type: string
          description: The instrument's catalog id.
          example: battery-reg-2023-1542
        recorded:
          $ref: '#/components/schemas/RecordedBasis'
          description: Who asserted this entry, fixed at issuance along with the rest of the applicable set.
      required:
        - instrument
        - recorded
      additionalProperties: false
    RecordedBasis:
      type: string
      enum:
        - catalog
        - operator
      description: |
        Who asserted that an act applies. Read it as provenance, not as how much to
        trust the entry.

        `catalog` — resolved from the instrument catalog.

        `operator` — asserted by the economic operator placing the product on the
        market. **Not a fallback.** The catalog cannot be exhaustive: horizontal
        ecodesign requirements cover sets of products that were never shortlisted as
        product groups, so an act may apply to a product while reaching no product
        group the catalog models. An operator who knows an act applies must be able to
        say so.
      example: catalog
    SealedEnvelope:
      type: object
      description: An eIDAS electronic seal over the passport's full-payload signature. Check `placeholder` before treating it as evidence — a placeholder envelope is produced when no QTSP is configured and carries no legal validity.
      required:
        - format
        - sealValue
        - sealedAt
        - placeholder
      properties:
        format:
          $ref: '#/components/schemas/SealFormat'
        sealValue:
          type: string
          description: Base64-encoded seal value as returned by the QTSP.
        signingCertRef:
          type: string
          description: Reference to the signing certificate chain.
        conformanceLevel:
          allOf:
            - $ref: '#/components/schemas/SealConformanceLevel'
          description: |-
            The baseline level this seal was **requested** at.
            A record of what was asked for, not proof of what arrived — the same standing as `signingCertRef`, which names the certificate the seal claims rather than one anybody verified. What the bytes actually carry is read out of the envelope by a validator, and the two agreeing is the cross-check.
            Absent means **not recorded** — an envelope written before this field existed, or a seal restored from a backup or produced elsewhere. It never means the level was low.
        sealedAt:
          type: string
          format: date-time
        placeholder:
          type: boolean
          description: '`true` when this envelope has **no** legal validity. Consumers must check this flag rather than inferring validity from the envelope''s presence.'
    SealFormat:
      type: string
      description: AdES format of a seal value.
      enum:
        - JADES
        - PADES
        - CADES
        - XADES
    SealConformanceLevel:
      type: string
      description: |-
        How much validation material a seal carries with it — the AdES baseline levels, named as the CSC API names them. They are cumulative: each adds to the one before.
        The level decides whether a verifier years from now can still establish that the seal was valid when it was made. A `baseline-b` seal stops verifying when its signing certificate expires, ESPR retention outlives certificate lifetimes comfortably, the seal is bought once, and the document it covers is retention-locked — so the choice cannot be corrected afterwards by re-sealing.
      enum:
        - baseline-b
        - baseline-t
        - baseline-lt
        - baseline-lta
      x-enum-descriptions:
        baseline-b: '`AdES-B-B` — the signature alone. No timestamp, no validation material. Verifiable only while the signing certificate is valid and its status is still resolvable. Adequate for a short-lived attestation, not for a passport.'
        baseline-t: '`AdES-B-T` — adds a trusted timestamp, so the signing time is established independently of the signer''s clock.'
        baseline-lt: '`AdES-B-LT` — adds the certificates and revocation data a verifier needs, so the seal remains verifiable after the signing certificate expires. The first level that survives certificate expiry, and therefore the first that suits a retention-locked document.'
        baseline-lta: '`AdES-B-LTA` — adds archival timestamps, extending validity past the cryptographic lifetime of the algorithms themselves.'
    CreatePassportRequest:
      type: object
      required:
        - productName
        - manufacturer
      description: |
        Request body for creating a new DPP. Only `productName` and
        `manufacturer` are required. All other fields are optional and
        can be filled in later via `PUT` before publishing.
      properties:
        productName:
          type: string
          description: Human-readable product name. Must not be blank.
          example: EcoCell Pro 48V
        productGroup:
          type: string
          description: |
            EU ESPR product group (the dispatch key), e.g. `battery`, `textile`,
            `electronics`. Optional — derived from `productGroupData` when omitted.
          example: battery
        manufacturer:
          $ref: '#/components/schemas/ManufacturerInfo'
        materials:
          type: array
          items:
            $ref: '#/components/schemas/MaterialEntry'
        co2ePerUnit:
          type: number
          minimum: 0
          description: |-
            CO₂ equivalent per unit, in kg. Must be finite and non-negative; anything else is rejected with `422`.
            Supplied here as a scalar and stored as a `CarbonFootprint` object, so the value echoed back on `PassportResponse` is `{ "valueKg": … }` rather than the bare number sent. The lifecycle stage and system boundary that make two figures comparable cannot be set through this route.
          example: 4.2
        repairabilityScore:
          type: number
          minimum: 0
          maximum: 10
          description: |-
            Non-regulatory repairability heuristic, **0–10**. Not EN 45554 or EU 2023/1669 — those have prescribed methodologies this does not implement. Anything outside 0–10 is rejected with `422`.
            Supplied here as a scalar and stored as a `RepairabilityScore` object, so the value echoed back on `PassportResponse` is `{ "overall": … }` rather than the bare number sent.
          example: 7.5
        productGroupData:
          description: Optional at create; an explicit `null` is accepted and equivalent to omitting it. Publish validates it only when present.
          anyOf:
            - $ref: '#/components/schemas/ProductGroupData'
            - type: 'null'
        batchId:
          type: string
          description: Optional batch or lot identifier
          example: BATCH-2026-04-001
        placedOnMarketDate:
          type: string
          format: date
          description: |
            The date this product was placed on the EU market — the regulated
            triggering event that fixes which law governs it.

            Optional, and omitting it is not neutral. A compliance determination
            whose rule is phased by date has no answer without it: the node
            reports the missing fact rather than substituting today's date, which
            would produce a determination that silently changes its own answer
            when a phase begins. For batteries this decides which EU 2023/1542
            Art. 8 minimum recycled shares apply.
          example: '2026-03-14'
        schemaVersion:
          type: string
          description: |
            Product group schema version. Optional, and the only accepted value is the
            product group's **current** version — omitting it is equivalent. Any other
            value is rejected with `422`.

            It is not the caller's to choose: the stored version selects the
            disclosure table the passport's public view is filtered through and
            signed under, and an older table classifies fewer fields, defaulting
            the rest to public. The body is validated against the current schema
            in either case, so a differing declaration is already false about the
            body it accompanies.
          example: 2.6.0
        derivedFrom:
          type: array
          items:
            $ref: '#/components/schemas/DerivationRef'
          description: Cross-operator predecessors this passport derives from (second-life successor linkage), each naming the operation that produced this unit from it. A list rather than the single `parentPassportRef` it replaces, because Regulation (EU) 2023/1542 Art. 77(7) permits several predecessors.
        lifeStatus:
          allOf:
            - $ref: '#/components/schemas/CreateLifeStatus'
          description: |-
            Where this unit sits in its **product** life — Annex XIII point 4(c) of Regulation (EU) 2023/1542.

            Orthogonal to `status`, which is the *publication* lifecycle: a `repurposed` unit's passport is `active` like any other. Omit it for any product group but battery — only Reg. (EU) 2023/1542 defines this vocabulary, and a textile passport asserting `original` would be borrowing a battery term for a question its own instrument does not ask. Sending it for another product group is refused.

            **This is the only way to set it.** `lifeStatus` is protected from patching, because it is part of a body that gets signed and rewriting it after publication would change what a proof covers.

            🚨 **`waste` is refused here.** The other four describe how a unit came to be, and Art. 77(7) makes each operation produce a *new* passport — so a repurposed unit is created as `repurposed`. `waste` is the one value that happens to a record which continues, and under that article's second subparagraph it moves responsibility as well, so it belongs to a versioning event on the passport becoming waste rather than to the creation of a new one.

            Omitting it is lawful everywhere. For a battery it reads as *not stated* rather than *not applicable*, and publish does not refuse it — the lint result says so instead, because a default would put a claim about a unit onto a record that is about to be signed.
        componentRefs:
          type: array
          items:
            $ref: '#/components/schemas/ComponentRef'
          description: Cross-operator references to this product's constituent passports (its bill of materials), each optionally qualified by how much and in what role. Local cycles / over-depth are refused at create/update; immutable after publish.
        commodityCode:
          type: string
          description: Customs tariff classification — HS-6, CN-8 or TARIC-10 (6, 8 or 10 digits, no separators). Registration data the EU registry verifies against the ranges its product group permits.
          example: '85076000'
        supersedesId:
          allOf:
            - $ref: '#/components/schemas/DppId'
          description: |
            The passport this one replaces, if it is a new version of an existing
            record.

            Declared here because `supersedesId` is protected: it is set by the write
            that creates the whole record, never by a field patch. Recording the link
            retires nothing — the predecessor is retired by
            `POST /dpp/{dppId}/supersede` once this successor is published, and that
            route checks the two agree.

            Only for a successor created independently. `POST /dpp/{dppId}/amend`
            mints its own successor and sets this field itself; it is not settable
            through that route and does not need to be.
    SuspendRequest:
      type: object
      description: |
        Optional body for suspending a published passport.

        The body itself is optional — `POST` with no body suspends without a recorded
        reason. Where a reason is supplied it is appended to the audit entry, not
        stored on the passport, so it is not part of the signed document.
      properties:
        reason:
          type: string
          description: Human-readable reason for the suspension, appended to the audit trail.
          example: Product recall — safety investigation pending
    EolRequest:
      type: object
      required:
        - reason
      description: Request body for declaring a passport end-of-life.
      properties:
        reason:
          $ref: '#/components/schemas/DeactivationReason'
        declaredBy:
          type:
            - string
            - 'null'
          description: DID of the declaring operator; defaults to the authenticated actor.
        materialRecovery:
          type:
            - object
            - 'null'
          description: Optional recovered-material summary (Battery Annex XIII circularity).
        notes:
          type:
            - string
            - 'null'
    DeactivationReason:
      description: Why a passport reached end-of-life, internally tagged by `kind`. Destruction alone requires a `derogation` citing the lawful basis.
      oneOf:
        - type: object
          required:
            - kind
          properties:
            kind:
              type: string
              enum:
                - recycled
        - type: object
          required:
            - kind
            - derogation
          properties:
            kind:
              type: string
              enum:
                - destroyed
            derogation:
              $ref: '#/components/schemas/DerogationRef'
        - type: object
          required:
            - kind
          properties:
            kind:
              type: string
              enum:
                - exported
        - type: object
          required:
            - kind
          properties:
            kind:
              type: string
              enum:
                - lost
      example:
        kind: recycled
    DerogationRef:
      type: object
      required:
        - category
      description: A recognised derogation from the ESPR Art. 25 destruction ban. The category list is fixed by the applicable delegated act; validated against that list at the engine boundary, not by this schema.
      properties:
        category:
          type: string
          description: The derogation category as named by the delegated act.
          example: health-and-safety
        actCitation:
          type:
            - string
            - 'null'
          description: The act/article this derogation is grounded in (e.g. an OJ/CELEX ref).
    TransferInitiateRequest:
      type: object
      required:
        - fromOperator
        - toOperator
        - reason
      properties:
        fromOperator:
          allOf:
            - $ref: '#/components/schemas/ResponsibleOperator'
          description: The current (outgoing) responsible operator — must match the chain head.
        toOperator:
          allOf:
            - $ref: '#/components/schemas/ResponsibleOperator'
          description: The incoming responsible operator taking over the DPP.
        reason:
          $ref: '#/components/schemas/TransferReason'
        notes:
          type:
            - string
            - 'null'
    TransferReason:
      type: string
      description: The reason for a transfer of DPP responsibility.
      enum:
        - sale
        - return
        - remanufacturing
        - repurposing
        - preparationForReuse
        - preparationForRepurposing
        - wasteHandover
        - import
        - insolvencySuccession
    PassportListResponse:
      type: object
      description: One page of passports, with the totals needed to page through them.
      required:
        - dpps
        - total
        - limit
        - skip
      properties:
        dpps:
          type: array
          items:
            $ref: '#/components/schemas/PassportResponse'
        total:
          type: integer
          description: Total matching the filter across every page — deliberately not the length of `dpps`.
        limit:
          type: integer
          description: The page size actually applied, after clamping.
        skip:
          type: integer
          description: The offset this page starts at.
    ValidateResponse:
      type: object
      description: 'The dry-run verdict. Two booleans rather than one, because create and publish deliberately differ: a body can be creatable as a draft and not yet publishable, and collapsing that into a single flag would hide the gap until the caller tried to publish.'
      required:
        - createValid
        - productGroupDataValid
      properties:
        createValid:
          type: boolean
          description: Always `true` on a 200 — a body create would reject gets back the identical 422 create would have returned, not a paraphrase of it.
          example: true
        productGroupDataValid:
          type: boolean
          description: |
            Whether the product group data would clear the publish-time schema gate. `true`
            when no product group data is supplied, since publish only validates it when
            present.

            **Not a publish verdict.** It reports one of publish's preconditions, and
            publish applies others this route does not run: the registry-identity
            requirement (a default facility and a primary operator identifier), which
            needs operator state this route never reads; the category-mandatory
            content gate, reachable only by attempting the lifecycle transition; and
            the compliance gate, which needs a `placedOnMarketDate` and a stored
            passport. `true` means "this body clears the schema gate", never "publish
            will succeed" — the field was called `publishValid` and was renamed
            because that name promised the latter.
          example: false
        detail:
          type:
            - string
            - 'null'
          description: Why the product group data would be refused. Null when `productGroupDataValid` is true.
          example: 'cannot publish: no registered JSON Schema for product group ''furniture'' — publish requires a resolvable schema when product group data is present'
    PassportAuditEntry:
      type: object
      required:
        - id
        - passportId
        - actor
        - action
        - timestamp
      properties:
        id:
          type: string
          format: uuid
        passportId:
          type: string
        actor:
          type: string
          example: admin@example.com
        action:
          type: string
          example: published
        previousStatus:
          type:
            - string
            - 'null'
        newStatus:
          type:
            - string
            - 'null'
        metadata:
          type:
            - object
            - 'null'
        timestamp:
          type: string
          format: date-time
        requestId:
          type:
            - string
            - 'null'
          description: |-
            The `x-request-id` of the HTTP request that produced this entry — the same value returned in that request's response header, so a support conversation can be traced from a client's log to the trail. Null for an entry written outside a request, and for every entry written before this field was populated.

            Deliberately **not** covered by `entryHash`: it describes the transport that carried the change, not the change, and folding it in would invalidate the chain of every entry already stored.
        prevHash:
          type:
            - string
            - 'null'
          description: 'Hex SHA-256 of the previous entry''s `entryHash`, or null for the first entry in a passport''s chain. Together with `entryHash` this makes the trail append-only and tamper-evident: recomputing the chain detects any inserted, removed or edited entry.'
        entryHash:
          type:
            - string
            - 'null'
          description: Hex SHA-256 over this entry's canonical (RFC 8785) bytes including `prevHash`. Null only for entries written before the chain was introduced.
    TreeReport:
      type: object
      required:
        - verified
        - nodes
      description: Result of recursively verifying a passport's component tree.
      properties:
        verified:
          type: boolean
          description: True iff every visited node verified.
        nodes:
          type: array
          items:
            $ref: '#/components/schemas/TreeNodeReport'
    TreeNodeReport:
      type: object
      required:
        - path
        - verified
      properties:
        path:
          type: array
          items:
            type: string
          description: Component-ref URIs from the root down to this node.
        verified:
          type: boolean
        reason:
          type:
            - string
            - 'null'
          enum:
            - unreachable
            - notPublished
            - hashMismatch
            - cycle
            - depthExceeded
            - nodeCapExceeded
            - malformedRef
          description: The failure reason when `verified` is false.
    TransferRecord:
      type: object
      required:
        - transferId
        - passportId
        - fromOperator
        - toOperator
        - reason
        - initiatedAt
      description: A single transfer-of-responsibility event, dual-signed by the outgoing and incoming operators.
      properties:
        transferId:
          type: string
          format: uuid
        passportId:
          type: string
          format: uuid
        fromOperator:
          $ref: '#/components/schemas/ResponsibleOperator'
        toOperator:
          $ref: '#/components/schemas/ResponsibleOperator'
        reason:
          $ref: '#/components/schemas/TransferReason'
        fromSignature:
          type:
            - string
            - 'null'
          description: Compact JWS from the outgoing operator, authorising the handover.
        nodeAcceptanceAttestation:
          type:
            - string
            - 'null'
          description: Attestation by the hosting node that the acceptance step ran. Not the incoming operator's own signature - a node cannot sign for a counterparty whose key it does not hold. The authoritative record of who holds the obligations is the EU registry under Impl. Reg. (EU) 2026/1778 Art. 6a.
        initiatedAt:
          type: string
          format: date-time
        completedAt:
          type:
            - string
            - 'null'
          format: date-time
        rejectedAt:
          type:
            - string
            - 'null'
          format: date-time
        cancelledAt:
          type:
            - string
            - 'null'
          format: date-time
        notes:
          type:
            - string
            - 'null'
    ResponsibleOperator:
      type: object
      required:
        - did
        - name
        - role
        - country
      description: An economic operator responsible for a DPP (ESPR "responsible economic operator").
      properties:
        did:
          type: string
          example: did:web:acme.example.com
        name:
          type: string
        role:
          $ref: '#/components/schemas/OperatorRole'
        euOperatorId:
          type:
            - string
            - 'null'
          description: EU-assigned economic operator identifier, if available.
        euOperatorIdScheme:
          type:
            - string
            - 'null'
          description: Scheme euOperatorId is expressed in — "vat", "lei", "eori", "duns".
        registeredTradeName:
          type:
            - string
            - 'null'
          description: Registered trade name or trade mark, where it differs from `name`.
          example: Nordwerk
        postalAddress:
          type:
            - string
            - 'null'
          description: Postal address at which the operator can be contacted. Distinct from `country`, which is the establishment country the registry matches on.
          example: Hauptstrasse 1, 10115 Berlin, DE
        electronicAddress:
          type:
            - string
            - 'null'
          description: Electronic means of contact — an email address or a contact URL.
          example: compliance@nordwerk.example
        country:
          type: string
          pattern: ^[A-Z]{2}$
          description: |
            ISO 3166-1 alpha-2 country code of the operator's establishment, upper
            case.

            Checked against the assigned ISO 3166-1 list on
            `POST /dpp/{dppId}/transfer/initiate`, not merely shaped — `de` and `XX`
            are both refused with `422`. Canonical form is required rather than
            normalised because both operators are inside the payload this node signs,
            and upper-casing a caller's value before signing would sign something they
            did not send.
          example: DE
    OperatorRole:
      type: string
      description: The role of an economic operator in the DPP supply chain.
      enum:
        - manufacturer
        - importer
        - distributor
        - authorisedRepresentative
        - fulfilmentServiceProvider
        - remanufacturer
        - repurposer
        - preparerForReuse
        - repairer
        - recycler
    ResponsibilityBasis:
      description: |-
        Which law makes an operator the responsible one.
        Carried beside the operator rather than on it: the same company can be an Art. 4 responsible operator for one product and a general product-safety responsible person for another, so the basis is a property of the pairing.
        Stated rather than inferred. Inferring it from the product group works for two groups and is a guess for every other.
      oneOf:
        - type: string
          enum:
            - marketSurveillanceArt4
          description: Article 4 of Regulation (EU) 2019/1020 (market surveillance). Applies only to products subject to the instruments listed in Art. 4(5). The tasks are those in Art. 4(3).
        - type: string
          enum:
            - generalProductSafety
          description: Regulation (EU) 2023/988 (general product safety).
        - type: object
          required:
            - otherUnionLaw
          description: '"Similar tasks pursuant to other Union law applicable to the product" — Annex III(k)''s own catch-all.'
          properties:
            otherUnionLaw:
              type: object
              required:
                - citation
              properties:
                citation:
                  type: string
                  description: The instrument and provision, as it would be cited. Carried because the point of this variant is that the law is not one of the two named, and a basis that cannot say which law it is says nothing at all.
                  example: Article 7 of Regulation (EU) 2017/745
    ResponsibleOperatorSnapshot:
      type: object
      required:
        - operator
        - basis
      description: |-
        Annex III(k)'s elements as the passport carries them: who is answerable for the product, and under which law.
        A snapshot, not a live pointer. A published passport is signed, so the operator recorded in it is the one that version was issued under — a later transfer of responsibility mints a new version rather than rewriting this.
      properties:
        operator:
          $ref: '#/components/schemas/ResponsibleOperator'
        basis:
          $ref: '#/components/schemas/ResponsibilityBasis'
    SealResponse:
      type: object
      description: The eIDAS seal, plus what is needed to check it — and an explicit statement of what this node did **not** check.
      required:
        - declaredBy
        - format
        - sealValue
        - sealedAt
        - placeholder
        - currentJws
        - currentPayloadHash
        - coverage
        - binding
        - validation
        - archival
        - verification
      properties:
        declaredBy:
          $ref: '#/components/schemas/SealDeclarer'
        format:
          type: string
          description: AdES format of `sealValue`.
          example: CADES
        sealValue:
          type: string
          description: Base64 detached CAdES (`.p7s`) as returned by the QTSP.
        sealedAt:
          type: string
          format: date-time
          description: '**This node''s clock when the backend answered — not a trusted timestamp.** Not necessarily when the signature was formed. A seal carries an independently established signing time only from baseline level `T` upward, where a timestamp authority attests it; at `B` there is no such token anywhere in the envelope, so this is an unattested claim by the party that bought the seal. Anything resting on *when* the seal was made must read the timestamp token out of `sealValue`.'
        signingCertRef:
          type:
            - string
            - 'null'
          description: Hex SHA-256 of the certificate the seal names as its signer, **as reported by the seal** — read out of the CAdES, never verified. It answers *which* certificate to ask about, not whether that certificate was qualified or on the EU Trusted List. Null when the seal predates extraction or could not be parsed.
        attestedSealedAt:
          type:
            - string
            - 'null'
          format: date-time
          description: |-
            **When a timestamp authority attests the seal was made.**

            `sealedAt` is the sealing node's own clock and an unattested claim by the party that bought the seal. This is a third party's statement, read out of the time-stamp token inside `sealValue` — the only place in a seal an attested time can be.

            Checked rather than merely read: the token's own signature is verified and its imprint is matched against this seal's signature. The attribute carrying the token is *unsigned*, so a genuine token lifted from another seal would otherwise be accepted and report someone else's time as this one's.

            `null` for a `B-B` seal, which carries no token, and for a token that failed either check.

            **Attested is not trusted.** A qualified electronic time stamp is a QTSP's service (Art. 42) and the presumption of accuracy attaches to that (Art. 41(2)); establishing it is a Trusted List question about the `TSA/QTST` service type, which this node cannot yet ask. A self-signed authority's token verifies perfectly and means nothing.
        conformanceLevel:
          oneOf:
            - $ref: '#/components/schemas/SealConformanceLevel'
            - type: 'null'
          description: The baseline level this node **asked** for, recorded on the envelope. A record of intent; `null` for a seal stored before the field existed.
        evidencedLevel:
          oneOf:
            - $ref: '#/components/schemas/SealConformanceLevel'
            - type: 'null'
          description: |-
            The baseline level the seal's **bytes** carry.

            The pair is the point. A provider enabled for a weaker profile than was paid for returns a seal that is correct in every record this node keeps and stops verifying when its signing certificate expires — years later, on a passport that is retention-locked and cannot be re-sealed. Serving both makes that answerable from the seal rather than from a log nobody kept.

            A floor, not a conformance verdict: it reports that the distinguishing material for a level is present, never that the material was validated. `null` when the bytes could not be read.
        archival:
          $ref: '#/components/schemas/ArchivalFreshness'
        placeholder:
          type: boolean
          description: True when this is a placeholder with no legal validity.
        currentJws:
          type: string
          description: The passport's **current** compact JWS.
        currentPayloadHash:
          type: string
          description: Hex SHA-256 of `currentJws`.
        sealedPayloadHash:
          type:
            - string
            - 'null'
          description: 'Hex SHA-256 this node **asked** the backend to seal. A record, not proof: it says what was requested, while the validator''s extracted message digest says what the CAdES actually covers. The two agreeing is the cross-check. Null for a seal restored from a backup or produced elsewhere.'
        coverage:
          $ref: '#/components/schemas/SealCoverage'
        binding:
          $ref: '#/components/schemas/SealBinding'
        validation:
          $ref: '#/components/schemas/SealValidationStatus'
        certificate:
          oneOf:
            - $ref: '#/components/schemas/CertificateStanding'
            - type: 'null'
        origin:
          oneOf:
            - $ref: '#/components/schemas/SealOrigin'
            - type: 'null'
          description: |-
            What **this seal's own certificate** says about who issued it: did a provider issue it, or did the node sign it itself?

            Null means **not read** — a placeholder seal, a format this node does not parse, unreadable bytes, or a deployment with no inspector wired. Never read null as "not self-issued": that is a finding, and it only comes from a certificate that was actually examined.

            For what the *currently configured* backend produces, which is a different question, see `trustMode` on `GET /api/v1/seal`.
        qualification:
          oneOf:
            - $ref: '#/components/schemas/SealQualification'
            - type: 'null'
          description: |-
            What Regulation (EU) No 910/2014 Art. 32(1) says about this seal, read against the EU Trusted Lists this node holds. Art. 40 applies Art. 32 to seals *mutatis mutandis*.

            🚨 **Read `qualification.issuer.consulted` and `.unchecked` before acting on a `notListed` standing.** That verdict is the only one claiming an absence, and an absence is only as wide as what was looked at. `consulted: 0` means no list was loaded — the answer is about nothing, and it is what a node that loaded no trusted-list cache at boot reports for every provider seal, qualified or not.

            Even at its widest this is **not** "not qualified in law": a provider can be qualified and its Member State's list wrong, which is that state's problem and not something this node can see.

            Null means **not read** — a placeholder seal, a format this node does not parse, unreadable bytes, or a deployment with no inspector wired. Never read null as "not qualified": that is a finding, and it only comes from a certificate that was actually examined.
        verification:
          type: string
          description: 'Stated, not implied: this node did not cryptographically validate the CAdES. A detached CAdES must be checked by an independent AdES validator against the EU Trusted List.'
    SealSummaryResponse:
      type: object
      description: 'Operator-wide sealing state. Read `sealingConfigured` first: when it is false every count is `0` because this node has no outbox, **not** because nothing is outstanding.'
      required:
        - unsealedPublished
        - pending
        - sealed
        - exhausted
        - sealingConfigured
      properties:
        unsealedPublished:
          type: integer
          description: Published passports carrying no seal at all. `0` is the healthy state.
        pending:
          type: integer
        sealed:
          type: integer
        exhausted:
          type: integer
          description: Rows that gave up after exhausting their retries.
        sealingConfigured:
          type: boolean
        audit:
          oneOf:
            - $ref: '#/components/schemas/SealAuditReport'
            - type: 'null'
          description: |-
            What the last completed pass over every stored seal found.

            **`null` means no pass has completed**, not that nothing is wrong. The pass walks the estate in bounded batches and starts over, so this is empty until the first one finishes, and absent entirely on a deployment that runs no audit. Reporting a zero here for a check that has not run would be the one answer worse than reporting nothing.

            A node that keeps its audit state restores the last completed report at boot, so a restart no longer empties this. Where it is still `null` after a deployment, the honest reading is that no pass has ever finished against this database — on a large estate, check the audit's cadence before concluding anything about the seals.
        trustMode:
          type:
            - string
            - 'null'
          enum:
            - ghost
            - sandbox
            - live
            - null
          description: |-
            The tier the **currently configured** sealing backend resolved to. The counts say how much sealing is outstanding; this says whether the sealing that does happen is worth anything — a node can sit at `unsealedPublished: 0` while every one of those seals was signed by a key it generated itself, and no count would show it.

            A different question from the per-passport `origin`, and neither substitutes for the other: this describes the backend running now, that describes the certificate inside one stored seal. A node moved from a local backend to a QTSP last week reports `live` here and `selfIssued: true` on everything sealed before the move, and both are correct.

            Null on a deployment that resolved no seal port at all. That is **not** `ghost`: a port nobody wired and a port that landed on a placeholder are different states, and only the second blocks a production boot.
    SealDeclarer:
      type: object
      description: |
        Who declared the content a seal covers, which is not who sealed it.

        A seal proves a document came from whoever holds the certificate. It carries no
        statement about *scope*: "we vouch for this content" and "we transmitted this
        intact" look identical. A response that serves a seal and names no declaring
        party invites the reader to collapse the two, whatever anyone intended.

        Every audience view strips the seal, so this is the only surface where that
        collapse is reachable — and its readers being authenticated and technical makes
        them more likely to build on the assumption, not less.
      required:
        - manufacturer
        - operatorIdentifier
        - responsibilityMayHaveTransferred
        - note
      properties:
        manufacturer:
          type: string
          description: The manufacturer named in the sealed passport, frozen at publish.
          example: TestCorp GmbH
        operatorIdentifier:
          type:
            - string
            - 'null'
          description: The Annex III(k) unique operator identifier recorded at publish. `null` means none was recorded — never that none applies.
        responsibilityMayHaveTransferred:
          type: boolean
          description: |
            True when the passport's transfer chain records a **completed** handover,
            so the party responsible now is not the one named above. An initiated
            handover nobody accepted has moved nothing and does not set this.

            The names above are frozen into the sealed bytes and cannot be rewritten —
            a published passport's content is immutable and the seal covers it — so
            this flag is the only honest way to say the answer above is historical
            rather than current.
        note:
          type: string
          description: States the sealing/authorship distinction outright rather than leaving it to be inferred from field names, in the same spirit as `verification`.
    SealCoverage:
      type: string
      description: 'Whether the stored seal covers the passport''s current signature. Answered from this node''s record of what it *asked* to be sealed — weaker than a validator''s verdict, stronger than nothing: it cannot confirm the CAdES, but a passport re-published after sealing is knowable without any AdES tooling.'
      enum:
        - current
        - superseded
        - unknown
    SealRepairResponse:
      type: object
      description: The outcome of repairing one passport's seal.
      required:
        - action
        - payloadHash
        - note
      properties:
        action:
          type: string
          enum:
            - rearmed
            - queued
          description: |-
            `rearmed`: a `sealed` row was re-armed — the one path that buys a second seal for a digest already paid for, justified because the first does not verify.

            `queued`: a row was queued the ordinary way. The passport was re-published since the broken seal was made, so the signature now needing a seal has never been sealed and nothing is being re-bought.
        payloadHash:
          type: string
          description: Hex SHA-256 the replacement seal will cover — the passport's **current** signature, not whatever the broken seal covered.
        note:
          type: string
          description: 'Stated rather than implied, like every other note on this surface: what was queued, what it will cost, and why spending it is right here.'
    EvidenceDossier:
      type: object
      description: |
        A self-contained, signed snapshot of a passport's full proof chain,
        persisted by the node at generation time. Verification
        (`POST /evidence/{id}/verify`) is an integrity check of the stored
        dossier against its own signatures and hash chains. See
        `docs/architecture/EVIDENCE-DOSSIER.md` for the full specification,
        including why `calcReceipts`/`checkpoint` are always empty/`null` in
        format v1.
      required:
        - manifest
        - manifestJws
        - fullView
        - publicView
        - didDocuments
        - auditEntries
      properties:
        manifest:
          $ref: '#/components/schemas/DossierManifest'
        manifestJws:
          type: string
        fullView:
          $ref: '#/components/schemas/SignedLayer'
        publicView:
          $ref: '#/components/schemas/SignedLayer'
        didDocuments:
          type: object
          description: DID document snapshots, keyed by DID.
          additionalProperties:
            type: object
        auditEntries:
          type: array
          items:
            $ref: '#/components/schemas/PassportAuditEntry'
        transferChain:
          type:
            - object
            - 'null'
          description: Present iff the passport has ever changed responsible operator.
        eolEvent:
          type:
            - object
            - 'null'
          description: Present iff the passport was declared end-of-life.
        checkpoint:
          type:
            - object
            - 'null'
          description: Always `null` in format v1 — the signed-checkpoint layer is not yet built.
        calcReceipts:
          type: array
          description: Always empty in format v1 — `dpp-calc` invocation is not yet wired end to end.
          items:
            type: object
        componentGraph:
          anyOf:
            - $ref: '#/components/schemas/TreeReport'
            - type: 'null'
          description: |
            The recursive component-tree (bill-of-materials) verification report,
            present iff the passport declares `componentRefs`. `null` for a unit
            with no modelled sub-assemblies.

            Generated at dossier-assembly time by walking the tree and pin-checking
            each node, then bound into `contentHashes` like every other member — so
            a tampered report fails the dossier's `content_integrity` check rather
            than passing as an unverifiable attachment.

            Integrity only, the same caveat as the standalone `verify-tree` route:
            it proves each node's signed public view is unchanged against its
            pinned hash, not the cryptographic validity of that node's signature.
        qualifiedSeal:
          anyOf:
            - $ref: '#/components/schemas/QualifiedSealMember'
            - type: 'null'
          description: The passport's eIDAS qualified seal, present iff one has been applied. `null` when the seal is still queued.
    DossierManifest:
      type: object
      description: |
        Signed metadata binding every dossier member into one atomic,
        tamper-evident unit.
      required:
        - formatVersion
        - passportId
        - issuerDid
        - createdAt
        - nodeVersion
        - coreVersion
        - contentHashes
      properties:
        formatVersion:
          type: string
          example: '1'
        passportId:
          type: string
        issuerDid:
          type: string
          example: did:web:node.example.com
        createdAt:
          type: string
          format: date-time
        nodeVersion:
          type: string
        rulesetVersion:
          type:
            - string
            - 'null'
        contentHashes:
          type: object
          description: member name -> hex SHA-256 of that member's JCS-canonical bytes.
          additionalProperties:
            type: string
        coreVersion:
          type: string
          description: The `dpp-core` version this node was built against. Recorded alongside `nodeVersion` because the two move independently — the regulatory logic, schemas and disclosure policy behind a determination live in core, so a dossier naming only the node version cannot be traced back to the code that produced its verdict.
          example: 0.18.0
    SignedLayer:
      type: object
      description: |
        A JWS alongside the exact JSON payload it was signed over — embedded
        directly rather than left for a verifier to reconstruct.
      required:
        - payload
        - jws
      properties:
        payload:
          type: object
        jws:
          type: string
          description: Compact EdDSA JWS.
    QualifiedSealMember:
      type: object
      description: |
        The passport's eIDAS qualified seal as carried inside an evidence dossier:
        the seal envelope plus the JWS it was computed over and that payload's hash,
        so a verifier holding only the dossier has both the CAdES and the preimage to
        check it against.

        Included because a dossier is what an authority is handed, and the seal is its
        one member carrying an Art. 35(2) presumption — and because it is unreachable
        otherwise: the seal is stripped from `fullView` and `publicView` alike, since
        it covers the full-payload signature rather than any redaction. Bound into
        `contentHashes` like every other member.

        ⚠️ **This shape is described here but not enforced anywhere.** The field is
        held as untyped JSON on the dossier record, so no Rust type declares these
        members and the OpenAPI contract test has nothing to compare them against.
        Treat the property list as documentation of intent, not as a guarantee. Giving
        the dossier a real type for this member would close that, and is the only thing
        that would — and it is worth doing: this list had fallen five fields behind
        what the dossier actually carries before anyone read the two side by side.
      properties:
        seal:
          type: object
          description: The `SealedEnvelope` as persisted on the passport.
        signedOverJws:
          type: string
          description: The JWS the seal was computed over.
        payloadHash:
          type: string
          pattern: ^[0-9a-f]{64}$
          description: SHA-256 of the sealed payload, lowercase hex.
        binding:
          $ref: '#/components/schemas/SealBinding'
        validation:
          $ref: '#/components/schemas/SealValidationStatus'
        certificate:
          oneOf:
            - $ref: '#/components/schemas/CertificateStanding'
            - type: 'null'
        origin:
          oneOf:
            - $ref: '#/components/schemas/SealOrigin'
            - type: 'null'
          description: What the seal's own certificate says about who issued it — the one fact that decides whether anything else in this member carries weight.
        evidencedLevel:
          type:
            - string
            - 'null'
          description: The baseline level the seal's **bytes** carry, which is not necessarily the level this node asked for. A seal weaker than ordered is otherwise visible only in a drain log no dossier reader has.
        attestedSealedAt:
          type:
            - string
            - 'null'
          format: date-time
          description: A timestamp authority's statement of when the seal was made, read out of the token inside the CAdES and checked. `seal.sealedAt` beside it is the sealing node's own clock, which is an unattested claim by the party that bought the seal.
        archival:
          $ref: '#/components/schemas/ArchivalFreshness'
    EvidenceDossierRecord:
      type: object
      description: A stored dossier snapshot — the dossier plus its persistence envelope.
      required:
        - id
        - passportId
        - actor
        - createdAt
        - docHash
        - dossier
      properties:
        id:
          type: string
          format: uuid
        passportId:
          type: string
          format: uuid
        actor:
          type: string
          description: Who requested generation.
        createdAt:
          type: string
          format: date-time
        docHash:
          type: string
          description: Hex SHA-256 of the JCS-canonicalised stored dossier document.
        dossier:
          $ref: '#/components/schemas/EvidenceDossier'
    EvidenceDossierSummary:
      type: object
      description: Listing projection of a stored dossier — everything but the document.
      required:
        - id
        - passportId
        - actor
        - createdAt
        - docHash
      properties:
        id:
          type: string
          format: uuid
        passportId:
          type: string
          format: uuid
        actor:
          type: string
        createdAt:
          type: string
          format: date-time
        docHash:
          type: string
    VerificationReport:
      type: object
      required:
        - trustAnchorNote
        - checks
      properties:
        trustAnchorNote:
          type: string
          example: trust anchored to the dossier's embedded DID-document snapshot dated 2026-07-10T00:00:00Z
        checks:
          type: array
          items:
            $ref: '#/components/schemas/CheckResult'
    CheckResult:
      type: object
      description: Outcome of a single named verification check.
      required:
        - name
        - status
      properties:
        name:
          type: string
          example: audit_chain
        status:
          type: string
          enum:
            - pass
            - fail
            - absent
        detail:
          type: string
          description: Present when status is `fail` or `absent`.
    PassportRegistryView:
      type: object
      description: 'EU-registry state for one passport. `configured: false` means this deployment has no registry queues at all — reported instead of a row of zeros, which would read as "everything is registered".'
      required:
        - passportId
        - configured
      properties:
        passportId:
          type: string
        configured:
          type: boolean
        registration:
          allOf:
            - $ref: '#/components/schemas/RegistrationView'
          description: 'Absent when the passport has never been published: it owes no registration, which is different from owing one that has not happened.'
        transfers:
          type: array
          description: Handover notifications recorded for this passport, newest first.
          items:
            $ref: '#/components/schemas/TransferNotificationView'
        currentOperator:
          allOf:
            - $ref: '#/components/schemas/CurrentOperatorView'
          description: Absent when the passport has never been transferred, in which case its own `operatorIdentifier` is current.
    RegistryRollupView:
      type: object
      description: 'Operator-wide EU-registry state. `configured: false` means this deployment has no registry queues; the counts are then omitted rather than reported as zero.'
      required:
        - configured
        - verification
      properties:
        configured:
          type: boolean
        verification:
          $ref: '#/components/schemas/RegistryVerificationView'
        registrations:
          $ref: '#/components/schemas/RegistrationCounts'
        transfers:
          $ref: '#/components/schemas/TransferNotificationCounts'
    RegistrationView:
      type: object
      description: One passport's registration, as the EU-registry queue holds it.
      required:
        - status
        - attempts
        - stalled
      properties:
        status:
          type: string
          enum:
            - pending
            - submitted
            - registered
            - rejected
            - deactivated
        registryId:
          type: string
          description: The registry's own record id, once it has issued one.
        message:
          type: string
          description: The last thing the registry (or the drain) said about it.
        attempts:
          type: integer
        stalled:
          type: boolean
          description: True once `attempts` reaches the drain threshold — the row is not going to succeed without someone looking at it.
        statusIntent:
          type: string
          description: 'A status change owed to the registry, independent of the queue state. Nothing drains these: the registry publishes no status-push API, so they are held durably and reported rather than accumulating out of sight.'
    RegistrationCounts:
      type: object
      required:
        - pending
        - submitted
        - registered
        - rejected
        - deactivated
        - statusIntents
        - stalled
        - unregisteredPublished
      properties:
        pending:
          type: integer
        submitted:
          type: integer
        registered:
          type: integer
        rejected:
          type: integer
        deactivated:
          type: integer
        statusIntents:
          type: integer
          description: Status changes owed to the registry that nothing drains.
        stalled:
          type: integer
          description: Rows that have retried past the point of self-recovery.
        unregisteredPublished:
          type: integer
          description: 'Published passports with **no** outbox row at all — they owe a registration nobody is tracking (published before the outbox existed, or lost to an older write path). Reported, not repaired: the queued payload is what a drain replays and there is none to rebuild, so fabricating a row would create an entry that can never drain.'
    TransferNotificationView:
      type: object
      description: One transfer-of-responsibility notification owed to the registry.
      required:
        - transferId
        - status
        - attempts
        - stalled
      properties:
        transferId:
          type: string
          format: uuid
        status:
          type: string
          enum:
            - pending
            - notified
            - rejected
        registryId:
          type: string
        message:
          type: string
        attempts:
          type: integer
        stalled:
          type: boolean
    TransferNotificationCounts:
      type: object
      required:
        - pending
        - notified
        - rejected
        - stalled
      properties:
        pending:
          type: integer
        notified:
          type: integer
        rejected:
          type: integer
        stalled:
          type: integer
    RegistryVerificationView:
      type: object
      description: The operator's verified-registry standing. Verified status ends when the electronic identification means used expire, and at the latest three years after verification; an operator that lets it lapse cannot register or amend anything until it verifies again. Reported whether or not the queues are configured.
      required:
        - current
      properties:
        current:
          type: boolean
          description: False both when never verified and when lapsed — the registry refuses either way, though they are different situations to act on.
        verifiedAt:
          type: string
          format: date-time
          description: Absent when never verified.
        expiresAt:
          type: string
          format: date-time
          description: The three-year cap. The eID means may expire sooner, which this cannot see, so it is an upper bound rather than a promise.
        daysRemaining:
          type: integer
          description: Negative once lapsed. Absent when never verified.
    CurrentOperatorView:
      type: object
      description: The operator responsible for a passport **today**, derived from its transfer chain. Reported separately from the passport's own `operatorIdentifier`, which is the operator that *published* it — frozen at publish and covered by the signature, so a transfer does not rewrite it. For a passport that has changed hands the two differ, and that difference is a fact about the product.
      required:
        - did
        - name
        - country
        - transferCount
      properties:
        did:
          type: string
        name:
          type: string
        country:
          type: string
        transferCount:
          type: integer
          description: Completed handovers this passport has been through.
    PassportScanStats:
      type: object
      description: Per-passport scan aggregates over a trailing window. `totalScans` and `qrRenders` sit side by side and are never combined — a QR render is label production, not a resolution. Nothing about the scanner (IP, agent, session) is collected or returned; the counters carry no such fields.
      required:
        - windowDays
        - totalScans
        - scansHtml
        - scansJson
        - daily
        - qrRenders
        - ingesting
      properties:
        windowDays:
          type: integer
        totalScans:
          type: integer
        scansHtml:
          type: integer
        scansJson:
          type: integer
        daily:
          type: array
          description: Per-day scan totals, oldest first.
          items:
            $ref: '#/components/schemas/DailyScanCount'
        qrRenders:
          type: integer
        ingesting:
          type: boolean
          description: |
            Whether scan telemetry is actually reaching this node.

            `false` alongside `totalScans: 0` means the number is **unmeasured**, not
            zero — and that is the shipped default, since `SCAN_INGEST_URL` is unset
            unless an operator configures it.

            The node cannot answer this from its own configuration: `SCAN_INGEST_URL`
            belongs to the resolver, a separate deployable. What it reports is whether
            a resolver has flushed to it, which the resolver makes possible by sending
            an empty batch each interval even with nothing to count.

            It goes false again when a resolver stops reporting: the resolver declares
            its own flush cadence in every batch, and telemetry is called stale after
            three of those intervals — enough to absorb the dropped heartbeats the
            sender never retries, since holding an empty batch would starve its
            counter. A resolver too old to declare a cadence leaves no window to be
            outside of, and this then reports only that one ever flushed; `lastIngestAt`
            is on the wire either way, so a caller who knows their own deployment can
            judge for themselves.

            Resets on restart — this is liveness, not history — so a freshly booted
            node reports `false` until the next flush, at most one interval.
        lastIngestAt:
          type: string
          format: date-time
          description: When a resolver last flushed to this node. Absent if none has.
    OperatorScanStats:
      type: object
      description: Operator-wide scan rollup over a trailing window.
      required:
        - windowDays
        - totalScans
        - totalQrRenders
        - distinctPassportsScanned
        - ingesting
      properties:
        windowDays:
          type: integer
        totalScans:
          type: integer
        totalQrRenders:
          type: integer
        distinctPassportsScanned:
          type: integer
        ingesting:
          type: boolean
          description: |
            Whether scan telemetry is actually reaching this node.

            `false` alongside `totalScans: 0` means the number is **unmeasured**, not
            zero — and that is the shipped default, since `SCAN_INGEST_URL` is unset
            unless an operator configures it.

            The node cannot answer this from its own configuration: `SCAN_INGEST_URL`
            belongs to the resolver, a separate deployable. What it reports is whether
            a resolver has flushed to it, which the resolver makes possible by sending
            an empty batch each interval even with nothing to count.

            It goes false again when a resolver stops reporting: the resolver declares
            its own flush cadence in every batch, and telemetry is called stale after
            three of those intervals — enough to absorb the dropped heartbeats the
            sender never retries, since holding an empty batch would starve its
            counter. A resolver too old to declare a cadence leaves no window to be
            outside of, and this then reports only that one ever flushed; `lastIngestAt`
            is on the wire either way, so a caller who knows their own deployment can
            judge for themselves.

            Resets on restart — this is liveness, not history — so a freshly booted
            node reports `false` until the next flush, at most one interval.
        lastIngestAt:
          type: string
          format: date-time
          description: When a resolver last flushed to this node. Absent if none has.
    DailyScanCount:
      type: object
      required:
        - day
        - count
      properties:
        day:
          type: string
          format: date
        count:
          type: integer
    ScanBatch:
      type: object
      required:
        - scans
        - qrRenders
      description: The full flush payload the resolver sends to the vault.
      properties:
        scans:
          type: array
          items:
            $ref: '#/components/schemas/ScanBatchEntry'
        qrRenders:
          type: array
          items:
            $ref: '#/components/schemas/QrRenderBatchEntry'
        flushIntervalSecs:
          type: integer
          format: int64
          minimum: 1
          description: |
            How often the sending resolver flushes, in seconds.

            Declared rather than assumed. The node reports `ingesting` on
            `GET /stats` by asking whether the last flush is recent, and "recent" is
            only meaningful relative to how often the sender promised to call —
            `SCAN_FLUSH_INTERVAL_SECS` belongs to the resolver, a separate deployable
            whose environment the node cannot read.

            Stamped when the window is drained, not when it is sent, so a batch held
            after a failed flush is re-sent byte-identical under its original
            `Idempotency-Key`.

            Optional: a resolver predating this field omits it, and the node then
            reports whether telemetry has *ever* arrived rather than applying a
            threshold to a cadence it does not know.
          example: 300
    ScanBatchEntry:
      type: object
      required:
        - dppId
        - day
        - variant
        - count
      description: One aggregated scan increment since the resolver's last flush.
      properties:
        dppId:
          type: string
          description: The resolved passport id, as an opaque string (validated at ingest).
        day:
          type: string
          format: date
        variant:
          $ref: '#/components/schemas/ScanVariant'
        count:
          type: integer
          minimum: 0
    QrRenderBatchEntry:
      type: object
      required:
        - dppId
        - day
        - count
      description: One aggregated QR-render increment since the resolver's last flush.
      properties:
        dppId:
          type: string
        day:
          type: string
          format: date
        count:
          type: integer
          minimum: 0
    ScanVariant:
      type: string
      enum:
        - html
        - json
    OperatorConfig:
      type: object
      required:
        - operatorId
        - legalName
        - address
        - country
        - contactEmail
      properties:
        operatorId:
          type: string
          example: self_hosted
        legalName:
          type: string
          example: Odal Node GmbH
        tradeName:
          type:
            - string
            - 'null'
        address:
          type: string
          example: Johannes Strauss 12
        country:
          type: string
          minLength: 2
          maxLength: 2
          example: DE
        contactEmail:
          type: string
          format: email
          example: contact@odal-node.io
        didWebUrl:
          type:
            - string
            - 'null'
          format: uri
        productCategories:
          type:
            - array
            - 'null'
          items:
            type: string
        brandPrimary:
          type:
            - string
            - 'null'
          description: Primary brand colour (hex)
          example: '#2E7D32'
        brandSecondary:
          type:
            - string
            - 'null'
        brandLogoUrl:
          type:
            - string
            - 'null'
          format: uri
        customDomain:
          type:
            - string
            - 'null'
        dataResidency:
          type: string
          default: EU
        retentionPolicyDays:
          type: integer
          default: 3650
        featureFlags:
          type:
            - object
            - 'null'
        createdAt:
          type:
            - string
            - 'null'
          format: date-time
        updatedAt:
          type:
            - string
            - 'null'
          format: date-time
        registryVerifiedAt:
          type:
            - string
            - 'null'
          format: date-time
          description: When this operator's identity was last verified against the EU registry. Null when no verification has succeeded — never treat absence as verified.
    UpdateOperatorConfig:
      type: object
      description: Merge-patch update — only supply fields being changed.
      properties:
        legalName:
          type: string
        tradeName:
          type: string
        address:
          type: string
        country:
          type: string
        contactEmail:
          type: string
        didWebUrl:
          type: string
        productCategories:
          type: array
          items:
            type: string
        brandPrimary:
          type: string
        brandSecondary:
          type: string
        brandLogoUrl:
          type: string
        customDomain:
          type: string
        dataResidency:
          type: string
        retentionPolicyDays:
          type: integer
        featureFlags:
          type: object
        registryVerifiedAt:
          type:
            - string
            - 'null'
          format: date-time
          description: When this operator's identity was last verified against the EU registry. Null when no verification has succeeded — never treat absence as verified.
    Facility:
      type: object
      required:
        - id
        - name
        - identifierScheme
        - identifierValue
        - country
        - isDefault
        - createdAt
      description: A manufacturing/processing facility (ESPR Annex III).
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          example: München Cell Plant
        identifierScheme:
          type: string
          description: Identifier scheme, e.g. "gln" or "national".
          example: gln
        identifierValue:
          type: string
          example: '4012345000009'
        country:
          type: string
          minLength: 2
          maxLength: 2
          example: DE
        address:
          type:
            - string
            - 'null'
        isDefault:
          type: boolean
          description: The default facility is stamped onto new passports.
        createdAt:
          type: string
          format: date-time
    CreateFacilityRequest:
      type: object
      required:
        - name
        - identifierScheme
        - identifierValue
        - country
      properties:
        name:
          type: string
        identifierScheme:
          type: string
          example: gln
        identifierValue:
          type: string
          description: Validated by scheme — a "gln" must pass the GS1 mod-10 check digit.
          example: '4012345000009'
        country:
          type: string
          pattern: ^[A-Z]{2}$
          description: |
            ISO 3166-1 alpha-2 country the facility is located in.

            **Upper case, and an assigned code.** Unlike the other country fields on
            this API, this one is checked against the ISO 3166-1 list rather than
            merely shaped — `de` and `XX` are both refused. The pattern is what makes
            the published contract as strict as the handler: a length bound alone
            admits `1?`, which satisfies the contract and is then refused with `422`,
            and a contract looser than the implementation is the kind a generated
            client trusts.
          example: DE
        address:
          type:
            - string
            - 'null'
        isDefault:
          type: boolean
          default: false
          description: Make this the default facility on creation (unsets any previous default).
    OperatorIdentifier:
      type: object
      required:
        - id
        - scheme
        - value
        - isPrimary
        - createdAt
      description: An economic-operator identifier (ESPR Art. 13).
      properties:
        id:
          type: string
          format: uuid
        scheme:
          type: string
          description: Identifier scheme, e.g. "vat", "lei", "eori", "duns".
          example: lei
        value:
          type: string
          example: 5493001KJTIIGC8Y1R12
        label:
          type:
            - string
            - 'null'
        isPrimary:
          type: boolean
          description: The primary identifier is stamped onto new passports.
        createdAt:
          type: string
          format: date-time
    CreateOperatorIdentifierRequest:
      type: object
      required:
        - scheme
        - value
      properties:
        scheme:
          type: string
          description: Identifier scheme, e.g. "vat", "lei", "eori", "duns".
          example: lei
        value:
          type: string
          description: Validated by scheme — LEI uses ISO 7064 MOD 97-10; DUNS is 9 digits; EORI/VAT require a 2-letter country prefix. Unknown schemes are accepted without structural verification.
          example: 5493001KJTIIGC8Y1R12
        label:
          type:
            - string
            - 'null'
        isPrimary:
          type: boolean
          default: false
          description: Make this the primary identifier on creation (unsets any previous primary).
    RegistryIdentityAuditEntry:
      type: object
      required:
        - id
        - operatorId
        - entityType
        - entityId
        - action
        - actor
        - ts
      description: An immutable audit record for a registry-identity mutation (a facility per Annex III or an operator identifier per Art. 13). Append-only.
      properties:
        id:
          type: string
          format: uuid
        operatorId:
          type: string
        entityType:
          type: string
          enum:
            - facility
            - operator_identifier
        entityId:
          type: string
          format: uuid
        action:
          type: string
          enum:
            - added
            - retired
            - set_default
            - set_primary
        actor:
          type: string
          description: user_id of the actor who performed the change.
        snapshot:
          type:
            - object
            - 'null'
          description: The full record at the time of the action, for reconstruction.
        ts:
          type: string
          format: date-time
    ApiKey:
      type: object
      required:
        - id
        - name
        - keyPrefix
        - isActive
        - scope
        - createdAt
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          example: CI pipeline
        keyPrefix:
          type: string
          description: First 12 characters of the key (e.g. 'odal_sk_abc1')
          example: odal_sk_abc1
        isActive:
          type: boolean
        createdAt:
          type: string
          format: date-time
        lastUsedAt:
          type:
            - string
            - 'null'
          format: date-time
        expiresAt:
          type:
            - string
            - 'null'
          format: date-time
        scope:
          $ref: '#/components/schemas/ApiKeyScope'
    ApiKeyScope:
      type: string
      description: What an API key authorises. `admin` is the default when a key is minted without an explicit scope, so a key issued to an integration should name `read` or `write` deliberately.
      enum:
        - read
        - write
        - admin
    CreateApiKeyRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
          description: Human-readable label for this key.
          example: CI pipeline
        expiresAt:
          type:
            - string
            - 'null'
          format: date-time
          description: Optional expiration. Null = never expires.
        scope:
          allOf:
            - $ref: '#/components/schemas/ApiKeyScope'
          description: Defaults to `admin` when omitted. Name `read` or `write` explicitly for a key issued to an integration.
    CredentialRole:
      description: |
        The access role granted.

        Only roles carrying a **legitimate interest** may be issued here.
        `market_surveillance_authority`, `customs_authority` and `notified_body` map
        to the authority audience under Art. 77(2)(b) and are refused with `422`:
        authority status is conferred by a member state, so an operator signing itself
        one has attested nothing. An authority presents a credential from its own
        issuer, named in `CREDENTIAL_ISSUERS_AUTHORITY`.

        A product-group-specific role that none of the named ones fit uses the object
        form — the variant is externally tagged, so it is an object where the rest are
        strings. It carries a legitimate interest like the others.
      oneOf:
        - type: string
          title: Named role
          enum:
            - authorised_repairer
            - recycler
            - remanufacturer
            - preparer_for_reuse
            - distributor
        - type: object
          title: Custom role
          required:
            - custom
          additionalProperties: false
          properties:
            custom:
              type: string
              description: Free-form role label, e.g. a product-group-specific one.
      example: authorised_repairer
    IssueCredentialRequest:
      type: object
      description: |
        Ask this node to vouch for a holder.

        The node signs with its own key, so the credential says "this operator
        attests that this party holds this role". That is a claim an operator is
        uniquely placed to make about its own authorised network, and one it has no
        standing to make about authority status — see `role`.
      required:
        - holderDid
        - holderName
        - role
        - country
      properties:
        holderDid:
          type: string
          description: |
            DID of the party being vouched for. A verifier matches it exactly, so a
            name or a URL here produces a credential nothing can present.
          example: did:web:repairs.example
        holderName:
          type: string
          description: Legal name of the holder, carried in the credential for an auditor.
          example: Nord Repair GmbH
        role:
          $ref: '#/components/schemas/CredentialRole'
        country:
          type: string
          pattern: ^[A-Za-z]{2}$
          description: |
            ISO 3166-1 alpha-2 country of the holder's registration.

            Either case is accepted and the credential carries the upper-cased form,
            which is what a verifier compares against. Same reason for the pattern as
            on `countryOfDisposal`: a length bound alone admits `1?`.
          example: DE
        productGroups:
          type: array
          items:
            type: string
          description: |
            Product groups the credential covers. Omit or leave empty to cover every
            product group this operator publishes.
          example:
            - battery
        validForDays:
          type: integer
          minimum: 1
          maximum: 90
          default: 30
          description: |
            Lifetime in days.

            Nothing can withdraw a credential once issued — this node fetches W3C
            status lists but publishes none, so a credential it mints carries no
            `credentialStatus` — which makes the expiry the only control there is.
            That is why the ceiling is 90 rather than open-ended, and why the default
            is shorter still. Re-issuing is cheap.
          example: 30
    IssuedCredential:
      type: object
      description: |
        The minted credential, in both the forms a caller needs: the wire value the
        holder presents, and the same claims readable without decoding it.
      required:
        - credentialJws
        - credential
      properties:
        credentialJws:
          type: string
          description: |
            Compact VC-JWT — the value the holder sends as the `X-DPP-Credential`
            header. This is the credential; `credential` below is the same claims in
            readable form, not a second artefact.
        credential:
          type: object
          description: |
            The credential document, so a caller can show the holder what it says
            without decoding the JWS. Shape follows W3C VC Data Model v2.0.
          additionalProperties: true
    CreateUnsoldGoodsEntry:
      type: object
      description: |
        One line of an ESPR Art. 24 disclosure: a category of unsold consumer products
        discarded in a financial year, with the reason and where it went.

        A disclosure is many of these, not one row. Art. 24(1) asks for the figures
        "differentiated per type or category of products", and separately per reason
        and destination — which a single aggregate cannot express.

        The operator is **not** part of the request. The report is about this node's
        own operator, taken from its operator config; there is no second operator on a
        single-tenant node for it to legitimately be.
      required:
        - reportingPeriod
        - unitCount
        - volumeKg
        - productCategory
        - reason
        - destination
        - countryOfDisposal
      properties:
        reportingPeriod:
          type: string
          pattern: ^[0-9]{4}$
          description: |
            The financial year the goods were discarded in. Art. 24(1) discloses "the
            preceding financial year", annually, so this is a year and not a month or
            a quarter.
          example: '2026'
        unitCount:
          type: integer
          format: int64
          minimum: 0
          description: |
            How many products. Art. 24(1)(a) asks for the number **and** the weight;
            both are required here because a disclosure carrying only one cannot
            satisfy it.
          example: 1240
        volumeKg:
          type: number
          format: double
          minimum: 0
          description: Their total weight in kilograms.
          example: 860.5
        productCategory:
          $ref: '#/components/schemas/UnsoldProductCategory'
        reason:
          $ref: '#/components/schemas/UnsoldDiscardReason'
        destination:
          $ref: '#/components/schemas/UnsoldDestination'
        destructionJustification:
          type: string
          description: |
            Which Art. 25 exemption the destruction relies on.

            **Required** when `destination` is `exemptDestruction`, and **refused**
            otherwise. ESPR Art. 25 prohibits destroying unsold consumer products
            listed in Annex VII from 19 July 2026, so a recorded destruction has to say
            why it was permitted; a justification attached to a donation describes
            nothing.
          example: Contaminated stock unfit for use under Art. 25(5)
        countryOfDisposal:
          type: string
          pattern: ^[A-Za-z]{2}$
          description: |
            ISO 3166-1 alpha-2 country the goods were disposed of in.

            Either case is accepted and the stored value is upper-cased. The pattern
            is what makes the description enforceable: a length bound alone admits
            `1?`, which satisfies the published contract and is then refused by the
            handler — a contract looser than the implementation is the kind a
            generated client trusts.
          example: DE
    UnsoldGoodsEntry:
      type: object
      description: A stored ESPR Art. 24 disclosure line.
      required:
        - id
        - reportingPeriod
        - volumeKg
        - productCategory
        - reason
        - destination
        - countryOfDisposal
        - createdAt
      properties:
        id:
          type: string
          format: uuid
        reportingPeriod:
          type: string
          example: '2026'
        unitCount:
          type:
            - integer
            - 'null'
          format: int64
          description: |
            Null only for a row written before the count had a column — the write path
            requires it, so anything recorded through this API carries one.
        volumeKg:
          type: number
          format: double
        productCategory:
          $ref: '#/components/schemas/UnsoldProductCategory'
        reason:
          $ref: '#/components/schemas/UnsoldDiscardReason'
        destination:
          $ref: '#/components/schemas/UnsoldDestination'
        destructionJustification:
          type: string
          description: Present only for `exemptDestruction`.
        countryOfDisposal:
          type: string
          pattern: ^[A-Z]{2}$
          description: ISO 3166-1 alpha-2 country the goods were disposed of in.
        operatorName:
          type:
            - string
            - 'null'
          description: |
            The operator the disclosure is about — this node's own legal name at the
            time the line was recorded. Report content, not a tenant key.
        createdAt:
          type: string
          format: date-time
    UnsoldProductCategory:
      type: string
      description: |
        The operator's own categorisation of the discarded goods, for Art. 24(1)(a)'s
        "differentiated per type or category of products" — which prescribes no
        vocabulary.

        This is **not** Annex VII ban scope. Annex VII gives two *code-valued*
        headings — apparel and clothing accessories (`4203`, `61`, `62`, `6504`,
        `6505`) and footwear (`6401`–`6405`) — matched by CN **prefix**, with clothing
        accessories inside the first heading rather than beside it. Reading this list
        as those headings gets both their count and their shape wrong.
      enum:
        - apparel
        - footwear
        - homeTextile
        - accessories
        - other
      example: apparel
    UnsoldDiscardReason:
      type: string
      description: Why the goods went unsold, for Art. 24(1)(b).
      enum:
        - endOfSeason
        - qualityDefect
        - packagingDefect
        - overProduction
        - customerReturn
        - other
      example: endOfSeason
    UnsoldDestination:
      type: string
      description: |
        Where the goods went, for Art. 24(1)(c).

        `exemptDestruction` is the only value recording an act that is otherwise
        prohibited, and the only one that requires `destructionJustification`.
      enum:
        - donation
        - recycling
        - repurposing
        - supplierReturn
        - exemptDestruction
      example: recycling
    CreatedApiKeyResponse:
      type: object
      required:
        - key
        - secret
      properties:
        key:
          $ref: '#/components/schemas/ApiKey'
        secret:
          type: string
          description: |
            The full plain-text API key. Shown ONCE at creation time.
            Store securely — it cannot be retrieved again.
          example: odal_sk_abc123def456ghi789jkl012mno345pqr678
    WebhookSubscription:
      type: object
      description: A receiver subscription, redacted. The signing secret is never carried here — it is returned exactly once from the create call and otherwise stays server-side.
      required:
        - id
        - url
        - events
        - active
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          format: uri
          description: Receiver URL — validated `https`, non-private host at creation.
        events:
          type: array
          description: Subject filter — event type strings, or a single `*` for all events.
          items:
            type: string
        active:
          type: boolean
          description: Removal is a soft `active = false`, never a hard delete.
        description:
          type:
            - string
            - 'null'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    CreateWebhookRequest:
      type: object
      description: Input for creating a subscription. The signing secret is generated server-side and is never client-supplied, so it is not part of this body.
      required:
        - url
        - events
      properties:
        url:
          type: string
          format: uri
          description: Receiver URL. SSRF-validated before it is persisted.
        events:
          type: array
          items:
            type: string
        description:
          type: string
    CreatedWebhookResponse:
      description: The created subscription's fields, inlined, plus the signing secret. The secret is shown **once** — it is not recoverable from any later read.
      allOf:
        - $ref: '#/components/schemas/WebhookSubscription'
        - type: object
          required:
            - secret
          properties:
            secret:
              type: string
              description: Signing secret. Store it now; it is never shown again.
    InstalledPlugin:
      type: object
      description: What the node installed, reported back after a successful hot-swap.
      required:
        - productGroup
        - abiVersion
      properties:
        productGroup:
          type: string
          description: Product group catalog key the plugin is bound to.
          example: battery
        abiVersion:
          type: string
          description: ABI version the plugin declared, formatted `major.minor`.
          example: '1.0'
    NodeState:
      type: object
      required:
        - bootstrapped
        - operatorComplete
      properties:
        bootstrapped:
          type: boolean
          description: True once at least one active API key exists (the node is claimed).
        operatorComplete:
          type: boolean
          description: True once the operator identity is complete enough to publish.
        profile:
          $ref: '#/components/schemas/NodeProfile'
          description: |
            The node's deployment profile. Absent on a standalone vault, which has no
            composition root to resolve one.
        trustMode:
          type: object
          additionalProperties:
            type: string
            enum:
              - ghost
              - sandbox
              - live
          description: |
            Each resolved trust port and the tier it operates at — `ghost` is a
            placeholder with no real authority behind it, `sandbox` a real but
            non-production service, `live` a production one.

            This is the ghost-honesty signal: no surface may present a placeholder as
            real. It is reported here rather than on the unauthenticated `/health`
            because which ports are degraded, and how, is a targeting signal — the
            same reasoning that keeps `/metrics` off the public router.

            Absent on a standalone vault, which resolves no trust ports.
          example:
            seal: ghost
            registry_sync: sandbox
        rulesetVersion:
          type: string
          description: |
            Version of the Compliance Current ruleset this node validates against, so
            the rules a passport was checked under are observable. Absent on a
            standalone vault.
          example: baseline
    NodeProfile:
      type: string
      description: 'The deployment profile a node was started under (`NODE_PROFILE`), which decides the trust tiers it will boot on. `development` boots on any tier, placeholders included, and is what an unset or unrecognised value means. `sandbox` is a real deployment against test authorities: it refuses a placeholder on a required trust port and accepts a sandbox tier. `production` accepts only live tiers on required ports.'
      enum:
        - development
        - sandbox
        - production
    WhoamiResponse:
      type: object
      description: What the presented credential is. Reports only what the caller already sent — it reveals nothing about any other key, and the key's secret is never stored in a recoverable form.
      required:
        - userId
        - scope
      properties:
        userId:
          type: string
          description: The caller's identity, as authenticated.
        scope:
          $ref: '#/components/schemas/ApiKeyScope'
        keyId:
          type: string
          format: uuid
          description: The key's row id — never the token. Absent for local-admin Basic auth, which has no key row.
    VaultInfo:
      type: object
      required:
        - version
        - coreVersion
        - authMethods
        - features
      description: Vault build/version metadata, for dashboard feature detection.
      properties:
        version:
          type: string
          description: This node's own dpp-vault crate version.
          example: 0.11.0
        coreVersion:
          type: string
          description: The dpp-domain (dpp-core) version this build was compiled against.
          example: 0.16.0
        authMethods:
          type: array
          items:
            type: string
          description: Auth schemes the vault accepts. Currently a fixed list, not derived from live config — `local` is listed even when `ADMIN_USERNAME`/`ADMIN_PASSWORD` are unset.
          example:
            - api_key
            - local
        features:
          type: array
          items:
            type: string
          example:
            - passthrough_compliance
    DidDocument:
      type: object
      description: |
        A W3C DID Core `did:web` document. Lists the operator's Ed25519
        verification methods; rotated keys are retained (so historical
        signatures still verify) and revoked keys are omitted.
      example:
        '@context':
          - https://www.w3.org/ns/did/v1
          - https://w3id.org/security/suites/ed25519-2020/v1
        id: did:web:id.odal-node.io
        verificationMethod:
          - id: did:web:id.odal-node.io#key-1
            type: JsonWebKey2020
            controller: did:web:id.odal-node.io
            publicKeyJwk:
              kty: OKP
              crv: Ed25519
              x: <base64url>
        authentication:
          - did:web:id.odal-node.io#key-1
        assertionMethod:
          - did:web:id.odal-node.io#key-1
    InternalSignRequest:
      type: object
      required:
        - operator_id
        - passport_id
        - payload
      description: Internal signing request. Field names are snake_case (internal contract).
      properties:
        operator_id:
          type: string
          description: Operator whose key signs. Auto-provisioned on first use.
          example: self_hosted
        passport_id:
          type: string
          description: The passport id being signed (recorded in the JWS payload).
        payload:
          type: string
          description: Base64-encoded canonical JSON of the payload to sign.
    InternalSignResponse:
      type: object
      required:
        - jws_signature
      properties:
        jws_signature:
          type: string
          description: Compact JWS (EdDSA over RFC 8785 canonical bytes).
    InternalVerifyRequest:
      type: object
      required:
        - operator_id
        - jws
        - payload
      description: Internal verification request. Field names are snake_case (internal contract).
      properties:
        operator_id:
          type: string
          description: Operator id whose key the signature is checked against.
          example: self_hosted
        jws:
          type: string
          description: The compact JWS to verify.
        payload:
          description: The payload the caller expects the JWS to have been signed over.
    InternalVerifyResponse:
      type: object
      required:
        - valid
      properties:
        valid:
          type: boolean
          description: True iff the signature verifies against the named operator's key AND was signed over exactly this payload.
    InternalRotateKeyRequest:
      type: object
      required:
        - operator_id
      properties:
        operator_id:
          type: string
          example: self_hosted
    InternalRotateKeyResponse:
      type: object
      required:
        - operator_id
        - new_key_id
        - fingerprint
        - rotated
        - did_document
      properties:
        operator_id:
          type: string
        new_key_id:
          type: string
          example: did:web:id.odal-node.io#key-1
        fingerprint:
          type: string
          description: SHA-256 fingerprint (hex) of the new public key.
        rotated:
          type: boolean
        did_document:
          $ref: '#/components/schemas/DidDocument'
    ImportSyncResponse:
      type: object
      required:
        - jobId
        - totalRows
        - successCount
        - errorCount
        - created
        - updated
        - errors
      description: Returned for synchronous imports (≤ 100 valid rows) and dry runs.
      properties:
        jobId:
          type: string
          format: uuid
          description: The job this response's report was persisted under — retrievable later via the job-status endpoint.
        totalRows:
          type: integer
          description: Rows read from the uploaded file, excluding the header.
        successCount:
          type: integer
          description: 'Rows **not rejected**, counted once each. Deliberately not the size of `created` plus `updated`: a row whose record was already up to date, or which conflicted, writes nothing and is still not a failure. Always `0` for a dry run, which writes nothing at all.'
        errorCount:
          type: integer
          description: Rows **rejected**, counted once each. A row that fails several checks contributes one to this count and several entries to `errors`, so `errorCount` and `errors.length` differ and are not interchangeable.
        created:
          type: array
          items:
            $ref: '#/components/schemas/ImportCreatedEntry'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/ImportErrorEntry'
        updated:
          type: array
          description: Rows that matched an existing passport and updated it. Counted in `successCount`, which is deliberately not `created.length + updated.length` — see that field.
          items:
            $ref: '#/components/schemas/ImportUpdatedEntry'
    ImportAsyncResponse:
      type: object
      required:
        - jobId
        - status
        - totalRows
      description: Returned for async imports (> 100 valid rows). Poll the job-status endpoint.
      properties:
        jobId:
          type: string
          format: uuid
        status:
          type: string
          example: queued
        totalRows:
          type: integer
    ImportCreatedEntry:
      type: object
      required:
        - row
        - passportId
        - status
      properties:
        row:
          type: integer
          description: 1-based row number from the uploaded file.
        passportId:
          type: string
        status:
          type: string
          example: draft
    ImportUpdatedEntry:
      type: object
      description: One row that matched an existing passport and updated it.
      required:
        - row
        - passportId
      properties:
        row:
          type: integer
          description: 1-based row number in the uploaded file, excluding the header.
        passportId:
          type: string
          format: uuid
    ImportErrorEntry:
      type: object
      required:
        - row
        - field
        - message
      properties:
        row:
          type: integer
        field:
          type: string
          description: Column name, or "vault" / "auth" / "internal".
        message:
          type: string
    JobStatusResponse:
      type: object
      required:
        - jobId
        - status
        - progress
      properties:
        jobId:
          type: string
          format: uuid
        status:
          type: string
          enum:
            - queued
            - processing
            - completed
            - failed
        progress:
          $ref: '#/components/schemas/JobProgress'
        result:
          type:
            - object
            - 'null'
          description: Populated on completion (created/errors) or failure (reason).
        report:
          type:
            - object
            - 'null'
          description: The row-addressed findings report — populated for every job, dry-run or apply, independent of `result`.
    JobProgress:
      type: object
      description: How far through its rows an import job has got.
      properties:
        processed:
          type: integer
          minimum: 0
          description: Rows handled so far.
          example: 120
        total:
          type: integer
          minimum: 0
          description: Rows the job was created with.
          example: 500
      required:
        - processed
        - total
    ProductGroupObligationList:
      type: object
      description: Every product group this build models, with its passport obligation.
      properties:
        productGroups:
          type: array
          items:
            $ref: '#/components/schemas/ProductGroupObligation'
      required:
        - productGroups
    ProductGroupObligation:
      type: object
      description: |
        What this node knows about one product group's digital product passport
        obligation, drawn from the embedded instrument catalog.
      properties:
        productGroup:
          type: string
          description: The catalog key.
          example: toy
        title:
          type:
            - string
            - 'null'
          description: |
            Human-readable name from the product group catalog.

            `null` where an act reaches this key but no product group descriptor
            exists for it — a real case, and the one this endpoint is most needed for:
            no schema, plugin or template names such a group, so nothing else in the
            API can be asked about it. A `null` title is not an error and says nothing
            about the obligation, which is reported in full beside it.
          example: Toys
        passport:
          $ref: '#/components/schemas/PassportObligation'
          description: Whether a passport is required for this product group, and from when.
        determinable:
          type: boolean
          description: |
            Whether this build can make a binding compliance determination for this
            product group. False where the duty exists but the implementing acts that
            define its technical requirements are unpublished.
          example: false
        granularity:
          anyOf:
            - $ref: '#/components/schemas/Granularity'
            - type: 'null'
          description: |
            The level a passport is issued at for this product group, where a
            delegated act fixes one. `null` where none does — which is every product
            group today.

            Nullable here rather than omitted, unlike the same field on a passport:
            this endpoint answers a question about every product group it lists, and a
            missing key would read as "not asked" rather than "no act has fixed one".
        retention:
          anyOf:
            - $ref: '#/components/schemas/RetentionPeriod'
            - type: 'null'
          description: How long records must be kept, with the basis of that figure. `null` where no reaching act fixes one.
        instruments:
          type: array
          description: |
            The acts the catalog knows reach this product group, each with how far
            along the act itself is and whether it binds *this* group.

            **These statuses qualify `passport.required`.** That field folds across
            every act here, so it reports that an act imposes a passport — not that
            any act binds this group today. Where every entry is `provisional` or
            `watch`, a `required: true` is a reading of instruments that have not yet
            bound anything, and must not be presented as a determination.
          items:
            $ref: '#/components/schemas/ReachingInstrument'
      required:
        - productGroup
        - title
        - passport
        - determinable
        - granularity
        - retention
        - instruments
    PassportObligation:
      type: object
      description: |
        Whether a passport is required for a product group, and from when.

        The obligation is split from its date so `required` can be answered where
        nothing fixes a date — which is most of the catalog.
      properties:
        required:
          type: boolean
          description: |
            Whether an instrument recorded against this product group imposes a
            passport obligation. Says nothing about whether one can be produced today
            — see `determinable` on the enclosing object.

            **Read with `instruments[].bindingStatus`.** This is a fold across every
            reaching act, so `true` means an act imposes a passport, not that any act
            binds this group today.
          example: true
        from:
          anyOf:
            - $ref: '#/components/schemas/ObligationDate'
            - type: 'null'
          description: |
            The date the obligation applies from, or `null` where no instrument fixes
            one. An obligation with no date is not one starting today.
      required:
        - required
        - from
    ObligationDate:
      type: object
      description: |
        The date a passport obligation applies from, with the provenance of that date.

        One object rather than two loose fields so a date cannot be served without its
        basis. Most of the catalog is undated, and of the dates that exist some trace
        to an adopted text and some are a reading — a date presented bare turns a
        qualified reading into an unqualified claim.
      properties:
        date:
          type: string
          format: date
          description: ISO-8601 date the obligation applies from.
          example: '2030-08-01'
        basis:
          $ref: '#/components/schemas/DateBasis'
          description: Whether this date traces to an adopted text.
      required:
        - date
        - basis
    DateBasis:
      type: string
      enum:
        - sourced
        - assumed
      description: |
        Whether an obligation date traces to an adopted text (`sourced`) or is a
        reading that has not been confirmed against one (`assumed`).

        Always served with the date it qualifies. A date without its basis presents a
        qualified reading as an unqualified claim, which on a public endpoint from a
        compliance vendor is a materially different statement.

        Deliberately a separate schema from `RetentionBasis`, which it currently
        matches variant for variant. They are two enumerations in the code answering
        two questions, and collapsing them here would let one drift silently behind
        the other.
      example: sourced
    RetentionPeriod:
      type: object
      description: |
        How long records must be kept, with the provenance of that figure.

        The figure is the maximum across every act reaching the product group, because
        retention periods are floors and a record kept long enough for the longest
        satisfies them all. The basis is `sourced` only when every contributing figure
        is sourced — one assumption anywhere makes the compound figure an assumption,
        whichever act supplied the maximum.
      properties:
        years:
          type: integer
          minimum: 0
          description: Years the record must be kept.
          example: 10
        basis:
          $ref: '#/components/schemas/RetentionBasis'
          description: Whether this figure traces to an adopted text.
      required:
        - years
        - basis
    RetentionBasis:
      type: string
      enum:
        - sourced
        - assumed
      description: |
        Whether a retention period traces to an adopted text (`sourced`) or is a
        reading that has not been confirmed against one (`assumed`).

        See `DateBasis` for why these are two schemas rather than one.
      example: sourced
    ReachingInstrument:
      type: object
      description: |
        One act the catalog records as reaching a product group, with how far along
        the act itself is and whether it binds *this* group.

        Distinct from the entries recorded on a passport, which carry no status: a
        passport freezes its applicable set at issuance and re-deriving status later
        would misstate what governed the product when it was placed on the market.
        Here nothing is frozen — the question is what the catalog holds now — so the
        statuses are what let a caller tell an adopted duty from a reading.
      properties:
        instrument:
          type: string
          description: The instrument's catalog id.
          example: toy-safety-2025-2509
        instrumentStatus:
          $ref: '#/components/schemas/InstrumentStatus'
          description: Whether the act itself exists.
        bindingStatus:
          $ref: '#/components/schemas/RegulatoryStatus'
          description: Whether that act's obligations bind this product group. Independent of `instrumentStatus` in both directions.
        recorded:
          $ref: '#/components/schemas/RecordedBasis'
          description: Always `catalog` here — this reports what the embedded catalog holds, and an operator assertion is recorded on a passport, not against the catalog. Shared with the passport's own instrument entries so the two stay one shape.
      required:
        - instrument
        - recorded
        - instrumentStatus
        - bindingStatus
    InstrumentStatus:
      type: string
      enum:
        - adopted
        - proposed
        - anticipated
      description: |
        How far through the legislative process an act is — whether the **act
        exists**, not whether it binds anything.

        `adopted` — adopted and published in the Official Journal, citable by CELEX.

        `proposed` — formally proposed but not adopted. Its text can be read and cited
        as a *proposal*, never as law.

        `anticipated` — announced in a working plan or otherwise expected, with no
        text to read. Such an act has no CELEX, and nothing derived from it may be
        presented as sourced.

        Distinct from `RegulatoryStatus`, and the distinction is easy to lose: an
        adopted act binds nothing until its own dates arrive, and routinely binds one
        product group while another waits. The practical case is that ESPR has been
        `adopted` since 2024 while every product group under it is still `provisional`
        — both statements are true and neither implies the other.
      example: adopted
    RegulatoryStatus:
      type: string
      enum:
        - in_force
        - provisional
        - watch
      description: |
        Whether an act's obligations bind **a given product group** — a different
        question from whether the act exists, which is `InstrumentStatus`.

        `in_force` — the act creates binding obligations for this product group that
        can be determined now. A future applicability date is *not* this status.

        `provisional` — the act exists or is anticipated, but nothing is bindingly
        determinable for this product group yet. Any obligation reported alongside
        this status is a reading, not a determination.

        `watch` — tracked, but this act imposes nothing on this product group. A
        watching brief rather than a duty, and never determinable.

        **Read this before acting on `passport.required`.** The obligation is a fold
        across every act reaching the product group, so `required: true` says an act
        imposes a passport — not that the act binds this group today. Where every
        reaching act is `provisional` or `watch`, the requirement is a reading of
        instruments that have not yet bound anything.
      example: provisional
    CreateLifeStatus:
      type: string
      description: |-
        The subset of `LifeStatus` a passport may be **created** in — Regulation (EU) 2023/1542, Annex XIII point 4(c), less `waste`.

        A separate schema rather than a note on the full one, because a generated client must not be able to offer a value `POST /dpp` always refuses: `waste` is a transition of an existing record and is rejected here with `422`. The field's own description says why; the full vocabulary a passport can be *read* in is `LifeStatus`.
      enum:
        - original
        - repurposed
        - re-used
        - remanufactured
    PassportVersion:
      type: object
      description: |-
        One archived version of a passport: the record as it stood, and the moment it stopped standing.

        ✅ EN 18221:2026 clause 4.2 (archiving), one of the six standards cited by Commission Implementing Decision (EU) 2026/1736.

        🚨 **Not the `retired` lifecycle status**, which is a terminal state reached after the ESPR retention period and says the record has stopped changing. This is the standard's sense of the word — historical versions of a passport that is still live — and the two are unrelated.
      required:
        - id
        - passportId
        - doc
        - supersededAt
      properties:
        id:
          type: string
          format: uuid
          description: Row identifier, UUID v7, so versions sort by when they were taken.
        passportId:
          type: string
          format: uuid
          description: The passport this is a version of.
        doc:
          type: object
          description: |-
            The complete record as it stood at the time, not a diff.

            Whole rather than differential so that retrieval is a read rather than a replay: a chain of diffs makes every historical version depend on every one before it, and one corrupt link loses everything after it. The clause asks that the version be retrievable, and a stored version is retrievable where a derived one is computed and hoped for.
        supersededAt:
          type: string
          format: date-time
          description: |-
            When this version stopped being current — the instant the change that replaced it was applied.

            One timestamp rather than a validity range: the previous version's `supersededAt` is this one's start, and two fields that must agree are two fields that can disagree.
    PublishBlocker:
      type: object
      description: |
        One reason a publish would be refused, addressed to the field that causes it.

        Addressed rather than prose: the mandatory-content gate names every missing
        field at once, and a caller completing a draft wants to attach each message to
        the input it belongs to.
      required:
        - field
        - message
      properties:
        field:
          type: string
          description: Path of the offending field.
          example: /productGroupData/batteryModelId
        message:
          type: string
          description: Why this field blocks the publish.
    PassportScopeReport:
      type: object
      description: |
        Whether Reg. (EU) 2023/1542 Art. 77(1) requires a battery passport for this
        record at all.

        > "From 18 February 2027 each LMT battery, each industrial battery with a
        > capacity greater than 2 kWh and each electric vehicle battery placed on the
        > market or put into service shall have an electronic record ('battery
        > passport')."

        The Regulation defines five battery categories; this article reaches three.
        Portable and SLI batteries bear no passport obligation, so a passport for one
        is the operator's own artefact rather than a discharged duty.

        Reported **beside** the gates, never instead of them. The category content
        gate follows the same answer: a record the article does not reach
        (`notCovered`, `belowThreshold`, `notYetBinding`) is not held to it, and one
        it does or may reach (`required`, `capacityUnknown`) is.

        Two details the article turns on: the threshold is **energy** (kWh), not the
        ampere-hour "rated capacity" defined for the purposes of an annex; and unlike
        Arts. 7 and 8 it carries no `rechargeable` qualifier.
      required:
        - status
      properties:
        status:
          type: string
          enum:
            - required
            - notCovered
            - belowThreshold
            - capacityUnknown
            - notYetBinding
            - notApplicable
          description: |
            Deliberately not a boolean, and deliberately not collapsed into one
            "voluntary". "This category never owes one", "this unit is under the
            threshold", "we cannot tell yet" and "not yet" are four different sentences
            to put in front of an operator, and only some of them describe something
            they can change.

            `required` — Art. 77(1) reaches this record.

            `notCovered` — the article does not name this category. Portable and SLI
            batteries owe no passport, ever.

            `belowThreshold` — an industrial battery at or below 2 kWh. The category
            *is* in scope; this unit is under the threshold, which is a different thing
            to be told than that industrial batteries are outside the article.

            `capacityUnknown` — an industrial battery that declares no
            `ratedCapacityKwh`, so the threshold cannot be applied. **Not an
            exemption**: the obligation turns on a number this record does not carry,
            and the node treats it as in scope. Declaring a capacity of 2 kWh or less
            is what moves it to `belowThreshold`.

            `notYetBinding` — in scope by type and capacity, but placed on the market
            before 18 February 2027. A record stating no `placedOnMarketDate` at all is
            treated as inside the period instead, because an unstated date is not
            evidence of an earlier one.

            `notApplicable` — not a battery, so this article has nothing to say. Other
            instruments may still require a passport.
          example: required
        note:
          type: string
          description: |
            Present when the answer needs justifying — an industrial battery that
            declares no `ratedCapacityKwh`, or a record the article does not reach and
            the content gate therefore does not apply to.
    PublishReadiness:
      type: object
      description: |
        Whether the passport would clear the publish gates that can be answered
        without attempting the transition: the category **mandatory-content** gate and
        the product-group data/schema gates.

        Deliberately not the whole of publish. The registry-identity requirement is
        operator state rather than passport state, and the binding-compliance gate
        needs a determination this route does not run — so `ready: true` means "these
        gates pass", not "publish will certainly succeed".

        The mandatory-content half is the reason this exists. It is the gate that most
        often refuses a battery — 45 data points for an electric-vehicle one, 44 for
        an LMT battery, 37 for an industrial one — and it could not previously be
        previewed anywhere: it is reachable only from a stored passport, and the only
        dry-run the API offered takes an unsaved request body.
      required:
        - ready
        - blockers
        - passportScope
      properties:
        ready:
          type: boolean
          description: True when no blocker below applies.
        blockers:
          type: array
          description: Every blocking field, named individually. Empty when `ready`.
          items:
            $ref: '#/components/schemas/PublishBlocker'
        passportScope:
          $ref: '#/components/schemas/PassportScopeReport'
    ArchivalFreshness:
      type: object
      description: |-
        Whether a seal's archival protection is still live.

        `evidencedLevel` reports `baseline-lta` from the **presence** of the archival material, and is right to — the material is there. This reports whether it still means anything.

        An archival timestamp is what keeps a `B-LTA` seal verifiable after its signing certificate expires, which is the whole point for a retention-locked passport that outlives every certificate involved. **It expires too**: its own timestamping authority's certificate has a validity period, and ETSI's long-term profiles expect re-timestamping before that. Without this field a seal whose archival protection lapsed years ago reads exactly as it did the day it was bought.

        **A signal, not a verdict.** A seal nearing its renewal date still verifies, and that window is the only chance to renew without an outage. No threshold is applied — how much notice is enough is a policy question for the reader.
      required:
        - state
      properties:
        state:
          type: string
          enum:
            - notArchived
            - current
            - lapsed
            - unknown
          description: |-
            `notArchived`: no archival timestamp — a seal below `B-LTA`. Nothing to renew, which is a different thing from a renewal that has lapsed: such a seal was never promised long-term protection.

            `current`: the archival timestamp's authority certificate is still valid.

            `lapsed`: it has expired. The seal may still verify today; what is gone is the thing meant to keep it verifying once its signing certificate goes.

            `unknown`: an archival timestamp is present and could not be read. **Not `current`** — a token that cannot be checked is not a fresh one, and reporting it as current is how a staleness signal goes quiet at the moment it matters.
        expires:
          type: string
          format: date-time
          description: 'When the archival timestamp''s authority certificate expires — the date by which re-timestamping must have happened, read from before or after it depending on `state`. One value, so one name: `current` and `lapsed` are the same date seen from either side. Absent for `notArchived` and `unknown`.'
    SealBinding:
      type: object
      description: |-
        Whether the seal's **own bytes** say it covers this passport's current signature.

        A detached CAdES states what it covers in exactly one place — the `messageDigest` signed attribute, RFC 5652 §11.2 — and that attribute sits *inside* the signature. This reports what it says, after checking the signature over it. That ordering is the whole point: the attribute is plain DER and trivial to rewrite, but rewriting it breaks the signature, so a seal cannot be retargeted at another passport by editing what it claims to cover.

        Read it beside `coverage`, which answers the same question from this node's own outbox records. Those records survive a seal that will not parse and need no cryptography; this needs both and is evidence. **Where the two disagree, the disagreement is the finding** — the records and the bytes are describing different things, which neither source could have revealed alone.
      required:
        - result
      properties:
        result:
          type: string
          enum:
            - coversThisSignature
            - coversAnotherDigest
            - notIntact
            - unknown
          description: |-
            `coversThisSignature`: the seal names this exact signature and its attributes verify under the certificate it carries. The strongest statement this node can make without an external validator — whatever is true of the seal's *trust*, it is demonstrably a seal over this passport and not over anything else.

            `coversAnotherDigest`: the seal is intact and names a different digest. Ordinary after a re-publish, since the passport re-signed. It is also what a seal stored against the wrong passport looks like, and the two are indistinguishable from the bytes alone; what separates them is whether an outbox row says this digest was ever requested for this passport.

            `notIntact`: the signature over the seal's attributes does not verify, so nothing the seal says about what it covers can be relied on.

            `unknown`: the bytes could not be read, or carry no digest at all — a placeholder, an unparsed format, or a signature with no signed attributes. **Never** to be read as a mismatch.
        covered:
          type: string
          description: |-
            Hex SHA-256 the seal actually covers. Present only with `coversAnotherDigest`, so a caller holding it can find which version of the passport it belongs to.

            Deliberately absent from `notIntact`: the attribute naming it sits inside a signature that failed, so reporting it would hand the caller a number that nothing vouches for.
    SealValidationStatus:
      type: object
      description: |-
        `binding` restated in the vocabulary of ETSI EN 319 102-1 clause 5.1.3 — the procedures standard CIR (EU) 2025/1945 points at for validating a qualified electronic seal.

        A translation, not a second opinion: it is derived from `binding` and adds no checking of its own. It is here because these findings travel to readers whose validation tooling speaks this vocabulary, and because the translation makes explicit something our own field names let a reader assume.

        **`totalPassed` is not among the values, and cannot be.** That indication requires, among other conditions, that the constraints applicable to the signer's certificate have been positively validated — a certificate path built and validated to a trust anchor, under a policy.

        This node checks the certificate's validity window and whatever revocation material the seal carries (see `certificate`), and it checks one issuer signature against a Trusted List entry. It builds no path and applies no policy constraints. So a seal that demonstrably covers this signature reports `indeterminate`: nothing has failed, and not everything has been checked. Reading `coversThisSignature` as a validation pass is the misreading this object exists to prevent.
      required:
        - indication
      properties:
        indication:
          type: string
          enum:
            - totalFailed
            - indeterminate
          description: |-
            `totalFailed`: the signature is demonstrably not valid. The standard makes this **stable** — the same inputs always yield the same answer, and additional validation data cannot lift it to a pass; only additional proofs of existence can change a result at all. That stability is what makes a broken seal safe to spend a replacement on.

            `indeterminate`: the available information is insufficient to decide. Not a weaker `totalFailed` — under CIR (EU) 2025/1945 it is its own technical outcome, "neither an EU qualified electronic signature, nor an EU qualified electronic seal", to be reported as such rather than collapsed into either neighbour.
        subIndication:
          type:
            - string
            - 'null'
          enum:
            - sigCryptoFailure
            - hashFailure
            - null
          description: |-
            The table 6 value qualifying the indication, where one applies.

            `sigCryptoFailure`: the signature value could not be verified with the public key in the signing certificate — a seal this node reports as `notIntact`.

            `hashFailure`: a hash of signed data does not match. Reported for a seal that is intact over a *different* digest, because validating **this passport's current signature** against it fails on the hash. The seal remains a sound attestation of the signature it does cover, which is why the node counts it apart and refuses to repair it.

            `null` is the standard's own custom-diagnostic case: no table 6 value fits, so `binding` carries the diagnostic instead of stretching one that nearly does. The two `null` cases are different diagnostics — "the certificate was never checked" and "these bytes could not be read" — and `binding` keeps them apart.
    ValidityWindow:
      type: object
      description: |-
        A certificate's validity window, and where the sealing moment falls in it.

        The window travels with the verdict so a reader can check the arithmetic rather than take it on trust — and because the two dates are what an auditor compares against a Trusted List entry when asking the other half of Art. 32(1)(b).
      required:
        - notBefore
        - notAfter
        - standing
      properties:
        notBefore:
          type: string
          format: date-time
          description: The certificate's `notBefore`.
        notAfter:
          type: string
          format: date-time
          description: The certificate's `notAfter`.
        standing:
          type: string
          enum:
            - inside
            - expired
            - notYetValid
          description: Where the judged moment falls in the window. `expired` and `notYetValid` are findings only when that moment is attested — see `judgedAt`.
    JudgedTime:
      type: object
      description: |-
        The moment a certificate's standing was judged against, and whether anything proves it.

        Regulation (EU) No 910/2014 Art. 32(1)(b), reached for seals by Art. 40, asks whether the certificate was valid **at the time of signing**. The whole verdict therefore turns on which moment is used and what that moment is worth.
      required:
        - at
        - attested
      properties:
        at:
          type: string
          format: date-time
          description: The moment used.
        attested:
          type: boolean
          description: |-
            True when the moment came from a timestamp token inside the seal, whose signature and imprint were both checked. False means it is the node's own clock — the validation time, not the signing time.

            This separates a failure from an open question. ETSI EN 319 102-1 treats a time nothing proves as no time at all, which is why a verdict resting on an unattested moment is reported with a `NO_POE` sub-indication rather than as `totalFailed`. Without it, every seal whose certificate has since expired — eventually all of them — would read as invalid.
    RevocationStanding:
      type: object
      description: |-
        What the seal's own revocation material says about its signing certificate.

        **Read from the seal, never fetched.** A CRL distribution point is a URL inside a certificate an operator was handed, and following one would have this node issue requests to an address chosen by whoever produced the seal. ETSI EN 319 122-1 puts revocation values in `SignedData.crls` from `B-LT` upward for exactly this reason: a seal meant to be checkable years later carries what is needed to check it, offline.
      required:
        - status
      properties:
        status:
          type: string
          enum:
            - notRevoked
            - revoked
            - notAvailable
            - unusable
          description: |-
            `notRevoked` — a CRL from this certificate's issuer, whose own signature verifies under a certificate the seal carries, does not list it.

            `revoked` — that CRL lists it, and `at` says when. Whether this condemns the seal depends on `judgedAt`: a certificate revoked *after* a seal was made does not unmake it.

            `notAvailable` — the seal carries no revocation material for this certificate. Ordinary below `B-LT` and **not a defect**.

            `unusable` — material was present and could not be relied on: an unparseable list, or one whose signature does not verify. Distinct from `notAvailable` because something claiming to be authoritative failed, which is worth looking at. A CRL wearing the issuer's name but signed by another key lands here and never on `notRevoked` — an unsigned list could otherwise *clear* a revoked certificate.
        asOf:
          type: string
          format: date-time
          description: With `notRevoked`, the CRL's `thisUpdate` — the moment its statement is about. A list issued before the seal was made cannot rule out a later revocation, and this is here so a reader can see which question was actually answered.
        at:
          type: string
          format: date-time
          description: With `revoked`, when the issuer says it was revoked.
        reason:
          type: string
          description: With `unusable`, what failed.
    CertificateStanding:
      type: object
      description: |-
        **Was the seal's signing certificate valid when the seal was made?**

        The second limb of Regulation (EU) No 910/2014 Art. 32(1)(b), reached for seals by Art. 40. The first limb — whether a qualified trust service provider issued it — is a Trusted List question, and is answered in `qualification.issuer` on the same response rather than here. Read the two together: a certificate can be well within its window and issued by nobody any list names.

        Two findings and the moment they were judged against, because the moment is what decides them: a certificate outside its window is a failure when a timestamp proves the seal was made outside it, and merely unproven otherwise. Certificates expire, sealed passports outlive them by years, and an expired certificate today says nothing about a seal made while it was good.
      required:
        - validity
        - judgedAt
        - revocation
      properties:
        validity:
          $ref: '#/components/schemas/ValidityWindow'
        judgedAt:
          $ref: '#/components/schemas/JudgedTime'
        revocation:
          $ref: '#/components/schemas/RevocationStanding'
    CreationDevice:
      type: string
      enum:
        - declaresQualifiedDevice
        - noQualifiedDevice
        - notAQualifiedCertificate
      description: |-
        What the certificate declares about the device holding its private key — a declaration, never a verification.

        `declaresQualifiedDevice`: the certificate carries the Annex III(j) indication that Regulation (EU) No 910/2014 Art. 32(1)(f), reached for seals through Art. 40, requires.

        `noQualifiedDevice`: it carries qualified-certificate statements but not that one. Lawful, and enough for Art. 40a — validation of an *advanced* seal based on a qualified certificate, which omits the device leg — but not for the Art. 32/40 pair.

        `notAQualifiedCertificate`: it carries no qualified-certificate statements at all, so it is not presenting itself as a qualified certificate in the first place. A self-signed development certificate lands here.
    SealOrigin:
      type: object
      description: |-
        What a stored seal's own certificate says about who issued it — read out of the seal's bytes, never from this node's configuration. The two are different facts: configuration records what the operator intended, and a seal restored from a backup or made before a backend change was not produced by whatever is configured now.

        **Not a qualification verdict.** Establishing that a seal is qualified needs the issuer matched against an EU Trusted List *and* the issuer's signature over this certificate verified; neither is done to produce this. What it does answer completely, and without a network, is whether anybody issued the certificate at all.
      required:
        - subject
        - issuer
        - selfIssued
        - creationDevice
      properties:
        subject:
          type: string
          description: The certificate's subject distinguished name, RFC 4514.
        issuer:
          type: string
          description: The certificate's issuer distinguished name, RFC 4514.
        selfIssued:
          type: boolean
          description: 'Whether issuer and subject are the same name — the structural test for "nobody issued this to us", and a property of the certificate that no environment variable can change. True means the seal attests that a key this node holds signed a digest and **nothing else**: it carries no legal weight, and no Trusted List would give it any.'
        creationDevice:
          $ref: '#/components/schemas/CreationDevice'
    TrustServiceStatus:
      oneOf:
        - type: 'null'
        - type: string
          enum:
            - granted
            - withdrawn
        - type: object
          required:
            - other
          properties:
            other:
              type: string
              description: The trusted-list status URI, verbatim.
      description: |-
        The status a trusted list records for a service — ETSI TS 119 612 Annex D.5.

        🚨 **Three shapes, not two.** `granted` and `withdrawn` are the pair that answers whether a service holds qualified status. Every other status a list can carry — `undersupervision`, `recognisedatnationallevel`, and whatever a Member State publishes next — arrives as an **object**, `{"other": "<the status URI>"}`.

        They are kept apart deliberately. Folding them into the same string vocabulary as `granted` would invite the reading that `undersupervision` is a lesser kind of qualified, or `recognisedatnationallevel` a weaker `granted`. Neither is true: they answer a different question, asked of a different kind of service.

        A client that assumes a string here breaks on the first list entry it meets that is neither granted nor withdrawn. `granted` is the only value that means qualified.

        `null` where it appears on a verdict means the list's history does not reach back to the moment asked about — the list is *silent* about it rather than negative, which is a different finding from a recorded non-granted status.
    IssuerStanding:
      type: object
      description: |-
        What the EU Trusted Lists say about the certificate's issuer — Regulation (EU) No 910/2014 Art. 32(1)(a)–(b), reached for seals by Art. 40.

        **"At sealing" throughout, never "now."** A provider granted qualified status in 2029 was not qualified in 2027, and a present-tense check would certify a seal that never was; a provider withdrawn last week did not retroactively unmake the seals it issued. Art. 32(1)(b) asks about the time of signing, and trusted lists carry the history to answer it.

        Discriminated by `standing`. The shape of the rest depends on which one it is.
      required:
        - standing
      properties:
        standing:
          type: string
          enum:
            - selfIssued
            - notListed
            - chainIncomplete
            - signatureNotFromListedCa
            - pathUnverifiable
            - notQualifiedAtSealing
            - qualifiedAtSealing
          description: |-
            `selfIssued`: the certificate is its own issuer — nobody issued it to this node. What the local development backend produces. A seal here carries **no legal weight whatsoever**; it attests that a key this node holds signed a digest.

            `notListed`: some provider issued it, and no list consulted names that issuer as a qualified CA. 🚨 The only verdict claiming an **absence** — read `consulted` and `unchecked` before acting on it.

            `chainIncomplete`: the seal did not carry enough certificates to reach a root, and no list names any issuer it does refer to. **Weaker than `notListed` deliberately**: the walk ran out of links, so a listed CA may sit above the gap and reporting an unlisted provider would be an accusation drawn from a certificate nobody shipped.

            `signatureNotFromListedCa`: a listed CA carries this name and **did not sign this certificate**. The forgery finding. Every listed certificate under that name was tried — a CA mid-rotation publishes several — and none verifies the signature over this certificate's `tbsCertificate`.

            `pathUnverifiable`: a listed CA carries this name and the signature could not be checked — usually a key algorithm this build does not verify. **Not an accusation, and never to be read as one.**

            `notQualifiedAtSealing`: a listed CA issued it, and its status at the sealing moment was not granted.

            `qualifiedAtSealing`: a granted qualified CA issued this certificate and was granted when the seal was made, and its signature over the certificate verifies under a key the list publishes.
        issuer:
          type: string
          description: The issuer distinguished name the certificate carries.
        subject:
          type: string
          description: '`selfIssued` only — the subject name, which is also the issuer name.'
        consulted:
          type: integer
          description: |-
            `notListed` only. How many territories' verified lists were searched.

            🚨 `0` means **nothing was looked at**: the answer is about nothing, and it is what a node with no trusted lists loaded reports for every provider seal, qualified or not.
        unchecked:
          type: integer
          description: '`notListed` only. How many territories the EU list of trusted lists names could not be verified, and so were not searched. Above zero, a provider listed *there* is indistinguishable from one listed nowhere — see the `unchecked` array on `SealQualification`, which names them.'
        missingIssuer:
          type: string
          description: |-
            `chainIncomplete` only. The name the walk needed next and the seal did not carry. Equal to `issuer` when the seal carries only its signing certificate.

            ETSI EN 319 122-1 clause 5.2.1 asks generators to include those intermediates where the signature is to be validated through a Trusted List, which is the remedy an operator seeing this should ask their provider for.
        provider:
          type:
            - string
            - 'null'
          description: The listed provider, where one was matched.
        territory:
          type:
            - string
            - 'null'
          description: The list carrying that name.
        status:
          allOf:
            - $ref: '#/components/schemas/TrustServiceStatus'
          description: |-
            `notQualifiedAtSealing` only — the status in force at the sealing time.

            Null means the list's history does not reach back that far, which is a **different finding** from a recorded non-granted status: the list is silent about that moment rather than negative about it.

            🚨 **Three shapes, not two.** `granted` and `withdrawn` are the two the supervisory body's vocabulary distinguishes for a qualified service; every other status a trusted list can carry — `undersupervision`, `recognisedatnationallevel` and the rest — arrives as an **object**, `{"other": "<the status URI>"}`. Core keeps them readable without making them comparable to `granted`, because they answer a different question asked of a different kind of service, and folding them in would invite the reading that one of them is a lesser kind of qualified.

            A client that assumes a string here will break on the first list entry it meets that is neither granted nor withdrawn.
        reason:
          type: string
          description: |-
            `pathUnverifiable` only — why the issuer's signature over this certificate could not be checked. Usually a key algorithm this build does not verify; a CA certificate that will not parse is the other.

            🚨 Read it as a limit of the reader, never as a finding about the seal. Reporting this as `signatureNotFromListedCa` would call a possibly-genuine seal a forgery on the strength of a check that never ran.
        remoteQscdManagement:
          type: boolean
          description: '`qualifiedAtSealing` only. Whether the listed service declares the additional-information qualifier for a QSCD managed on the signer''s behalf — Art. 39a''s second grant.'
    UncheckedTerritory:
      type: object
      description: |-
        A territory the EU list of trusted lists names and this node could not consult.

        Carried rather than dropped because the two states a caller must tell apart — *this issuer is on no list* and *the list it would be on could not be read* — are otherwise identical at the point of the verdict.
      required:
        - territory
        - reason
      properties:
        territory:
          type: string
          description: The `SchemeTerritory` the list of trusted lists names — the two-letter country code.
        reason:
          type: string
          description: |-
            Why this node could not consult it, in terms an operator can act on.

            Free text on purpose. What stops a list being consulted is not an enumerable set: a signature that does not verify, a document over a parser ceiling, a fetch that failed, a scheme operator mid-rotation whose entry has not caught up. An operator needs the sentence; a caller deciding what to do needs only that the territory is absent.
    SealQualification:
      type: object
      description: |-
        What Regulation (EU) No 910/2014 Art. 32(1) says about a seal, read off the seal and the EU Trusted Lists this node holds. Art. 40 applies Art. 32 to seals *mutatis mutandis*.

        Two legs, and they are independent — a seal can hold either without the other, which is why they are two fields rather than one ladder:

        * **Art. 32(1)(a)–(b)** — the certificate was a qualified certificate issued by
          a qualified trust service provider. A Trusted List question (Art. 22),
          answered by `issuer`.
        * **Art. 32(1)(f)** — the seal was created by a qualified electronic seal
          creation device, which Annex III(j) requires the certificate to declare in
          machine-processable form. Answered by `creationDevice`.


        🚨 **This is not a qualified validation result.** Under Art. 33, reached for seals by Art. 40, a *qualified* validation service is itself a provider service whose result carries that provider's seal. Nothing here is signed and nothing here is qualified — this is a reading of published lists, and conditions (c), (d) and (h) of Art. 32(1) are not checked at all.
      required:
        - issuer
        - creationDevice
        - unchecked
      properties:
        issuer:
          $ref: '#/components/schemas/IssuerStanding'
        creationDevice:
          $ref: '#/components/schemas/CreationDevice'
        unchecked:
          type: array
          items:
            $ref: '#/components/schemas/UncheckedTerritory'
          description: |-
            The territories the EU list of trusted lists names and this node could not consult, with the reason for each.

            🚨 **Read this before acting on a `notListed` issuer.** The two states a caller must tell apart — *this issuer is on no list* and *the list it would be on could not be read* — are otherwise identical at the point of the verdict. Empty means every territory the list of lists names was read.
    SealAuditReport:
      type: object
      description: |-
        What the last completed pass over **every stored seal** found.

        The counts beside this describe outbox rows and passports carrying *no* seal. This describes seals that exist and do not stand up — a condition neither can see, because both ask the database whether the seal member is absent, and a worthless seal is present.

        Whether a stored seal stands up is cryptographic rather than relational: open the CAdES, verify the signature, read the digest it covers. So it is found by a background pass that walks the estate in bounded batches and starts over, not by a query.
      required:
        - completedAt
        - checked
        - sound
        - superseded
        - broken
        - certificateFailed
        - unreadable
        - archivalLapsed
        - archivalDue
        - archivalUnverifiable
        - brokenPassports
        - renewalPassports
        - truncated
        - renewalTruncated
      properties:
        completedAt:
          type: string
          format: date-time
          description: When the pass finished. The age of this is the age of every number below it — a pass takes as long as the estate divided by the audit's throughput, so on a large deployment these are hours old by construction. Fine for a condition that does not appear suddenly, and stated rather than implied.
        checked:
          type: integer
          description: Seals opened.
        sound:
          type: integer
          description: |-
            Seals covering their passport's current signature.

            In EN 319 102-1's terms this is `indeterminate`, **not** `totalPassed`: nothing about these seals has failed, and their certificates have not been validated by this node. See `validation` on `GET /api/v1/dpp/{dppId}/seal`.
        superseded:
          type: integer
          description: |-
            Seals over a different digest. Ordinarily a passport re-published after sealing, which is **not** a defect — the seal remains a valid attestation of the signature it covers, and `GET /api/v1/dpp/{dppId}/seal` reports it per passport as `coverage`.

            `totalFailed` / `hashFailure` when the question asked is about the passport's *current* signature, which is the question this walk asks.
        broken:
          type: integer
          description: |-
            Seals whose own signature does not verify. **The finding.** Those passports are published and, in substance, unsealed — and invisible to `unsealedPublished`, which asks only whether a seal is present.

            `totalFailed` / `sigCryptoFailure`, and EN 319 102-1 makes that verdict stable: no additional validation data can lift it. That is what makes a replacement seal worth buying for these and for nothing else here — `POST /api/v1/dpp/{dppId}/seal/repair`.
        certificateFailed:
          type: integer
          description: |-
            Seals whose signature is sound and whose **certificate** was not, at the moment they were made — revoked before sealing, or outside its validity window, with an attested time to prove the order. `totalFailed` with a certificate sub-indication.

            Counted apart from `broken` because the two need opposite responses. A broken seal is worth replacing; a seal made under a revoked certificate would only be replaced by another from the same certificate, so `POST /api/v1/dpp/{dppId}/seal/repair` refuses it and says why.

            Where nothing attests when the seal was made, the same observation is *indeterminate* rather than a failure and the seal stays in `sound`: a certificate that has expired since is the ordinary state of an old seal.
        unreadable:
          type: integer
          description: |-
            Seals this node could not read — a placeholder, or a format it does not parse. **Not a finding.** Treating "cannot check" as "broken" would make every seal from a backend emitting an unparsed format look like corruption.

            `indeterminate` with a custom diagnostic: a limit of the reader, not a defect in the seal.
        archivalLapsed:
          type: integer
          description: |-
            `B-LTA` seals whose archival protection has already gone — the archive timestamp's own authority certificate has expired.

            **Not a defect in the seal**, which still verifies. It is the protection `B-LTA` exists to provide, having run out while nothing renewed it. A seal here is past the window in which renewing was routine.
        archivalDue:
          type: integer
          description: |-
            `B-LTA` seals whose archival protection expires soon.

            A reporting threshold rather than a purchase trigger: nothing on this node buys a renewal. The window between "due" and "gone" is the only one in which renewing is routine rather than an incident, which is why it is reported separately from `archivalLapsed`.
        archivalUnverifiable:
          type: integer
          description: |-
            `B-LTA` seals carrying an archive timestamp this node cannot use — either it does not parse, or it is not a timestamp of *this* seal.

            **Not a renewal candidate.** Renewing carries existing protection forward, and here there is none: the seal claims a level it does not have. Read this beside `broken` rather than beside `archivalDue`.
        brokenPassports:
          type: array
          items:
            type: string
            format: uuid
          description: The passports carrying a broken seal, so an operator can act rather than search a log. Capped — see `truncated`. A node with thousands of broken seals has one problem, not thousands; `broken` states its size, and a list long enough to prove that is a list nobody reads.
        renewalPassports:
          type: array
          items:
            type: string
            format: uuid
          description: |-
            The passports whose archival protection has lapsed or is due, capped the same way `brokenPassports` is.

            Lapsed and due share one list because they share one action — renew — and `archivalLapsed` and `archivalDue` already say how many are in each state.
        truncated:
          type: boolean
          description: True when `brokenPassports` was cut short. Stated rather than left to be inferred from the length matching the cap, which is an inference that is right until the cap changes.
        renewalTruncated:
          type: boolean
          description: |-
            True when `renewalPassports` was cut short.

            🚨 A separate flag from `truncated`, not a shared one. Two capped lists fill independently: a pass can name every renewal candidate while cutting the broken list short, or the reverse. One flag covering both would report a complete list as truncated, or — worse — a truncated one as complete.
    AmendRequest:
      type: object
      description: |
        Body for amending a published passport.

        A published passport's content is immutable — its signatures commit to its
        bytes and the retention guard refuses the write. An amendment therefore
        publishes a **new** passport carrying `supersedesId` back to the one being
        corrected, and moves that one to the terminal `superseded` state. The
        superseded record keeps its signatures and stays resolvable by its own id.
      required:
        - patch
      properties:
        patch:
          type: object
          description: |
            The correction, in the same shape the draft-update body takes: only the
            patchable content fields (`productName`, `co2ePerUnit`,
            `repairabilityScore`, `productGroupData`, `componentRefs`) take effect.

            Other keys are ignored rather than refused, so a client that sends a full
            create-shaped body still works and still cannot make a create-time field
            take effect. Fields fixed at issuance — the product group, the schema
            version, the manufacturer, the lineage edges — are not amendable; a
            passport whose product group is wrong is not the same product.
          additionalProperties: true
          example:
            productName: Model X Battery Pack (rev B)
            productGroupData:
              productIdentifier:
                scheme: gs1
                gtin: '01234567890128'
              batteryType: industrial
        reason:
          type: string
          description: |
            Why the passport is being corrected. Recorded on the superseded record's
            audit entry alongside the successor's id — the two together are what
            answers "why did this change" for a reader who arrives at the old record.
          example: Recycled-content share restated after supplier re-declaration
    SupersedeRequest:
      type: object
      description: |
        Which passport replaces the one being retired.

        Supersession is a **link between two passports that already exist**, not a
        create-and-retire in one call: a successor is an ordinary passport with its own
        content and its own publish gates, so it goes through the normal create and
        publish routes and is only then named here.

        For the other direction — correcting a published passport, where the successor
        is derived from the record it replaces — use `POST /dpp/{dppId}/amend`, which
        mints the successor itself and cannot name one that already exists.
      required:
        - supersededBy
      properties:
        supersededBy:
          allOf:
            - $ref: '#/components/schemas/DppId'
          description: |
            The successor, which must already be **published** and must already carry
            `supersedesId` pointing back at the passport named in the path. That link
            is set when the successor is created; this call confirms the two agree
            before retiring anything, and refuses with `422` if they do not.
        reason:
          type: string
          description: |
            Why the passport is being retired. Stored on the predecessor's audit entry
            alongside the successor's id — the same entry `amend` writes, so a reader
            arriving at a retired record finds what replaced it and why, whichever
            route retired it.
          example: Reissued on schema 2.6.0 after the product group's lens changed
    RulesetReload:
      type: object
      description: The outcome of re-reading the signed compliance-ruleset channel.
      required:
        - rulesetVersion
        - changed
      properties:
        rulesetVersion:
          type: string
          description: The bundle version in force after the reload. `baseline` on a node that has never adopted a signed bundle.
          example: 2026-Q3.2
        changed:
          type: boolean
          description: Whether this call actually replaced the ruleset. `false` means the channel re-offered the bundle already in force — the ordinary answer on a quiet channel, and a success rather than an error.
          example: true
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        An opaque, client-minted string that makes this write safe to retry.

        Send the same key with the same body and the first outcome is returned
        instead of a second resource being created. The replayed response carries
        `Idempotency-Replayed: true`, so a client can tell a replay from a first
        execution without comparing anything.

        Only the routes that document this parameter accept it. Sending it to any
        other route is a `400` rather than being ignored — a route that is idempotent
        by shape records nothing, and silently accepting the header would suggest a
        protection that is not there.

        **A retry must resend byte-identical bytes.** The key is bound to a SHA-256
        of the raw request body, not of a canonicalised form, so re-serialising with
        different member order or whitespace counts as a different request and is
        refused with `422`. Use a fresh key whenever the body changes.

        On a `multipart/form-data` route this includes the **boundary**: the whole
        encoded body is what is hashed. Most HTTP clients generate a fresh boundary
        per request, which makes the retry differ in bytes and be refused. Either
        pin the boundary across attempts, or omit the key on multipart uploads.

        Keys are scoped to the authenticated caller and to this route, so the same
        key may safely be reused across different operations, and one caller can
        never observe another's. A key is honoured for **24 hours**; after that the
        same key starts a new request.

        While a first attempt is still running, a duplicate receives `409` with
        `Retry-After`.
      schema:
        type: string
        minLength: 1
        maxLength: 255
      example: 6f2a1c40-2f6f-4b8a-9a3e-1f4c9b2d7e51
  responses:
    Unauthorized:
      description: Missing or invalid authentication credentials.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/unauthorized
            title: Unauthorized
            status: 401
            detail: Missing or invalid Authorization header.
    Forbidden:
      description: |
        The credential is authenticated but lacks the required scope — e.g. a
        `write`/`read` key attempting an admin-only action.

        Every scope refusal in the service answers this one sentence, naming the scope
        required rather than the route. The route is what the caller just addressed;
        the scope is the part they have to act on.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/forbidden
            title: Forbidden
            status: 403
            detail: This operation requires an admin-scoped credential.
    IdempotentRequestInFlight:
      description: |
        An earlier attempt carrying this `Idempotency-Key` is still running.

        Returned instead of letting a duplicate execute alongside it. Retry shortly
        with the same key and the same body; `Retry-After` carries the suggested
        delay.

        A claim left behind by a process that died mid-request is reclaimed
        automatically after 60 seconds, so this can never wedge a key permanently.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            minimum: 1
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/idempotent-request-in-flight
            title: Idempotent Request In Flight
            status: 409
            detail: An earlier attempt with this `Idempotency-Key` is still running. Retry shortly; do not change the body.
    ValidationError:
      description: One or more fields failed validation.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/unprocessable-entity
            title: Unprocessable Entity
            status: 422
            detail: productName must not be empty.
    NotFound:
      description: Resource not found within the operator's scope.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/not-found
            title: Not Found
            status: 404
            detail: DPP not found.
    BadRequest:
      description: |
        A path parameter is malformed — most often a `{dppId}` or `{id}` that is not a
        UUID. The request never reaches the resource, so this is distinct from `404`,
        which means the identifier was well-formed and nothing matched it.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/bad-request
            title: Bad Request
            status: 400
            detail: not-a-uuid is not a valid DPP id.
    Conflict:
      description: |
        State conflict — e.g. attempting to publish an already-published DPP
        or updating a retired DPP.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/conflict
            title: Conflict
            status: 409
            detail: DPP is already published.
    Gone:
      description: |
        The passport exists but has been withdrawn from public view — it is suspended.
        Distinct from `404`: the node is confirming the identifier is real and
        declining to serve it, rather than denying knowledge of it.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/gone
            title: Gone
            status: 410
            detail: This passport is suspended and is not publicly available.
    NotAcceptable:
      description: |
        No representation matches the request's `Accept` header. The response
        body names the media types this resource can produce.

        A passport carrying no GTIN — an unsold-goods report, or an untyped
        product group — also gets this for `application/aas+json`: it identifies no
        trade item, so it has no AAS asset identity and therefore no AAS
        representation.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/not-acceptable
            title: Not Acceptable
            status: 406
            detail: No representation matches 'application/pdf'. This resource is available as text/html, application/ld+json, or application/aas+json.
    PassportSignatureUnverified:
      description: |
        The passport was fetched but its public signature did not verify against the
        operator's DID, so the resolver refuses to serve it.

        Every resolver representation is built from the **verified** signed payload,
        never from whatever the vault returned, and verification fails closed: a
        published passport carrying no public signature, a signature that does not
        check out, or a payload that does not match what was signed all end here
        rather than being served unmarked.

        Distinct from `503`, which means verification could not be *attempted*
        because the DID document was unreachable.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/conflict
            title: Conflict
            status: 409
            detail: The passport's digital signature could not be verified.
    ResolverUpstreamFailure:
      description: |
        The resolver could not read the passport from the vault it fronts.

        The resolver holds no passport data of its own — it fetches from the vault's
        public tier on every request — so an unreachable vault, or one answering
        anything other than a success, a `404`/`400` or a `410`, is reported as an
        upstream failure rather than as an answer about the passport.

        Deliberately not `404`: the identifier may be perfectly good. A consumer
        scanning a carrier should retry rather than conclude the product has no
        passport.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/bad-gateway
            title: Bad Gateway
            status: 502
            detail: The passport could not be read.
    PassportVerificationUnavailable:
      description: |
        The passport could not be verified right now, so it is not served.

        Verification needs the operator's DID document, and this is the answer when
        that document cannot be fetched, cannot be parsed, or carries no key matching
        the signature's `kid`. The passport itself may be perfectly valid — nothing
        has been established about it either way, which is why this is a temporary
        failure and not a judgement.

        Distinct from `409`, where verification ran and the signature did not check
        out. Retry; a `409` will not fix itself.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/service-unavailable
            title: Service Unavailable
            status: 503
            detail: The passport could not be verified right now; try again later.
    ServiceUnavailable:
      description: |
        A dependency the node needs is not reachable — the readiness probe pings the
        primary datastore, and reports this when that ping fails. The node is running;
        it is not ready to serve.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://problems.odal-node.io/service-unavailable
            title: Service Unavailable
            status: 503
            detail: The primary datastore did not respond.
