openapi: 3.1.0
info:
  title: Traceable Headless DPP Integration API
  version: "1.0.0-phase1"
  description: |
    Author Digital Product Passports (DPPs) programmatically — an external system
    (ERP / PLM, e.g. Odoo) creates a draft, fills it, attaches evidence, checks
    the publish gate, and submits it for review, WITHOUT the Traceable operator UI.

    ## Regulatory boundary (read first)
    This API is a DATA-INGESTION surface. Every state-changing operation
    terminates at **Submitted (for review)**. **The API can NEVER publish a DPP.**
    Publication is a deliberate human act performed by an operator in the
    Traceable UI, and the published record carries that natural person's
    attribution (ESPR / EU 2023/1542 Art. 77). There is no `dpp:publish` scope in
    Phase 1 and no endpoint that can set a product to Approved/Published. A
    submitted DPP enters the verifier/operator review queue.

    ## Availability
    Disabled by default. The endpoints return `503 FEATURE_DISABLED` unless
    `API_V1_PRODUCTS_ENABLED=true` is set on the target environment.

    ## What is NOT covered here
    `POST /api/v1/products/{id}/bms-update` (BMS telemetry ingestion) is a
    separate surface documented in `bms-v1.yaml`.

    ## Testing
    The authentication chain (signature tamper, replay, timestamp-skew,
    cross-tenant isolation, and fail-closed rate limiting) is covered by an
    automated test suite. Detailed assurance status is available to onboarding
    partners under NDA.

    ## Response headers (every response)
    Every response carries `x-traceable-api-version` (this contract's version) and
    `x-traceable-request-id` (a correlation id, also on the audit trail). Every
    authenticated response additionally carries `RateLimit-Limit` /
    `RateLimit-Remaining` / `RateLimit-Reset` (seconds), and a 429 adds
    `Retry-After` (seconds). Responses served to an API key whose last
    rotation (or mint) is older than 90 days carry `Warning: 299` advising
    rotation (soft at 90 days, urgent wording at 365 days).

    ## Sandbox mode
    Keys are `live` (`pk_live_*`) or sandbox (`pk_test_*`). Sandbox-authored
    data is invisible to live keys (and vice versa), never appears on a public
    passport, and has its own rate-limit quota. A sandbox request MUST send
    `X-Traceable-Mode: test`; every authenticated response echoes the mode in
    the same header. The idempotency replay window for key mint/rotate is 1
    hour (a retried mint returns the SAME key instead of creating a second).

    ## Outbound webhooks — verifying deliveries
    Deliveries POST a canonical JSON body with headers `Webhook-Signature:
    t=<unix_seconds>,v1=<hex>` and `Webhook-Id: <delivery_id>` (your retry
    idempotency handle). Verify EXACTLY like this (never compare with `===`):

        const expected = crypto.createHmac("sha256", signingSecret)
          .update(`${t}.${rawBody}`).digest("hex");
        const ok = crypto.timingSafeEqual(
          Buffer.from(v1, "hex"), Buffer.from(expected, "hex"));

    Reject when `|now - t| > 300` seconds (replay window). The signing secret
    is shown ONCE at endpoint creation/rotation. Failed deliveries retry on a
    30s/2m/10m/1h/6h/24h ladder, then dead-letter (re-queue them via the
    redeliver endpoint); 20 consecutive 5xx/timeouts auto-disable the endpoint
    (re-enable via PATCH status=active). NOTE: the bulk-revoke kill-switch can
    revoke the calling admin key itself — revoke it LAST in incident runbooks.
servers:
  - url: https://app.traceable.digital
    description: Production (api-v1 may be flag-off)
  - url: https://staging.traceable.digital
    description: Staging

security:
  - bearerAuth: []

tags:
  - name: discovery
    description: Authorized industries, categories, and category field schema (wizard steps 1-3)
  - name: products
    description: DPP authoring (list, read, create, update, form-data ingest)
  - name: lifecycle
    description: Readiness/publish-gate check and submit-for-review
  - name: operational
    description: Service metadata, liveness, and the machine-readable OpenAPI document

paths:
  /api/v1/health:
    get:
      operationId: getApiHealth
      tags: [operational]
      summary: Liveness probe (unauthenticated)
      security: []
      description: |
        Shallow liveness for the API surface. Touches no database or cache. Always
        returns 200; the body reports the server clock and whether the products API
        is enabled on this environment. Not gated by `API_V1_PRODUCTS_ENABLED`.
      responses:
        "200":
          description: Service is alive
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  service: { type: string, example: api-v1 }
                  apiVersion: { type: string, example: 1.0.0-phase1 }
                  productsEnabled: { type: boolean }
                  time: { type: string, format: date-time }
  /api/v1/meta:
    get:
      operationId: getApiMeta
      tags: [operational]
      summary: Introspect the calling key's scopes and the API's policy
      description: |
        Returns the calling key's granted scopes plus the contract version, the full
        scope vocabulary, and the rate-limit policy. Requires a valid bearer key but
        NO specific scope — it reveals only the key's own grants and public
        constants, never tenant data. Rate-limited.
      responses:
        "200":
          description: Key + API metadata
          headers:
            RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  apiVersion: { type: string }
                  grantedScopes:
                    type: array
                    items: { type: string }
                  availableScopes:
                    type: array
                    items: { type: string }
                  rateLimit:
                    type: object
                    properties:
                      tier: { type: string }
                      limitPerMinute: { type: integer }
                      window: { type: string }
                  productsEnabled: { type: boolean }
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: The products API is disabled on this environment
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
  /api/v1/openapi.json:
    get:
      operationId: getOpenApiDocument
      tags: [operational]
      summary: This OpenAPI 3.1 contract, machine-readable (unauthenticated)
      security: []
      description: |
        Returns this document as JSON, served from a build-time-generated copy of
        docs/api/integration-v1.yaml kept in sync by a contract test. Public — the
        spec discloses that the live endpoints may be flag-disabled on a given
        environment.
      responses:
        "200":
          description: The OpenAPI document
          content:
            application/json:
              schema:
                type: object
  /api/v1/products:
    get:
      operationId: listProducts
      tags: [products]
      summary: List the calling company's products
      description: "Required scope: `dpp:read`. Tenant is always the API key's company."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items: { $ref: "#/components/schemas/ProductSummary" }
                  count: { type: integer }
                  nextCursor: { type: ["string", "null"] }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    post:
      operationId: createProduct
      tags: [products]
      summary: Create a Draft DPP
      description: |
        Required scope: `dpp:draft:write`. A WRITE: requires the HMAC signature,
        `Idempotency-Key`, and a per-key signing secret. `companyId`, `status`,
        and `createdBy` are owned by the server and rejected if sent in the body.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateProductRequest" }
      responses:
        "201":
          description: Draft created
          content:
            application/json:
              schema:
                type: object
                properties:
                  product: { $ref: "#/components/schemas/ProductSummary" }
                  publicUrl:
                    type: ["string", "null"]
                    description: Stable public DPP URL (404s until published).
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: "GTIN or name already in use (`GTIN_IN_USE` / `NAME_IN_USE`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: "Plan product limit reached (`PLAN_LIMIT`) or rejected."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/industries:
    get:
      operationId: listIndustries
      tags: [discovery]
      summary: List the industries this key is authorized for (wizard step 1)
      description: "Required scope: `category:read`. Scoped to the API key's company industry (single-industry in Phase 1.5)."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  industries:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string }
                        name: { type: string }
                  count: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/categories:
    get:
      operationId: listCategories
      tags: [discovery]
      summary: List the categories this key is authorized for (wizard step 2)
      description: "Required scope: `category:read`. Scoped to the company industry; each carries the current schema version + hash for pinning."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string }
                        name: { type: string }
                        industryCode: { type: string }
                        schemaVersion: { type: ["string", "null"] }
                        schemaHash: { type: ["string", "null"] }
                  count: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/categories/{code}/schema:
    parameters:
      - name: code
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getCategorySchema
      tags: [discovery]
      summary: Field contract for a category (wizard step 3)
      description: "Required scope: `schema:read`. Returns every field's field_id, label, type, required, validation, options, access_tier, help_text, unit (or unit_unspecified). The machine-readable surface an ERP maps its columns against. Out-of-scope/unknown code returns 404 (no existence oracle)."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  schema:
                    type: object
                    properties:
                      category_code: { type: string }
                      schema_version: { type: ["string", "null"] }
                      field_count: { type: integer }
                      required_count: { type: integer }
                      fields:
                        type: array
                        items: { $ref: "#/components/schemas/CategoryField" }
        "400":
          description: Missing category code.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/categories/{code}/diff:
    parameters:
      - name: code
        in: path
        required: true
        schema: { type: string }
      - name: from
        in: query
        required: true
        schema: { type: string }
        description: The earlier schema version.
      - name: to
        in: query
        required: true
        schema: { type: string }
        description: The later schema version.
    get:
      operationId: getCategorySchemaDiff
      tags: [discovery]
      summary: Field-level delta between two schema versions
      description: |
        Required scope: `schema:read`. Returns the `added` / `removed` / `changed`
        fields between two FormTemplate versions of an in-scope category, so an
        integrator can see exactly what a regulatory schema update changed before
        re-mapping. Unknown category code or unknown version → 404 (no oracle).
      responses:
        "200":
          description: Schema diff
          content:
            application/json:
              schema:
                type: object
                properties:
                  diff:
                    type: object
                    properties:
                      category_code: { type: string }
                      from_version: { type: string }
                      to_version: { type: string }
                      added: { type: array, items: { type: object } }
                      removed: { type: array, items: { type: object } }
                      changed: { type: array, items: { type: object } }
                      summary:
                        type: object
                        properties:
                          added: { type: integer }
                          removed: { type: integer }
                          changed: { type: integer }
        "400":
          description: "Missing `from`/`to` query parameter."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/suppliers:
    get:
      operationId: listSuppliers
      tags: [discovery]
      summary: Discover the suppliers you may reference in a BoM
      description: |
        Required scope: `supplier:read`. Returns ONLY the owned-or-linked
        supplier set (suppliers your company invited, already references in a
        traceability record, or is linked to via an active membership), each
        tagged with its `source`. GDPR-minimised: public registry fields only —
        never contactEmail / phoneNumber / primaryAddress. Use the returned
        `id` as `supplierId` in a traceability write. Rate-limited per key.
      responses:
        "200":
          description: Owned-or-linked suppliers
          content:
            application/json:
              schema:
                type: object
                properties:
                  suppliers:
                    type: array
                    items: { $ref: "#/components/schemas/SupplierSummary" }
                  count: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/companies/me/gs1-prefixes:
    get:
      operationId: listGs1Prefixes
      tags: [lifecycle]
      summary: List your company's registered GS1 prefixes
      description: "Required scope: `dpp:read`. Returns the GS1 company prefixes registered for your company."
      responses:
        "200":
          description: Prefixes
          content:
            application/json:
              schema:
                type: object
                properties:
                  prefixes: { type: array, items: { type: string } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    post:
      operationId: registerGs1Prefixes
      tags: [lifecycle]
      summary: Register GS1 company prefix(es) (closes the GS1 gate)
      description: |
        Required scope: `dpp:draft:write`. A WRITE (HMAC + Idempotency-Key).
        ADD semantics — the provided prefixes are merged into your company's
        existing set (deduped). Each prefix must be 4-12 digits. Registering at
        least one prefix closes the `gs1_prefix_not_registered` publish gate that
        otherwise blocks every paid-plan publish. Returns the resulting set.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prefixes]
              properties:
                prefixes:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { type: string, pattern: "^[0-9]{4,12}$" }
      responses:
        "201":
          description: Registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  prefixes: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: "A prefix is not 4-12 digits (`GS1_PREFIX_INVALID`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/products/{id}:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      operationId: getProduct
      tags: [products]
      summary: Get one owned product (full formData)
      description: "Required scope: `dpp:read`. Cross-tenant / unknown id returns 404 (never 403), so the API never confirms a product exists in another tenant."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  product: { $ref: "#/components/schemas/ProductDetail" }
                  publicUrl: { type: ["string", "null"] }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    patch:
      operationId: updateProduct
      tags: [products]
      summary: Update a Draft's fields (optimistic-locked)
      description: |
        Required scope: `dpp:draft:write`. A WRITE (HMAC + Idempotency-Key).
        `status` is NOT touchable here (use submit). Pass `lockVersion` for
        optimistic concurrency; a stale value returns 409 `LOCK_CONFLICT`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateProductRequest" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  product: { $ref: "#/components/schemas/ProductSummary" }
                  lockVersion: { type: integer }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "Optimistic lock conflict (`LOCK_CONFLICT`) — re-GET and retry."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/products/{id}/form-data:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    put:
      operationId: ingestProductFormData
      tags: [products]
      summary: Batch-ingest category form-data (wizard step 4)
      description: |
        Required scope: `dpp:draft:write`. A WRITE (HMAC + Idempotency-Key).
        The headless equivalent of filling the wizard's field sections: push a
        partial batch of category `field_id` -> value pairs and get a
        fail-closed, FIELD-LEVEL accept/reject. Every provided field is checked
        against the category schema (type / enum / range / pattern) and unknown
        fields are REJECTED (422 `FIELD_VALIDATION_ERROR`, `details.fieldErrors[]`),
        never silently dropped — so a mis-mapped ERP column is immediately
        visible. Required-completeness is NOT enforced here (a partial batch is
        allowed); it is reported by the publish gate. On success the response
        echoes the post-write completeness score and a compact `readiness`
        summary. `lockVersion` gives optimistic concurrency (stale -> 409).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
        - name: mapping
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: |
            Optional (T1.2). Translate the body's `formData` keys from ERP column
            names to canonical field_ids via a tenant-owned ApiFieldMapping (also
            requires the `mapping:read` scope). FAIL-CLOSED: a column with no entry
            in the referenced mapping rejects the WHOLE request (422
            `NO_MAPPING_FOR_SOURCE`, with `unmappedColumns`) — nothing is written.
            An invalid/foreign mapping id is 400 `INVALID_MAPPING`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateProductRequest" }
      responses:
        "200":
          description: Ingested (fully validated)
          content:
            application/json:
              schema:
                type: object
                properties:
                  product:
                    type: object
                    properties:
                      id: { type: string }
                      version: { type: string }
                      lockVersion: { type: integer }
                      status: { type: ["string", "null"] }
                      completenessScore: { type: ["number", "null"] }
                      sustainabilityScore: { type: ["number", "null"] }
                  fieldErrors:
                    type: array
                    description: Always empty on a 200 (the accept signal).
                    items: {}
                  readiness:
                    type: ["object", "null"]
                    properties:
                      ok: { type: boolean }
                      gapCount: { type: integer }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "Optimistic lock conflict (`LOCK_CONFLICT`) — re-GET and retry."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: |
            Field-level validation failure (`FIELD_VALIDATION_ERROR`).
            `details.fieldErrors[]` carries `{field_id, label, code, message}` —
            field references only, never the submitted values (PII redaction).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/products/{id}/traceability:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      operationId: listTraceability
      tags: [products]
      summary: List a product's supply-chain traceability records (BoM)
      description: "Required scope: `dpp:read`. Cross-tenant / unknown id returns 404. Rate-limited per key."
      responses:
        "200":
          description: Traceability records
          content:
            application/json:
              schema:
                type: object
                properties:
                  traceability:
                    type: array
                    items: { $ref: "#/components/schemas/TraceabilityRecord" }
                  count: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    post:
      operationId: addTraceability
      tags: [products]
      summary: Add a traceability record (closes the traceability gate)
      description: |
        Required scope: `dpp:draft:write`. A WRITE (HMAC + Idempotency-Key).
        Adds one supply-chain material; a single record closes the
        `traceability_required_for_category` publish gate for categories that
        require traceability. `supplierId` MUST be owned-or-linked to your
        company (discover via GET /suppliers) — a non-accessible supplier returns
        404 `SUPPLIER_NOT_ACCESSIBLE` (never 403). All guards (product
        editability, cross-tenant linked DPP, circular parent) are enforced
        identically to the operator UI.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TraceabilityCreateRequest" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  traceability:
                    type: object
                    properties:
                      id: { type: string }
                      supplierLinkageBasis:
                        type: string
                        enum: [invited, traceability, membership]
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: "Product not found, or `supplierId`/linked DPP not accessible (no existence oracle)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "409":
          description: "Product is frozen (`PRODUCT_FROZEN`) — published DPPs are immutable."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: "Circular parent reference (`CIRCULAR_REFERENCE`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/products/{id}/components:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      operationId: listComponents
      tags: [products]
      summary: List a product's BoM component linkages
      description: "Required scope: `dpp:read`. Cross-tenant / unknown id returns 404. Rate-limited per key."
      responses:
        "200":
          description: Component linkages
          content:
            application/json:
              schema:
                type: object
                properties:
                  components:
                    type: array
                    items: { $ref: "#/components/schemas/ComponentRecord" }
                  count: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    post:
      operationId: addComponent
      tags: [products]
      summary: Link a BoM component (sub-product)
      description: |
        Required scope: `dpp:draft:write`. A WRITE (HMAC + Idempotency-Key).
        A `childProductId` MUST be a product your company owns OR a publicly
        Approved DPP — a cross-tenant child returns 404
        `CHILD_PRODUCT_NOT_AVAILABLE` (never 403). Max 50 components per product;
        self-reference and circular linkages (depth 5) are rejected.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ComponentCreateRequest" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  component:
                    type: object
                    properties:
                      id: { type: string }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: "Product not found, or `childProductId` not accessible (no existence oracle)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "409":
          description: "Product is frozen (`PRODUCT_FROZEN`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: "Cap reached (`MAX_COMPONENTS`), self-reference (`SELF_REFERENCE`), or cycle (`CIRCULAR_REFERENCE`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/products/{id}/schema-compatibility:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      operationId: getSchemaCompatibility
      tags: [lifecycle]
      summary: Is this product on the category's current schema version?
      description: |
        Required scope: `dpp:read`. Reports the product's authored schema version
        vs the category's active version, and `mustMigrate` — true ONLY when a
        removed/changed field is actually present in the product's own data (a
        version bump that doesn't touch the product's fields is benign). When the
        versions differ and the historical schema is retained, a field-level
        `diff` is included. Cross-tenant / unknown id → 404.
      responses:
        "200":
          description: Compatibility report
          content:
            application/json:
              schema:
                type: object
                properties:
                  compatibility:
                    type: object
                    properties:
                      productId: { type: string }
                      authoredVersion: { type: ["string", "null"] }
                      latestVersion: { type: ["string", "null"] }
                      mustMigrate: { type: boolean }
                      affectedFields: { type: array, items: { type: string } }
                      diff: { type: ["object", "null"] }
                      note: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/products/{id}/gate:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      operationId: getPublishGate
      tags: [lifecycle]
      summary: Publish-readiness report (the integration wedge)
      description: |
        Required scope: `dpp:gate:read`. Runs the SAME publish gate the operator
        UI uses and returns the structured gap list — each gap carries a
        `resolver` (erp | operator-ui | verifier) so the integrator knows which
        gaps it can close via the API vs which need a human.
      responses:
        "200":
          description: Gate result
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GateResult" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/products/{id}/readiness:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      operationId: getProductReadiness
      tags: [lifecycle]
      summary: Readiness & completeness/compliance score (wizard steps 5-6)
      description: |
        Required scope: `dpp:gate:read`. Returns the SAME numbers the wizard
        shows — `completenessScore` (% of required fields filled), the persisted
        `complianceScore` / `complianceStatus`, the per-section breakdown,
        `missingFieldEntries[]` ({fieldId,label,kind} — which fields are still
        missing), the full partner-stable `gaps[]` (each with a `resolver`), and
        `ok` (ready-to-submit). The richer sibling of GET /gate (which stays as a
        back-compat alias). Rate-limited per key (fail-closed).
      responses:
        "200":
          description: Readiness report
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string }
                  productId: { type: string }
                  status: { type: string }
                  ok:
                    type: boolean
                    description: Ready-to-submit (the publish gate passes).
                  publishReady: { type: boolean }
                  completenessScore: { type: integer }
                  requiredFields:
                    type: object
                    properties:
                      filled: { type: integer }
                      total: { type: integer }
                  complianceScore: { type: ["number", "null"] }
                  complianceStatus: { type: ["string", "null"] }
                  sections: { type: object }
                  missingFieldEntries:
                    type: array
                    items:
                      type: object
                      properties:
                        fieldId: { type: ["string", "null"] }
                        label: { type: string }
                        kind: { type: string }
                  gapCount: { type: integer }
                  gaps:
                    type: array
                    items: { $ref: "#/components/schemas/PartnerGap" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/products/{id}/submit:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    post:
      operationId: submitProduct
      tags: [lifecycle]
      summary: Submit a Draft for review (Draft → Submitted)
      description: |
        Required scope: `dpp:submit`. A WRITE (HMAC + Idempotency-Key), bodyless.
        Runs the publish gate; on failure returns 422 `SUBMIT_BLOCKED` with the
        gap list. **Can only ever set status to Submitted — never Approved or
        Published** (structural firewall; the human-publish step stays in the UI).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      responses:
        "200":
          description: Submitted for review
          content:
            application/json:
              schema:
                type: object
                properties:
                  product:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string, const: "Submitted" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: |
            `SUBMIT_BLOCKED` — compliance gaps block submission (the response
            `error.missingFields` lists them); or `SUBMIT_REJECTED` — the entity
            can't be submitted (wrong status, demo product).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubmitBlockedError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/products/{id}/withdraw:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    post:
      operationId: withdrawProduct
      tags: [lifecycle]
      summary: Withdraw a published DPP (Approved -> Withdrawn -> public 410)
      description: |
        Required scope: `dpp:withdraw` (distinct least-privilege scope — it pulls
        already-published content offline). A WRITE (HMAC + Idempotency-Key).
        The public DPP flips to HTTP 410 Gone within the cache tick. Per
        EU Battery Reg Art. 76(2) / GPSR Art. 19: a `successorProductId` is
        REQUIRED for reasons EndOfLife / MarketWithdrawal / Supersession, and
        Recall / Counterfeit carry the 24h regulator-notify metadata
        (`regulatorNotifiedAuthority`). The successor must be your own product or
        a publicly Approved DPP.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason, details]
              properties:
                reason:
                  type: string
                  enum:
                    [Recall, SafetyDefect, Counterfeit, EndOfLife, MarketWithdrawal, Supersession, NonCompliance, CustomerComplaint, RegulatoryDirective, OperatorRequest]
                details: { type: string, minLength: 10, maxLength: 1000 }
                successorProductId:
                  type: ["string", "null"]
                  description: Product UUID or public slug. Required for EndOfLife/MarketWithdrawal/Supersession.
                regulatorNotifiedAuthority: { type: ["string", "null"], maxLength: 120 }
      responses:
        "200":
          description: Withdrawn
          content:
            application/json:
              schema:
                type: object
                properties:
                  product:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string, const: "Withdrawn" }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "Product is not in a withdrawable state (`INVALID_STATE`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: "Successor invalid (`SUCCESSOR_INVALID`) or demo product (`DEMO_PRODUCT`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/products/{id}/documents:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      operationId: listProductDocuments
      tags: [lifecycle]
      summary: List a product's evidence documents
      description: |
        Required scope: `dpp:read`. Returns each document's metadata including
        `fileSha256` (integrity), `virusScanStatus`, and `verificationStatus`
        (verifier progress). Cross-tenant / unknown id returns 404. Use
        GET /documents/{id}/download for a signed URL to the file.
      responses:
        "200":
          description: Documents
          content:
            application/json:
              schema:
                type: object
                properties:
                  documents:
                    type: array
                    items: { $ref: "#/components/schemas/DocumentRecord" }
                  count: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    post:
      operationId: uploadProductDocument
      tags: [lifecycle]
      summary: Attach a supporting evidence document (multipart)
      description: |
        Required scope: `document:write`. A multipart WRITE. The HMAC signs the
        RAW FILE BYTES (see "HMAC for multipart" in the guide), since the body is
        binary. The file is magic-byte-validated (the real type must match the
        claimed MIME). Allowed: PDF, DOCX, XLSX, PNG, JPG. EP7 is the newest
        surface and is flag-gated like the rest.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: "#/components/schemas/UploadDocumentRequest" }
      responses:
        "201":
          description: Document attached (verification Pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  document:
                    type: object
                    properties:
                      id: { type: string }
                      productId: { type: string }
                      status: { type: string, const: "Pending" }
        "400":
          description: "`FILE_REQUIRED` (no/empty file or filename) or `MALFORMED_BODY`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`PRODUCT_FROZEN` — the product is not editable."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "415":
          description: "`UNSUPPORTED_MEDIA_TYPE` — validated type is not an allowed evidence format."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: "`FILE_INVALID` (magic-byte mismatch / too large), `INVALID_METADATA`, or `STORAGE_LIMIT`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: "`UPLOAD_FAILED` — transient storage error; retry (the request was NOT cached)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/documents/{id}/download:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getDocumentDownloadUrl
      tags: [lifecycle]
      summary: Get a short-lived signed download URL for a document
      description: |
        Required scope: `dpp:read`. Returns a 2-hour signed URL to the file
        (Content-Disposition: attachment). The document must belong to your
        company — a cross-tenant or supplier-standalone document returns 404
        (no existence oracle). Rate-limited per key.
      responses:
        "200":
          description: Signed URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  download:
                    type: object
                    properties:
                      url: { type: string }
                      expiresInSeconds: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: "Document not found / not owned (`NOT_FOUND`), or no file stored (`NO_FILE`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: "`DOWNLOAD_FAILED` — transient storage error; retry."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/erp-mappings:
    post:
      operationId: createErpMapping
      tags: [erp-mapping]
      summary: Create an ERP field-mapping (new version)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `mapping:write`. Stores a `{ erp_column -> field_id }`
        alias map for the calling company under `source`, as a new monotonic
        version. Every field_id VALUE is validated against the company's active
        FormTemplate catalog: an unknown field_id returns 422 `UNKNOWN_FIELDS`
        (the whole request is rejected, nothing stored) with `details.unknownFieldIds`
        listing ONLY the unresolved field_ids, never the erp_column keys. companyId
        comes from the key, never the body. Idempotent via Idempotency-Key.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [source, mappings]
              properties:
                source:
                  type: string
                  enum: [ODOO, SAP, NETSUITE, MS_DYNAMICS, CUSTOM]
                mappings:
                  type: object
                  description: "{ erp_column: field_id }; 1..2000 entries."
                  additionalProperties: { type: string }
      responses:
        "201":
          description: The created mapping version.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  source: { type: string }
                  version: { type: integer }
                  createdAt: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: "`UNKNOWN_FIELDS` — a mapping value is not in the company's active field catalog; `details.unknownFieldIds` lists the unresolved field_ids (never the erp_column keys). Nothing is stored."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    get:
      operationId: listErpMappings
      tags: [erp-mapping]
      summary: List the company's ERP field-mappings
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `mapping:read`. With `?latest=true` (default) returns only
        the current (max-version) mapping per source.
      parameters:
        - name: source
          in: query
          required: false
          schema: { type: string, enum: [ODOO, SAP, NETSUITE, MS_DYNAMICS, CUSTOM] }
        - name: latest
          in: query
          required: false
          schema: { type: boolean, default: true }
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 50, maximum: 200 }
        - name: cursor
          in: query
          required: false
          schema: { type: string }
      responses:
        "200":
          description: The company's mappings.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        source: { type: string }
                        version: { type: integer }
                        mappings: { type: object, additionalProperties: { type: string } }
                        createdAt: { type: string, format: date-time }
                        updatedAt: { type: string, format: date-time }
                  nextCursor: { type: string, nullable: true }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/erp-mappings/{id}:
    patch:
      operationId: updateErpMapping
      tags: [erp-mapping]
      summary: Update an ERP field-mapping (inserts a new version)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `mapping:write`. Inserts a new version for the same source
        (the previous version is never mutated). 404 if the id is unknown or owned
        by another tenant (no existence oracle). 422 `UNKNOWN_FIELDS` as on create.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [mappings]
              properties:
                mappings:
                  type: object
                  additionalProperties: { type: string }
      responses:
        "200":
          description: The new mapping version.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  source: { type: string }
                  version: { type: integer }
                  createdAt: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`UNKNOWN_FIELDS` — see POST /api/v1/erp-mappings."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    delete:
      operationId: deleteErpMapping
      tags: [erp-mapping]
      summary: Delete one ERP field-mapping version
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `mapping:write`. Deletes a single version row (other
        versions retained). 404 if unknown or cross-tenant (no oracle).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/Signature"
      responses:
        "204": { description: Deleted (no body). }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/mappings/lint:
    post:
      operationId: lintErpColumns
      tags: [erp-mapping]
      summary: Suggest canonical field_ids for ERP columns (deterministic, no AI)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `mapping:read`. For each ERP column, returns the best
        canonical field_id from the category's schema by Jaro-Winkler similarity
        (deterministic, NO AI / zero tokens). 404 if the category is unknown or
        out-of-scope — returned BEFORE any scoring, so 404 timing is never
        proportional to catalog size. The response echoes the algorithm + threshold.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [categoryCode, columns]
              properties:
                categoryCode: { type: string }
                columns:
                  type: array
                  items: { type: string }
                  minItems: 1
                  maxItems: 500
                mappingId: { type: string, format: uuid }
      responses:
        "200":
          description: Per-column suggestions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  algorithm: { type: string, example: jaro-winkler }
                  threshold: { type: number, example: 0.86 }
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        erp_column: { type: string }
                        status: { type: string, enum: [exact, suggested, unmatched] }
                        suggestion:
                          type: ["object", "null"]
                          properties:
                            field_id: { type: string }
                            confidence: { type: number }
                        already_mapped:
                          type: boolean
                          description: >
                            Present only when the request supplied `mappingId`: true
                            if that existing mapping already covers this erp_column.
                            Lets an integrator UI grey out columns that are done.
        "400":
          description: "`VALIDATION_ERROR` — bad body (missing categoryCode/columns, >500 columns, ...)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/categories/{code}/erp-template:
    get:
      operationId: getErpTemplate
      tags: [erp-mapping]
      summary: Curated ERP starter pack for a category
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `mapping:read`. Returns a curated `{ erp_column -> field_id }`
        starter mapping for `?source` + the category, so an integrator can bootstrap
        a mapping with one GET. ODOO + SAP only, for the EV / LMT / Industrial
        battery categories; any other source/category returns 404. 404 also if the
        category is unknown or out-of-scope (no existence oracle).
      parameters:
        - name: code
          in: path
          required: true
          schema: { type: string }
        - name: source
          in: query
          required: true
          schema: { type: string, enum: [ODOO, SAP, NETSUITE, MS_DYNAMICS, CUSTOM] }
      responses:
        "200":
          description: The starter pack.
          content:
            application/json:
              schema:
                type: object
                properties:
                  source: { type: string }
                  categoryCode: { type: string }
                  mappings: { type: object, additionalProperties: { type: string } }
                  note: { type: string }
        "400":
          description: "`VALIDATION_ERROR` — missing or invalid `source`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/keys:
    post:
      operationId: mintApiKey
      tags: [key-management]
      summary: Mint a data-scoped API key (keys:admin)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `keys:admin` (a privileged scope that is NOT part of the
        data-scope vocabulary; it is granted only through the dedicated admin
        mint in the Traceable UI and can never be self-granted through this
        API). Mints a `pk_live_*` key holding DATA scopes only; requesting
        `keys:admin` in the body fails validation. The response is the ONLY
        place `rawKey` and `signingSecret` appear (reveal-once); a retried
        request with the same Idempotency-Key returns the SAME key for 1 hour
        instead of minting twice. Max 10 active keys per company.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [name, scopes]
              properties:
                name: { type: string, minLength: 3, maxLength: 100 }
                scopes:
                  type: array
                  minItems: 1
                  items: { type: string }
                  description: Data scopes only; `keys:admin` is rejected.
                expiresInDays: { type: integer, minimum: 1, maximum: 3650 }
                allowedCidrs:
                  type: array
                  maxItems: 20
                  items: { type: string }
                  description: IPv4/IPv6 CIDRs or addresses; empty = any IP.
                mode:
                  type: string
                  enum: [live, test]
                  default: live
                  description: |
                    Sandbox keys (`pk_test_*`) write rows that are fully
                    isolated from live data and can never appear on a public
                    passport. Every request made with a sandbox key MUST send
                    `X-Traceable-Mode: test` (else 400 MODE_HEADER_MISMATCH);
                    every response echoes the authenticated mode in the same
                    header. Sandbox and live rate-limit quotas are separate.
      responses:
        "201":
          description: Reveal-once key material (never retrievable again).
          content:
            application/json:
              schema:
                type: object
                properties:
                  keyId: { type: string, format: uuid }
                  keyPrefix: { type: string }
                  scopes: { type: array, items: { type: string } }
                  rawKey: { type: string }
                  signingSecret: { type: string }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: "`MINT_REJECTED` — cap reached or invalid grant."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    get:
      operationId: listApiKeys
      tags: [key-management]
      summary: List the company's API keys (keys:admin)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `keys:admin`. Safe projection only — key hashes and
        signing-secret references are structurally excluded and can never be
        re-revealed.
      responses:
        "200":
          description: The company's keys (all statuses).
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKeySummary" }
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/keys/{id}:
    get:
      operationId: getApiKey
      tags: [key-management]
      summary: Read one key (keys:admin)
      security: [{ bearerAuth: [] }]
      description: "Required scope: `keys:admin`. Cross-tenant/unknown ids return 404 with no existence oracle."
      parameters:
        - $ref: "#/components/parameters/KeyId"
      responses:
        "200":
          description: The key (safe projection).
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { $ref: "#/components/schemas/ApiKeySummary" }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    patch:
      operationId: patchApiKey
      tags: [key-management]
      summary: Update name / expiry / IP allow-list (keys:admin)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `keys:admin`. Mutable fields: `name`, `expiresInDays`,
        `allowedCidrs` (the per-key IP allow-list; empty array removes the
        restriction). SCOPES ARE IMMUTABLE after mint — a body carrying
        `scopes` fails validation; rotate or revoke instead.
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name: { type: string, minLength: 3, maxLength: 100 }
                expiresInDays: { type: integer, minimum: 1, maximum: 3650 }
                allowedCidrs:
                  type: array
                  maxItems: 20
                  items: { type: string }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keyId: { type: string, format: uuid }
                  updated: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`PATCH_REJECTED` — validation failed in the core."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    delete:
      operationId: revokeApiKey
      tags: [key-management]
      summary: Revoke a key (keys:admin)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `keys:admin`. Soft revoke — keys are never hard-deleted,
        so audit attribution survives. Takes effect on the next request made
        with the revoked key.
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keyId: { type: string, format: uuid }
                  revoked: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`REVOKE_REJECTED` — e.g. the key is already revoked."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/keys/{id}/rotate:
    post:
      operationId: rotateApiKey
      tags: [key-management]
      summary: Rotate a key (keys:admin)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `keys:admin`. Mints a replacement key carrying the old
        key's scopes, IP allow-list and mode BYTE-FOR-BYTE (rotation takes no
        parameters — an empty JSON body only), revokes the old key in the same
        transaction, and stamps `lastRotatedAt`. Reveal-once response, replayed
        for 1 hour under the same Idempotency-Key.
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: false }
      responses:
        "201":
          description: The replacement key (reveal-once).
          content:
            application/json:
              schema:
                type: object
                properties:
                  keyId: { type: string, format: uuid }
                  keyPrefix: { type: string }
                  revokedKeyId: { type: string, format: uuid }
                  rawKey: { type: string }
                  signingSecret: { type: string }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`ROTATE_REJECTED` — only an active key can be rotated."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/keys/{id}/signing-secret/rotate:
    post:
      operationId: rotateSigningSecret
      tags: [key-management]
      summary: Rotate only the HMAC signing secret (keys:admin)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `keys:admin`. The bearer key stays; a fresh signing
        secret is provisioned (the previous one stops verifying immediately)
        and `lastRotatedAt` is stamped. Reveal-once response.
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: false }
      responses:
        "201":
          description: The new signing secret (reveal-once).
          content:
            application/json:
              schema:
                type: object
                properties:
                  keyId: { type: string, format: uuid }
                  signingSecret: { type: string }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`ROTATE_REJECTED` — the key is not active."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/webhook-endpoints:
    post:
      operationId: createWebhookEndpoint
      tags: [webhooks]
      summary: Create a webhook endpoint (reveal-once secret)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `webhook:write`. The URL must be https:// and passes an
        SSRF gate at create time AND a resolve-and-pin check at every delivery.
        The whsec_ signing secret is returned ONCE (idempotency replays it for
        1 hour). Max 10 active endpoints per company. Endpoints are mode-scoped:
        a sandbox key's endpoints receive only sandbox events.
      parameters:
        - $ref: "#/components/parameters/ModeHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [name, url, eventTypes]
              properties:
                name: { type: string, minLength: 3, maxLength: 100 }
                url: { type: string, description: "https:// only; SSRF-gated." }
                eventTypes:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/WebhookEventType" }
      responses:
        "201":
          description: Reveal-once signing secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpointId: { type: string, format: uuid }
                  signingSecret: { type: string }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: "`ENDPOINT_REJECTED` — SSRF-gated URL or endpoint cap."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    get:
      operationId: listWebhookEndpoints
      tags: [webhooks]
      summary: List webhook endpoints
      security: [{ bearerAuth: [] }]
      description: "Required scope: `webhook:read`. Safe projection — never the secret or its Vault reference."
      parameters:
        - $ref: "#/components/parameters/ModeHeader"
      responses:
        "200":
          description: The company's endpoints (this mode).
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpointSummary" }
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/webhook-endpoints/{id}:
    get:
      operationId: getWebhookEndpoint
      tags: [webhooks]
      summary: Read one webhook endpoint
      security: [{ bearerAuth: [] }]
      description: "Required scope: `webhook:read`. Cross-tenant/cross-mode = 404 (no oracle)."
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/ModeHeader"
      responses:
        "200":
          description: The endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { $ref: "#/components/schemas/WebhookEndpointSummary" }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    patch:
      operationId: patchWebhookEndpoint
      tags: [webhooks]
      summary: Update name / url / events / status
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `webhook:write`. `url` re-passes the SSRF gate.
        `status: active` is the re-arm after a circuit-breaker auto-disable.
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/ModeHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name: { type: string, minLength: 3, maxLength: 100 }
                url: { type: string }
                eventTypes:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/WebhookEventType" }
                status: { type: string, enum: [active, disabled] }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpointId: { type: string, format: uuid }
                  updated: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`PATCH_REJECTED` — e.g. SSRF-gated URL."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }
    delete:
      operationId: deleteWebhookEndpoint
      tags: [webhooks]
      summary: Delete a webhook endpoint
      security: [{ bearerAuth: [] }]
      description: "Required scope: `webhook:write`. Deliveries cascade with the endpoint."
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/ModeHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpointId: { type: string, format: uuid }
                  deleted: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/webhook-endpoints/{id}/rotate-secret:
    post:
      operationId: rotateWebhookSecret
      tags: [webhooks]
      summary: Rotate the signing secret (reveal-once)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `webhook:write`. Empty body. The previous secret stops
        signing immediately; the new whsec_ value is revealed once.
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/ModeHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: false }
      responses:
        "201":
          description: The new signing secret (reveal-once).
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpointId: { type: string, format: uuid }
                  signingSecret: { type: string }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/webhook-endpoints/{id}/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [webhooks]
      summary: List deliveries for an endpoint
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `webhook:read`. Filters: `?status=pending|succeeded|
        failed|dead_letter`, `?since=<ISO date>`, `?limit=1..100` (default 50).
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/ModeHeader"
      responses:
        "200":
          description: Delivery ledger (newest first).
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDeliverySummary" }
                  count: { type: integer }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/webhook-deliveries/{id}/redeliver:
    post:
      operationId: redeliverWebhookDelivery
      tags: [webhooks]
      summary: Re-queue a dead-lettered delivery
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `webhook:write`. Resets a DEAD-LETTERED delivery to
        pending with a fresh attempt budget (the manual operator action after
        fixing the receiver). Anything not dead-lettered = 404.
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/ModeHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      responses:
        "200":
          description: Re-queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveryId: { type: string, format: uuid }
                  requeued: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/keys/bulk-revoke:
    post:
      operationId: bulkRevokeApiKeys
      tags: [key-management]
      summary: Incident kill-switch — bulk revoke (keys:admin)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `keys:admin`. Revokes a named set of keys
        (`keyIds`, 1-100) or ALL active keys (`all: true`) for the company —
        exactly one of the two. Every revocation writes its own audit row
        (per-entity attribution). The calling admin key can revoke itself.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                keyIds:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { type: string, format: uuid }
                all: { type: boolean, enum: [true] }
      responses:
        "200":
          description: Revocation summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  revokedCount: { type: integer }
                  failed:
                    type: array
                    items: { type: string, format: uuid }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: "`BULK_REVOKE_REJECTED`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/gdpr/erase:
    post:
      operationId: requestGdprErasure
      tags: [gdpr]
      summary: File a GDPR erasure request (gdpr:admin, live mode only)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `gdpr:admin` (privileged, isolated — never grantable
        alongside data scopes; minted only via the Company Portal admin-key
        dialog). GDPR Art. 17: files a structured deletion request for the
        company's account with the platform's **30-day processing SLA** — the
        same record the in-app privacy flow creates. This endpoint REQUESTS
        erasure (202 Accepted); processing is completed by a human
        administrator within the SLA. Idempotent: while a request is Pending,
        filing again returns the same request (`alreadyPending: true`).

        **Live mode only.** A sandbox (`pk_test_`) key receives a fail-loud
        `403 CROSS_MODE_DENIED`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/Signature"
        - $ref: "#/components/parameters/ModeHeader"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                reason: { type: string, maxLength: 2000 }
      responses:
        "202":
          description: The deletion request (created or already pending).
          content:
            application/json:
              schema:
                type: object
                properties:
                  deletionRequest:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      status: { type: string }
                      requestedAt: { type: string, format: date-time }
                      deadlineAt: { type: string, format: date-time }
                      alreadyPending: { type: boolean }
                      slaDays: { type: integer, enum: [30] }
        "400": { $ref: "#/components/responses/BadRequestWrite" }
        "401": { $ref: "#/components/responses/SignatureRejected" }
        "403":
          description: "`FORBIDDEN_SCOPE` (no gdpr:admin) or `CROSS_MODE_DENIED` (sandbox key)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/gdpr/access:
    get:
      operationId: getGdprSubjectAccess
      tags: [gdpr]
      summary: Subject-access export (gdpr:admin, live mode only)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `gdpr:admin`. GDPR Art. 15: returns the company
        account's full subject-access snapshot — identical to the Company
        Portal privacy-page export (same shared core): user, company,
        products, audit trail, notifications, preferences, deletion-request
        history, AI consent, support tickets, signatures, API-key METADATA
        (never hashes or secrets), data requests and change logs. Categories
        cap at 10,000 rows with an explicit `truncationWarning`.
        Response is personal data: served `Cache-Control: private, no-store`.

        **Live mode only** (`403 CROSS_MODE_DENIED` for sandbox keys).
      parameters:
        - $ref: "#/components/parameters/ModeHeader"
      responses:
        "200":
          description: The subject-access snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  subjectAccess:
                    { $ref: "#/components/schemas/SubjectAccessSnapshot" }
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403":
          description: "`FORBIDDEN_SCOPE` or `CROSS_MODE_DENIED`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

  /api/v1/gdpr/portability:
    get:
      operationId: getGdprPortabilityExport
      tags: [gdpr]
      summary: Portability export — machine-readable attachment (gdpr:admin, live mode only)
      security: [{ bearerAuth: [] }]
      description: |
        Required scope: `gdpr:admin`. GDPR Art. 20: the SAME snapshot as
        `/gdpr/access`, wrapped in an explicit machine-readable envelope
        (`format: traceable.gdpr.v1`) and served with
        `Content-Disposition: attachment` for direct transmission to another
        controller. **Live mode only** (`403 CROSS_MODE_DENIED`).
      parameters:
        - $ref: "#/components/parameters/ModeHeader"
      responses:
        "200":
          description: The portability envelope (JSON attachment).
          content:
            application/json:
              schema:
                type: object
                properties:
                  format: { type: string, enum: ["traceable.gdpr.v1"] }
                  generatedAt: { type: string, format: date-time }
                  subject: { type: string, format: email }
                  data:
                    { $ref: "#/components/schemas/SubjectAccessSnapshot" }
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403":
          description: "`FORBIDDEN_SCOPE` or `CROSS_MODE_DENIED`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/FeatureDisabled" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "pk_live_*"
      description: |
        `Authorization: Bearer pk_live_…`. The key determines the tenant
        (companyId) and the granted scopes. Writes ALSO require the HMAC
        signature header and a per-key signing secret (provisioned at key mint).
        Scopes (exhaustive): `dpp:read`, `dpp:gate:read`, `dpp:draft:write`,
        `dpp:submit`, `document:write`, `category:read`, `schema:read`,
        `supplier:read`, `dpp:withdraw`, `mapping:read`, `mapping:write`,
        `webhook:read`, `webhook:write`. There is NO `ai.*` scope and NO
        `dpp:publish` scope. Two PRIVILEGED scopes exist OUTSIDE this
        grantable set and are minted only via the Company Portal admin-key
        dialog, each always in isolation: `keys:admin` (/api/v1/keys) and
        `gdpr:admin` (/api/v1/gdpr).

  parameters:
    ProductId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    KeyId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: The API key's id (NOT the bearer token or prefix).
    ModeHeader:
      name: X-Traceable-Mode
      in: header
      required: false
      schema: { type: string, enum: [live, test], default: live }
      description: |
        REQUIRED (value `test`) on every request made with a sandbox
        (`pk_test_*`) key; a mismatch with the key's mode is a fail-loud
        400 MODE_HEADER_MISMATCH. Every authenticated response echoes the
        authenticated mode in the same header.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string }
      description: |
        Required on every write. The server scopes its cache to the key + the
        path id + a body hash, so reusing one key across different products or
        payloads executes (it never replays a different operation). 24h window.
    Signature:
      name: X-Traceable-Signature
      in: header
      required: true
      schema: { type: string, example: "t=1782460000, v1=<hmac-sha256-hex>" }
      description: |
        `t=<unix_sec>, v1=<hex>`. v1 = HMAC-SHA256(signingSecret) of the canonical
        string `${t}.${METHOD}.${pathWithQuery}.${sha256Hex(rawBody)}`. For
        multipart, `rawBody` is the raw request bytes. Window: 300s past, 30s
        future. Verified with a constant-time compare.

  responses:
    FeatureDisabled:
      description: "503 — the API is not enabled on this environment (`FEATURE_DISABLED`)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Forbidden:
      description: "403 — missing scope (`FORBIDDEN_SCOPE`) or module (`FORBIDDEN_MODULE`)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    NotFound:
      description: "404 — not found OR owned by another tenant (`NOT_FOUND`; IDOR-safe)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    BadRequestWrite:
      description: "400 — `INVALID_JSON`, `VALIDATION_ERROR`, or `IDEMPOTENCY_KEY_REQUIRED`."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    SignatureRejected:
      description: "401 — `INVALID_SIGNATURE` or `SIGNING_NOT_CONFIGURED` (or bearer auth failure)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    RateLimited:
      description: |
        429 — `RATE_LIMITED` (per-key limit, reads and writes alike; fails closed,
        so a limiter outage also returns 429). A limiter-down 429 is deliberately
        indistinguishable from a quota 429. Carries `Retry-After` (seconds).
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        RateLimit-Reset:
          $ref: "#/components/headers/RateLimitReset"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  headers:
    # Emitted on EVERY /api/v1 response (see x-response-headers in info.description).
    TraceableApiVersion:
      description: The API contract version this response was served by.
      schema: { type: string, example: "1.0.0-phase1" }
    TraceableRequestId:
      description: Per-request correlation id (also on the audit + ApiCallLog rows).
      schema: { type: string, format: uuid }
    # Emitted on every AUTHENTICATED (rate-limited) response.
    RateLimitLimit:
      description: Per-minute request ceiling for this key's tier.
      schema: { type: integer, example: 100 }
    RateLimitRemaining:
      description: Requests left in the current window (floored at 0).
      schema: { type: integer, example: 87 }
    RateLimitReset:
      description: Seconds until the window resets.
      schema: { type: integer, example: 42 }
    RetryAfter:
      description: Seconds to wait before retrying (429 only).
      schema: { type: integer, example: 60 }

  schemas:
    WebhookEventType:
      type: string
      description: |
        Live event types (a producer fires them today): `dpp_published`,
        `dpp_withdrawn`, `document_verified`, `submission_received`.
        DECLARED BUT NOT YET EMITTED (coming soon — do not rely on them):
        `gate_changed`, `category_schema_published`. Subscriptions to the
        not-yet-emitted types are accepted but will receive no deliveries
        until their producers ship.
      enum:
        [
          dpp_published,
          gate_changed,
          dpp_withdrawn,
          document_verified,
          submission_received,
          category_schema_published,
        ]
    WebhookEndpointSummary:
      type: object
      description: Safe projection — never the secret or its Vault reference.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        url: { type: string }
        eventTypes:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventType" }
        status: { type: string, enum: [active, disabled] }
        mode: { type: string, enum: [live, test] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        disabledAt: { type: string, format: date-time, nullable: true }
        lastRotatedAt: { type: string, format: date-time, nullable: true }
    WebhookDeliverySummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        eventType: { $ref: "#/components/schemas/WebhookEventType" }
        eventId: { type: string, format: uuid }
        status:
          { type: string, enum: [pending, succeeded, failed, dead_letter] }
        attemptCount: { type: integer }
        nextAttemptAt: { type: string, format: date-time, nullable: true }
        lastAttemptAt: { type: string, format: date-time, nullable: true }
        lastStatusCode: { type: integer, nullable: true }
        lastError: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        deliveredAt: { type: string, format: date-time, nullable: true }
    ApiKeySummary:
      type: object
      description: Safe key projection — never the hash or a secret reference.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        keyPrefix: { type: string }
        status: { type: string, enum: [active, revoked, expired] }
        scopes: { type: array, items: { type: string } }
        mode: { type: string, enum: [live, test] }
        allowedCidrs: { type: array, items: { type: string } }
        lastUsedAt: { type: string, format: date-time, nullable: true }
        lastRotatedAt: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        revokedAt: { type: string, format: date-time, nullable: true }
    SubjectAccessSnapshot:
      type: object
      description: |
        GDPR Art. 15/20 subject-access data set — identical to the Company
        Portal privacy-page export (same shared core). API keys appear as
        METADATA ONLY (never hashes, raw keys, or secret references). Each
        list category caps at 10,000 rows; a cap adds `truncationWarning`.
      properties:
        exportedAt: { type: string, format: date-time }
        exportedFor: { type: string, format: email }
        format: { type: string }
        truncationWarning: { type: string }
        user: { type: object, nullable: true }
        company: { type: object, nullable: true }
        products: { type: array, items: { type: object } }
        auditLog: { type: array, items: { type: object } }
        notifications: { type: array, items: { type: object } }
        emailPreferences: { type: object, nullable: true }
        deletionRequests: { type: array, items: { type: object } }
        aiConsent: { type: array, items: { type: object } }
        supportTickets: { type: array, items: { type: object } }
        digitalSignatures: { type: array, items: { type: object } }
        apiKeys: { type: array, items: { type: object } }
        dataRequests: { type: array, items: { type: object } }
        changeLogs: { type: array, items: { type: object } }
    ErrorEnvelope:
      type: object
      description: Canonical error shape for every /api/v1 4xx/5xx.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, description: Machine-stable error code. }
            message: { type: string }
            requestId: { type: string }
          additionalProperties: true
    SubmitBlockedError:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          properties:
            error:
              type: object
              properties:
                gapCount: { type: integer }
                missingFields:
                  type: array
                  items: { type: string }
    ProductSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        status: { type: string, description: "Draft | Submitted | Approved | …" }
        publicUrlSlug: { type: ["string", "null"] }
    ProductDetail:
      allOf:
        - $ref: "#/components/schemas/ProductSummary"
        - type: object
          properties:
            lockVersion: { type: integer }
            formData:
              type: object
              description: The full DPP field map (template-defined).
              additionalProperties: true
    CreateProductRequest:
      type: object
      description: |
        Strict shape (server rejects `companyId`/`status`/`createdBy`). The exact
        required fields are enforced by the server-side Zod schema; partners
        should treat a 400 `VALIDATION_ERROR` `error.fields` list as the contract.
      required: [productName]
      properties:
        productName: { type: string }
        formData:
          type: object
          additionalProperties: true
      additionalProperties: false
    UpdateProductRequest:
      type: object
      properties:
        formData:
          type: object
          additionalProperties: true
        lockVersion:
          type: integer
          description: Optimistic-lock version from the last GET/POST.
      additionalProperties: false
    UploadDocumentRequest:
      type: object
      required: [file, documentName, documentType]
      properties:
        file:
          type: string
          format: binary
        documentName: { type: string, maxLength: 200 }
        documentType:
          type: string
          enum: ["Test Report", "Declaration", "Certificate", "Data Sheet", "Other"]
        certificateRuleId: { type: string, format: uuid }
        expiryDate: { type: string, format: date }
    CategoryField:
      type: object
      description: |
        One field in a category's contract — what an ERP maps its columns
        against (wizard step 3). `unit`/`example` may be reported as
        absent via the `unit_unspecified`/`example_unspecified` flags rather
        than a misleading null.
      properties:
        field_id: { type: string }
        label: { type: string }
        type:
          type: string
          description: Field data type (text, integer, float, boolean, select, date, ...).
        required: { type: boolean }
        ui_section: { type: ["string", "null"] }
        access_tier:
          type: string
          enum: [public, internal, poli, authority]
          description: Effective access tier (most-restrictive of field-declared and spec-driven).
        help_text: { type: ["string", "null"] }
        validation:
          type: ["object", "null"]
          properties:
            min: { type: number }
            max: { type: number }
            pattern: { type: string }
        options:
          type: ["array", "null"]
          items: { type: string }
        unit: { type: ["string", "null"] }
        unit_unspecified: { type: boolean }
        example: {}
        example_unspecified: { type: boolean }
      required: [field_id, label, type, required, access_tier]
    DocumentRecord:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        type: { type: string }
        fileName: { type: string }
        fileSha256: { type: ["string", "null"] }
        virusScanStatus: { type: string }
        verificationStatus: { type: string }
        validUntil: { type: ["string", "null"], format: date-time }
        createdAt: { type: string, format: date-time }
    ComponentRecord:
      type: object
      properties:
        id: { type: string }
        componentName: { type: string }
        componentType: { type: string }
        quantity: { type: integer }
        childProductId: { type: ["string", "null"] }
        childDppUrl: { type: ["string", "null"] }
        childDppId: { type: ["string", "null"] }
        childProductSlug: { type: ["string", "null"] }
    ComponentCreateRequest:
      type: object
      description: |
        A BoM component. `parentProductId` is taken from the path (any body value
        is ignored). Provide a `childProductId` (owned or publicly Approved) to
        link a sub-product DPP, or `childDppUrl`/`childDppId` for an external one.
      required: [componentName, componentType]
      properties:
        componentName: { type: string, maxLength: 200 }
        componentType: { type: string, maxLength: 100 }
        quantity: { type: integer, minimum: 1 }
        childProductId: { type: string, format: uuid }
        childDppUrl: { type: string, format: uri, maxLength: 500 }
        childDppId: { type: string, maxLength: 100 }
    TraceabilityRecord:
      type: object
      properties:
        id: { type: string }
        materialName: { type: string }
        materialType: { type: string }
        tier: { type: integer }
        supplierId: { type: string }
        originCountry: { type: string }
        quantity: { type: number }
        quantityUnit: { type: string }
        complianceStatus: { type: string }
        parentTraceId: { type: ["string", "null"] }
        linkedDppId: { type: ["string", "null"] }
    TraceabilityCreateRequest:
      type: object
      description: |
        A supply-chain material. `productId` is taken from the path (any body
        value is ignored). `supplierId` must be owned-or-linked to your company.
      required:
        [materialName, materialType, quantity, quantityUnit, tier, supplierId, originCountry]
      properties:
        materialName: { type: string, maxLength: 200 }
        materialType:
          type: string
          enum: [Raw, Subcomponent, Assembly, Additive, Component]
        quantity: { type: number, exclusiveMinimum: 0 }
        quantityUnit: { type: string, enum: ["kg", "%", "g", "unit"] }
        tier: { type: integer, minimum: 1 }
        supplierId: { type: string, format: uuid }
        originCountry: { type: string, minLength: 2, maxLength: 2 }
        parentTraceId: { type: ["string", "null"], format: uuid }
        conflictMineralsFlag: { type: boolean }
        isRecycledMaterial: { type: boolean }
        complianceStatus:
          type: string
          enum: [Pending, Verified, Rejected, Invited]
        linkedDppId: { type: ["string", "null"], format: uuid }
        preConsumerRecycledPct: { type: ["number", "null"] }
        postConsumerRecycledPct: { type: ["number", "null"] }
        bioBasedContentPct: { type: ["number", "null"] }
        isCriticalRawMaterial: { type: boolean }
    SupplierSummary:
      type: object
      description: |
        A GDPR-minimised supplier in the owned-or-linked discovery set. Public
        registry fields only — contactEmail / phoneNumber / primaryAddress are
        deliberately omitted (Art. 5(1)(c) data minimisation).
      properties:
        id: { type: string }
        legalEntityName: { type: string }
        vatNumber: { type: string }
        countryOfRegistration: { type: string }
        entityType: { type: string }
        onboardingStatus: { type: string }
        source:
          type: string
          enum: [invited, traceability, membership]
          description: The linkage basis by which this supplier is accessible to you.
      required: [id, legalEntityName, source]
    PartnerGap:
      type: object
      description: |
        A partner-stable publish-gap envelope. Shared by GET /gate and
        GET /readiness. Never exposes internal ids (cert-rule UUIDs,
        cross-tenant product ids) — only regulator-meaningful labels and the
        caller's own values.
      properties:
        kind: { type: string }
        message: { type: string }
        resolver:
          type: string
          enum: [erp, operator-ui, verifier, support]
          description: Who can close this gap.
        detail: { type: object }
    GateResult:
      type: object
      properties:
        ready: { type: boolean }
        gapCount: { type: integer }
        gaps:
          type: array
          items: { $ref: "#/components/schemas/PartnerGap" }
