# ToldUntold Client API v1 — Catalog surface
# OpenAPI 3.1 description of /v1/client/catalog/*
#
# Formal, downloadable contract for integrators. Narrative integration guide:
# https://www.tolduntold.com/api-doc/catalog
openapi: 3.1.0

info:
  title: ToldUntold Client API — Catalog
  version: 1.0.0
  license:
    name: Proprietary — ToldUntold
    identifier: LicenseRef-Proprietary
  description: |
    Machine-to-machine REST API letting a brand's PIM/ERP/OMS manage its
    ToldUntold catalog: products, variant axes, variants and stock.

    ## Authentication
    Bearer authentication with a `tuk_` Client API key
    (`tuk_<24hex>.<secret40>`), issued by a ToldUntold super-admin. Legacy feed
    tokens (`cfk_`) are NOT valid on this surface and are rejected with `401`.

    ## Conventions
    - **Errors** use a flat envelope `{ "code", "message", "errors"? }` where
      `code` is a stable machine-readable string.
    - **Lists** return `{ "data": [...], "meta": {...} }` with
      `page`/`per_page` pagination (`per_page` default 50, hard cap 200).
      Single objects are returned as a flat JSON body.
    - **Timestamps** are ISO-8601 strings.
    - **Rate limits** are advertised through `X-RateLimit-*` response headers.
    - **Idempotency**: mutating endpoints accept an `Idempotency-Key` header;
      it is REQUIRED on the endpoints whose creations cannot be de-duplicated by
      a database constraint (see each operation).

    ## Tenancy
    Each key carries exactly one brand. Every resource is resolved within that
    brand; a resource that belongs to another brand (or does not exist) is a
    flat `404 not_found`. There is no implicit cross-brand access.

servers:
  - url: https://api.{env}.tolduntold.com
    description: ToldUntold environment
    variables:
      env:
        default: staging
        description: Environment slug provided by ToldUntold (e.g. staging, production).

security:
  - bearerAuth: []

tags:
  - name: Status
    description: Key smoke-test.
  - name: Products
    description: Product catalog reads and writes (superset shape).
  - name: Axes
    description: Brand variant-axis library (axes and their values).
  - name: Product axis selection
    description: The axes and values effectively selected on a product.
  - name: Variants
    description: Per-product variants.
  - name: Stock locations
    description: Brand stock locations.
  - name: Stock
    description: Stock levels, adjustments and movement ledger.
  - name: Stock feeds
    description: Bulk stock ingestion (snapshot feeds).

paths:
  # ───────────────────────────── Status ─────────────────────────────
  /v1/client/catalog/status:
    get:
      tags: [Status]
      operationId: getStatus
      summary: Key smoke-test
      description: |
        Echoes the authenticated key's identity, name, scopes and expiry.
        Requires a valid key but no particular scope — the fastest way for an
        integrator to confirm credentials and see the granted scopes.
      responses:
        '200':
          description: The authenticated key context.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KeyStatus' }
              example:
                brand_id: 8347
                key_id: tuk_9f2c1a4b6d8e0f2a4c6e8b0d
                name: ERP production
                scopes: [catalog.products:read, catalog.stock:write]
                expires_at: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────────── Products ─────────────────────────────
  /v1/client/catalog/products:
    get:
      tags: [Products]
      operationId: listProducts
      summary: List products
      description: |
        Superset product list for the key's brand. Requires
        `catalog.products:read`.

        The additive `stock_summary` object is served only when the key ALSO
        carries `catalog.stock:read` (amendment A4). Soft-deleted products are
        excluded.
      parameters:
        - name: sku
          in: query
          description: Exact SKU match.
          schema: { type: string }
        - name: is_active
          in: query
          description: Filter on the active flag.
          schema: { type: boolean }
        - name: has_variants
          in: query
          description: Keep only variant-parent products (true) or simple products (false).
          schema: { type: boolean }
        - name: updated_since
          in: query
          description: Only products with `updated_at` at or after this ISO-8601 instant.
          schema: { type: string, format: date-time }
        - name: sort
          in: query
          description: |
            Comma-separated sort keys; a leading `-` means descending. Allowed
            fields: `sku`, `created_at`, `updated_at`. Unknown fields are
            ignored; the default is `-updated_at`.
          schema: { type: string, example: '-updated_at,sku' }
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        '200':
          description: A page of products.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ProductListItem' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Products]
      operationId: createProduct
      summary: Create a product
      description: |
        Creates a product with its Profile, default-language settings and
        optional `versions` translations. Requires `catalog.products:write`.

        **Idempotency-Key is REQUIRED** (amendment A3): `products.sku` has no
        database UNIQUE constraint, so a replay after a network timeout could
        otherwise forge a real duplicate.

        - `sku` is required; brand uniqueness (against products AND variants,
          including soft-deleted variants) is enforced as a `409 duplicate_sku`.
        - `is_active` defaults to `false`.
        - `type` defaults to `simple`.
        - Stock, media, categories and markets are NOT settable here (any
          `stock_count` / `stock_type` key is silently dropped — guard G1).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProductCreate' }
            example:
              sku: TEE-CLASSIC
              title: Classic Tee
              type: simple
              price_value: 29.90
              price_currency: EUR
              tax_class: standard
              short_description: A soft everyday tee.
              is_active: true
              default_language: en-US
              versions:
                fr-FR:
                  title: Tee Classique
                  short_description: Un tee doux au quotidien.
      responses:
        '201':
          description: The created product (superset detail shape).
          headers:
            Idempotency-Replayed: { $ref: '#/components/headers/Idempotency-Replayed' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductDetail' }
        '400': { $ref: '#/components/responses/IdempotencyKeyRequiredError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: The SKU is already taken by a product or variant of the brand.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: duplicate_sku, message: This SKU is already taken by a product or variant of this brand. }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/products/{tuId}:
    parameters:
      - $ref: '#/components/parameters/ProductTuId'
    get:
      tags: [Products]
      operationId: getProduct
      summary: Get a product
      description: |
        Superset product detail: core fields plus `axes`, `variants`,
        `price_range`, `availability` and (with `catalog.stock:read`)
        `stock_summary`. Requires `catalog.products:read`.

        Amendment A4: without `catalog.stock:read`, `stock_summary` is omitted
        and each `variants[].availability.quantity` is stripped (the
        `availability.status` merchandising signal remains).
      responses:
        '200':
          description: The product.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Products]
      operationId: updateProduct
      summary: Update a product (partial)
      description: |
        Partial update — only the fields present in the body are written.
        Requires `catalog.products:write`. Idempotency-Key is OPTIONAL here
        (a PATCH is naturally re-runnable). Archive a product by sending
        `is_active: false`. `title` cannot be nulled.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProductUpdate' }
            example:
              price_value: 24.90
              is_active: false
      responses:
        '200':
          description: The updated product (superset detail shape).
          headers:
            Idempotency-Replayed: { $ref: '#/components/headers/Idempotency-Replayed' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The SKU is already taken by a product or variant of the brand.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: duplicate_sku, message: This SKU is already taken by a product or variant of this brand. }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────────── Axes ─────────────────────────────
  /v1/client/catalog/axes:
    get:
      tags: [Axes]
      operationId: listAxes
      summary: List brand axes
      description: The brand's variant-axis library with nested values. Requires `catalog.products:read`.
      parameters:
        - name: is_active
          in: query
          schema: { type: boolean }
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        '200':
          description: A page of axes.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Axis' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Axes]
      operationId: createAxis
      summary: Create an axis
      description: |
        Creates a brand axis, optionally with an initial set of values.
        Requires `catalog.axes:write`. `code` is unique per brand — a collision
        is a `409 duplicate_axis_code`. Idempotency-Key is optional.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AxisCreate' }
            example:
              code: size
              label: Size
              position: 0
              values:
                - { value: S, label: Small, position: 0 }
                - { value: M, label: Medium, position: 1 }
      responses:
        '201':
          description: The created axis.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Axis' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: An axis with this code already exists for the brand.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: duplicate_axis_code, message: An axis with this code already exists for this brand. }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/axes/{code}:
    parameters:
      - $ref: '#/components/parameters/AxisCode'
    patch:
      tags: [Axes]
      operationId: updateAxis
      summary: Update an axis (partial)
      description: |
        Updates `label`, `position` or `is_active`. Requires
        `catalog.axes:write`. `code` is immutable — sending it is a `422`
        (`prohibited`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AxisUpdate' }
            example: { label: Taille, is_active: true }
      responses:
        '200':
          description: The updated axis.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Axis' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Axes]
      operationId: deleteAxis
      summary: Delete an axis
      description: |
        Deletes an unused axis. Requires `catalog.axes:write`. Refused with
        `409 axis_in_use` when the axis is attached to a product or one of its
        values is referenced by a variant.
      responses:
        '204': { description: Deleted. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The axis is attached to a product or used by a variant.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: axis_in_use, message: This axis is attached to a product or used by a variant. }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/axes/{code}/values:
    parameters:
      - $ref: '#/components/parameters/AxisCode'
    post:
      tags: [Axes]
      operationId: createAxisValue
      summary: Add a value to an axis
      description: |
        Adds a value to the axis. Requires `catalog.axes:write`. `(axis, value)`
        is unique — a collision is a `409 duplicate_axis_value`. Idempotency-Key
        is optional.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AxisValueCreate' }
            example: { value: L, label: Large, position: 2 }
      responses:
        '201':
          description: The created value.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AxisValue' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: A value with this code already exists on the axis.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: duplicate_axis_value, message: A value with this code already exists on this axis. }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/axes/{code}/values/{value}:
    parameters:
      - $ref: '#/components/parameters/AxisCode'
      - $ref: '#/components/parameters/AxisValueParam'
    patch:
      tags: [Axes]
      operationId: updateAxisValue
      summary: Update an axis value (partial)
      description: |
        Updates `label`, `position` or `is_active`. Requires
        `catalog.axes:write`. `value` is immutable — sending it is a `422`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AxisValueUpdate' }
            example: { label: Large size, position: 3 }
      responses:
        '200':
          description: The updated value.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AxisValue' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Axes]
      operationId: deleteAxisValue
      summary: Delete an axis value
      description: |
        Deletes an unused value. Requires `catalog.axes:write`. Refused with
        `409 value_in_use` when the value is referenced by a variant or selected
        by a product.
      responses:
        '204': { description: Deleted. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The value is used by a variant or selected by a product.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: value_in_use, message: This axis value is used by a variant or selected by a product. }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────── Product axis selection ───────────────────────
  /v1/client/catalog/products/{tuId}/axes:
    parameters:
      - $ref: '#/components/parameters/ProductTuId'
    get:
      tags: [Product axis selection]
      operationId: getProductAxes
      summary: Get a product's axis selection
      description: |
        The axes effectively selected on the product (attached axes and their
        selected value subset). Requires `catalog.products:read`. Not paginated.
      responses:
        '200':
          description: The product's effective axis selection.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/EffectiveAxis' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    put:
      tags: [Product axis selection]
      operationId: replaceProductAxes
      summary: Replace a product's axis selection (declarative)
      description: |
        Declaratively sets the product's axis selection by codes/values. The
        array order is the per-product axis position. An empty `axes` array
        detaches everything. Requires `catalog.variants:write` (selecting axes
        is a variant-management gesture). Idempotent by nature — no
        Idempotency-Key.

        Error semantics (identical to the internal admin path):
        - unknown code/value → `422`, indexed at `axes.{i}.code` /
          `axes.{i}.values.{j}`;
        - attaching a NEW inactive axis or selecting a NEW inactive value → `422`;
        - attaching a new axis, or removing a value a live variant uses, while
          the product already has variants → `422`;
        - detaching an axis a live variant still carries → `409 axis_in_use`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProductAxesReplace' }
            example:
              axes:
                - { code: size, values: [S, M, L] }
                - { code: color, values: [black, white] }
      responses:
        '200':
          description: The resulting effective axis selection.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/EffectiveAxis' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Cannot detach an axis that a variant of this product still uses.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: axis_in_use, message: Cannot detach an axis that a variant of this product still uses. }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────────── Variants ─────────────────────────────
  /v1/client/catalog/products/{tuId}/variants:
    parameters:
      - $ref: '#/components/parameters/ProductTuId'
    get:
      tags: [Variants]
      operationId: listVariants
      summary: List a product's variants
      description: |
        The product's variants (soft-deleted excluded). Requires
        `catalog.products:read`. Amendment A4: without `catalog.stock:read` each
        `availability.quantity` is stripped (the `availability.status` remains).
      parameters:
        - name: is_active
          in: query
          schema: { type: boolean }
        - name: sku
          in: query
          description: Exact SKU match.
          schema: { type: string }
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        '200':
          description: A page of variants.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Variant' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Variants]
      operationId: createVariant
      summary: Create a variant
      description: |
        Creates one variant of the product. Requires `catalog.variants:write`.
        **Idempotency-Key is REQUIRED** (amendment A3).

        `axis_values` maps each axis code of the product's effective selection
        to exactly one selected value. A malformed axis payload is a `422`; a
        SKU collision (against products AND variants of the brand) or an
        already-existing axis combination is a `409`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VariantCreate' }
            example:
              sku: TEE-CLASSIC-M-BLACK
              axis_values: { size: M, color: black }
              price: { value: 32.00 }
              barcode: '3760000000017'
              is_active: true
      responses:
        '201':
          description: The created variant.
          headers:
            Idempotency-Replayed: { $ref: '#/components/headers/Idempotency-Replayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Variant' }
        '400': { $ref: '#/components/responses/IdempotencyKeyRequiredError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            A duplicate SKU (`duplicate_sku`) or an already-existing axis
            combination (`conflict`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                duplicate_sku:
                  value: { code: duplicate_sku, message: This SKU is already taken by a product or variant of this brand. }
                combination:
                  value: { code: conflict, message: A variant with this axis combination already exists. }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/products/{tuId}/variants/generate:
    parameters:
      - $ref: '#/components/parameters/ProductTuId'
    post:
      tags: [Variants]
      operationId: generateVariants
      summary: Generate variants (cartesian)
      description: |
        Expands the product's effective axis selection into its cartesian
        product, skips combinations that already exist, and creates the rest.
        Requires `catalog.variants:write`. **Idempotency-Key is REQUIRED**
        (amendment A3).

        - The expansion is capped at 500 combinations → `422 variant_cap_exceeded`.
        - A product with no axis selection is a `422 no_axes_selected`.
        - `dry_run: true` returns the plan with no writes (`200`).
        - A concurrent SKU collision during generation is a replayable
          `409 conflict`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VariantGenerate' }
            example: { dry_run: false, activate: true, sku_template: '{sku}-{size}-{color}' }
      responses:
        '200':
          description: 'The dry-run plan (only returned when `dry_run: true`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VariantGenerateDryRun' }
              example: { total_combinations: 6, would_create: 4, skipped_existing: 2 }
        '201':
          description: The generated variants.
          headers:
            Idempotency-Replayed: { $ref: '#/components/headers/Idempotency-Replayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VariantGenerateResult' }
        '400': { $ref: '#/components/responses/IdempotencyKeyRequiredError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: A SKU conflict occurred while generating; retry the request.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: conflict, message: SKU conflict while generating variants; retry the request. }
        '422':
          description: |
            No axes selected (`no_axes_selected`) or too many combinations
            (`variant_cap_exceeded`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                no_axes_selected:
                  value: { code: no_axes_selected, message: This product has no axes selected; select product axes before generating variants. }
                variant_cap_exceeded:
                  value: { code: variant_cap_exceeded, message: 'Too many combinations (720); refine the axis selection before generating (max 500).' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/variants/{tuId}:
    parameters:
      - $ref: '#/components/parameters/VariantTuId'
    patch:
      tags: [Variants]
      operationId: updateVariant
      summary: Update a variant (partial)
      description: |
        Partial update. Requires `catalog.variants:write`. `axis_values` is
        immutable (`422`) — recreate the variant to change its combination.
        `price.value: null` clears the override so the variant inherits the
        parent product's price. A SKU collision is a `409 duplicate_sku`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VariantUpdate' }
            example: { price: { value: null }, is_active: false }
      responses:
        '200':
          description: The updated variant.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Variant' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The SKU is already taken by a product or variant of the brand.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: duplicate_sku, message: This SKU is already taken by a product or variant of this brand. }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Variants]
      operationId: deleteVariant
      summary: Delete a variant
      description: Soft-deletes the variant. Requires `catalog.variants:write`.
      responses:
        '204': { description: Deleted. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────── Stock locations ───────────────────────────
  /v1/client/catalog/stock/locations:
    get:
      tags: [Stock locations]
      operationId: listStockLocations
      summary: List stock locations
      description: The brand's stock locations. Requires `catalog.stock:read`.
      parameters:
        - name: is_active
          in: query
          schema: { type: boolean }
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        '200':
          description: A page of locations.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/StockLocation' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Stock locations]
      operationId: createStockLocation
      summary: Create a stock location
      description: |
        Mints a brand stock location. Requires `catalog.stock:write`.
        `external_code` is required, URL-safe and unique per brand (collision →
        `409 conflict`). The reserved `__default__` sentinel cannot be minted
        (`422`). Idempotency-Key is optional.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StockLocationCreate' }
            example: { external_code: store-paris, name: Paris Flagship, is_active: true }
      responses:
        '201':
          description: The created location.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockLocation' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: A location with this external code already exists for the brand.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { code: conflict, message: "A stock location with external code 'store-paris' already exists for this brand." }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/stock/locations/{externalCode}:
    parameters:
      - $ref: '#/components/parameters/ExternalCode'
    patch:
      tags: [Stock locations]
      operationId: updateStockLocation
      summary: Update a stock location (partial)
      description: |
        Updates `name` or `is_active`. Requires `catalog.stock:write`.
        `external_code` is immutable and POS attachment is not writable — either
        in the body is a `422`. A write addressed to the `__default__` sentinel
        is a `404`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StockLocationUpdate' }
            example: { name: Paris — Rivoli, is_active: false }
      responses:
        '200':
          description: The updated location.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockLocation' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────────── Stock ───────────────────────────────
  /v1/client/catalog/stock/levels:
    get:
      tags: [Stock]
      operationId: listStockLevels
      summary: Flat stock-level view (full sync)
      description: |
        A flat, cross-product view of every stock level for the brand, for a
        PIM/ERP full sync. Requires `catalog.stock:read`. Filters: `sku` (exact,
        matching the level's own stockable), `location` (external_code) and
        `updated_since` (on `source_updated_at`).
      parameters:
        - name: sku
          in: query
          schema: { type: string }
        - name: location
          in: query
          description: Location external_code.
          schema: { type: string }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        '200':
          description: A page of stock levels.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/FlatStockLevel' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/products/{tuId}/stock:
    parameters:
      - $ref: '#/components/parameters/ProductTuId'
    get:
      tags: [Stock]
      operationId: getProductStock
      summary: Get a product's stock
      description: |
        Stock levels for a product, keyed by variant × location, plus the
        product-level rows and the aggregate availability. Requires
        `catalog.stock:read`. Filters: `location` (external_code) and `variant`
        (variant tu_id).
      parameters:
        - name: location
          in: query
          schema: { type: string }
        - name: variant
          in: query
          description: Variant tu_id.
          schema: { type: string }
      responses:
        '200':
          description: The product stock view.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductStock' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/products/{tuId}/stock/movements:
    parameters:
      - $ref: '#/components/parameters/ProductTuId'
    get:
      tags: [Stock]
      operationId: getProductStockMovements
      summary: Get a product's stock movement ledger
      description: |
        The product's stock movement ledger, newest first. Requires
        `catalog.stock:read`. Filters: `location` (external_code) and `variant`
        (variant tu_id). Movements written through the Client API adjust carry
        no actor.
      parameters:
        - name: location
          in: query
          schema: { type: string }
        - name: variant
          in: query
          description: Variant tu_id.
          schema: { type: string }
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        '200':
          description: A page of movements.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/StockMovement' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/products/{tuId}/stock/adjust:
    parameters:
      - $ref: '#/components/parameters/ProductTuId'
    post:
      tags: [Stock]
      operationId: adjustStock
      summary: Set an absolute stock quantity
      description: |
        Sets an absolute quantity for a (product|variant) × location. Requires
        `catalog.stock:write`. **Idempotency-Key is REQUIRED** (amendment A3).

        - `variant` is required for a variant-parent product and forbidden for a
          simple one.
        - A null `location` resolves to the per-brand `__default__` sentinel
          (auto-created); a supplied `location` must already exist.
        - `reserved` is preserved and NOT writable (sending it is a `422`).
        - Optimistic locking: pass `expected_quantity` to get a
          `409 stock_version_conflict` (carrying `current_quantity`) if the
          level changed since you read it.
        - The movement records reason `manual` and no actor; the note defaults
          to `client-api:<key_id>`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StockAdjust' }
            example: { variant: tuv_1a2b3c4d, location: store-paris, quantity: 42, expected_quantity: 40 }
      responses:
        '200':
          description: The resulting level and (when the quantity changed) the movement.
          headers:
            Idempotency-Replayed: { $ref: '#/components/headers/Idempotency-Replayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockAdjustResult' }
        '400': { $ref: '#/components/responses/IdempotencyKeyRequiredError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            The level changed since you read it (`stock_version_conflict`,
            carrying `current_quantity`), or the write was rejected on a
            conflicting timestamp (`rejected_stale`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                stock_version_conflict:
                  value: { code: stock_version_conflict, message: The stock level was modified since you last read it., current_quantity: 5 }
                rejected_stale:
                  value: { code: rejected_stale, message: The stock level was rejected due to a conflicting timestamp; no changes were made. }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ────────────────────────────── Stock feeds ──────────────────────────────
  /v1/client/catalog/stock/feeds:
    post:
      tags: [Stock feeds]
      operationId: ingestStockFeed
      summary: Ingest a stock feed (snapshot)
      description: |
        Bulk stock ingestion. Requires `catalog.stock:ingest`. This surface uses
        the existing feed idempotence mechanism, NOT the generic Idempotency-Key
        middleware: the **`Idempotency-Key` header is REQUIRED and folds into
        `source_batch_id`** (a missing header is a `422`, not a `400`).
        Re-posting the same batch id returns the existing feed unchanged
        (`200`); a new batch is queued for processing (`202`). Only snapshot
        mode is supported (`mode: delta` → `422`).
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: The feed batch id (`source_batch_id`). Re-using it replays the same feed.
          schema: { type: string, maxLength: 191 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StockFeed' }
            example:
              source: erp-eu
              mode: snapshot
              lines:
                - { sku: TEE-CLASSIC-M-BLACK, location_code: store-paris, quantity: 12, reserved: 2, updated_at: '2026-07-08T09:00:00Z' }
                - { sku: TEE-CLASSIC-L-BLACK, location_code: store-paris, quantity: 7 }
      responses:
        '200':
          description: The feed already existed for this batch id (replay); returned unchanged.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockFeedAck' }
              example: { feed_id: tuf_5e6f7a8b, status: queued, received_lines: 2, report_url: /v1/client/catalog/stock/feeds/tuf_5e6f7a8b }
        '202':
          description: The feed was accepted and queued for processing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockFeedAck' }
              example: { feed_id: tuf_5e6f7a8b, status: queued, received_lines: 2, report_url: /v1/client/catalog/stock/feeds/tuf_5e6f7a8b }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/client/catalog/stock/feeds/{tuId}:
    parameters:
      - $ref: '#/components/parameters/FeedTuId'
    get:
      tags: [Stock feeds]
      operationId: getStockFeed
      summary: Get a stock feed report
      description: |
        The processing report for a feed the key's brand submitted. Readable by
        EITHER a `catalog.stock:read` OR a `catalog.stock:ingest` key (ANY-OF).
        The `lines` block keeps its own `page`/`per_page` pagination
        (`per_page` default 100, cap 500).
      parameters:
        - name: per_page
          in: query
          description: Lines per page (1–500, default 100).
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        '200':
          description: The feed report.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockFeedReport' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

# ═══════════════════════════════════════════════════════════════════════════
# Components
# ═══════════════════════════════════════════════════════════════════════════
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        A ToldUntold Client API key, presented as `Authorization: Bearer
        tuk_<key_id>.<secret>`. Legacy `cfk_` feed tokens are rejected here.

  parameters:
    PageParam:
      name: page
      in: query
      description: 1-based page number.
      schema: { type: integer, minimum: 1, default: 1 }
    PerPageParam:
      name: per_page
      in: query
      description: Items per page. Default 50; values above 200 are clamped to 200.
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    ProductTuId:
      name: tuId
      in: path
      required: true
      description: Product opaque id (`tu_id`).
      schema: { type: string }
    VariantTuId:
      name: tuId
      in: path
      required: true
      description: Variant opaque id (`tu_id`).
      schema: { type: string }
    FeedTuId:
      name: tuId
      in: path
      required: true
      description: Stock-feed opaque id (`tu_id`).
      schema: { type: string }
    AxisCode:
      name: code
      in: path
      required: true
      description: Brand axis code (immutable, URL-safe).
      schema: { type: string }
    AxisValueParam:
      name: value
      in: path
      required: true
      description: Axis value (immutable, URL-safe).
      schema: { type: string }
    ExternalCode:
      name: externalCode
      in: path
      required: true
      description: Stock-location external code (immutable, URL-safe).
      schema: { type: string }
    IdempotencyKeyRequired:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Client-generated idempotency token (max 191 chars). REQUIRED on this
        endpoint. A replay with the same key and identical body returns the
        original response with `Idempotency-Replayed: true`; a different body
        with the same key is a `409 idempotency_key_reuse`. Settled responses
        are retained for 24 h.
      schema: { type: string, maxLength: 191 }
    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Optional client-generated idempotency token (max 191 chars). A replay
        with the same key and identical body returns the original response with
        `Idempotency-Replayed: true`; a different body with the same key is a
        `409 idempotency_key_reuse`. Settled responses are retained for 24 h.
      schema: { type: string, maxLength: 191 }

  headers:
    Idempotency-Replayed:
      description: Present and `true` when the response was replayed from a prior identical request.
      schema: { type: string, enum: ['true'] }
    X-RateLimit-Limit:
      description: The request ceiling for the most-constrained bucket applying to this call.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current window for that bucket.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix epoch (seconds) when the reported bucket resets.
      schema: { type: integer }
    Retry-After:
      description: Seconds to wait before retrying (present on 429).
      schema: { type: integer }

  responses:
    Unauthorized:
      description: |
        Missing, malformed, expired or revoked key — or a `cfk_` feed token
        presented on this surface.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing: { value: { code: unauthenticated, message: Missing API key. } }
            invalid: { value: { code: unauthenticated, message: Invalid or expired API key. } }
            feed_token: { value: { code: unauthenticated, message: 'Feed tokens are not valid on the Client API. Use /v1/catalog/stock/feeds for stock ingestion, or issue a Client API key (tuk_).' } }
    Forbidden:
      description: The key is valid but lacks the required scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: insufficient_scope, message: 'Missing scope: catalog.products:write.' }
    NotFound:
      description: The resource does not exist or belongs to another brand.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: not_found, message: Resource not found. }
    ValidationFailed:
      description: The request body or query failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationError' }
          example:
            code: validation_failed
            message: The given data was invalid.
            errors:
              sku: ['The sku field is required.']
    RateLimited:
      description: |
        A per-key (read/write) or per-brand rate limit was exceeded. Carries the
        `X-RateLimit-*` trio and `Retry-After`.
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: rate_limited, message: Too many requests. }
    IdempotencyKeyRequiredError:
      description: This endpoint requires an `Idempotency-Key` header.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: idempotency_key_required, message: This endpoint requires an Idempotency-Key header. }

  schemas:
    # ── Errors ──
    Error:
      type: object
      description: Flat Client API error envelope.
      required: [code, message]
      properties:
        code:
          type: string
          description: Stable machine-readable error code.
        message:
          type: string
          description: Human-readable message (not for programmatic branching).
      additionalProperties: true
    ValidationError:
      type: object
      required: [code, message, errors]
      properties:
        code: { type: string, const: validation_failed }
        message: { type: string }
        errors:
          type: object
          description: Field → list of messages.
          additionalProperties:
            type: array
            items: { type: string }

    # ── Shared ──
    PaginationMeta:
      type: object
      required: [current_page, per_page, total, last_page]
      properties:
        current_page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }
        last_page: { type: integer }
    KeyStatus:
      type: object
      required: [brand_id, key_id, scopes]
      properties:
        brand_id: { type: integer }
        key_id: { type: string, description: 'Public key id (the `tuk_<hex>` prefix, without the secret).' }
        name: { type: [string, 'null'] }
        scopes:
          type: array
          items: { $ref: '#/components/schemas/Scope' }
        expires_at: { type: [string, 'null'], format: date-time }
    Scope:
      type: string
      description: A Client API scope in the `{domain}.{resource}:{action}` grammar.
      enum:
        - catalog.products:read
        - catalog.products:write
        - catalog.axes:write
        - catalog.variants:write
        - catalog.stock:read
        - catalog.stock:write
        - catalog.stock:ingest

    # ── Products ──
    ProductListItem:
      type: object
      description: |
        A product list item: the core product fields (id, tu_id, sku, type,
        price_value, price_currency, tax_class, is_active, brand_id, profile,
        timestamps, …) MERGED with the additive superset keys below. Only the
        additive keys are contractually described here; core keys pass through
        the core product serialization (guard G4 — never renamed or removed).
      properties:
        id: { type: integer }
        tu_id: { type: string }
        sku: { type: [string, 'null'] }
        is_active: { type: boolean }
        has_variants:
          type: boolean
          description: True when the product is a variant parent.
        price_range:
          description: Min/max effective price over ACTIVE variants; null when there are none.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/PriceRange'
        availability:
          $ref: '#/components/schemas/AvailabilityStatusOnly'
        stock_summary:
          description: Present only when the key carries `catalog.stock:read` (amendment A4).
          $ref: '#/components/schemas/StockSummary'
      additionalProperties: true
    ProductDetail:
      type: object
      description: |
        Superset product detail: the core product fields merged with the
        additive keys below (`axes`, `variants`, `price_range`, `availability`,
        and `stock_summary` under `catalog.stock:read`). Core keys pass through
        unchanged (G4).
      properties:
        id: { type: integer }
        tu_id: { type: string }
        sku: { type: [string, 'null'] }
        is_active: { type: boolean }
        has_variants: { type: boolean }
        price_range:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/PriceRange'
        availability:
          $ref: '#/components/schemas/AvailabilityStatusOnly'
        stock_summary:
          description: Present only with `catalog.stock:read` (amendment A4).
          $ref: '#/components/schemas/StockSummary'
        axes:
          type: array
          items: { $ref: '#/components/schemas/ProductAxisSummary' }
        variants:
          type: array
          items: { $ref: '#/components/schemas/EmbeddedVariant' }
      additionalProperties: true
    PriceRange:
      type: object
      required: [min, max, currency]
      properties:
        min: { type: [number, 'null'] }
        max: { type: [number, 'null'] }
        currency: { type: [string, 'null'] }
    AvailabilityStatusOnly:
      type: object
      description: Product availability status (merchandising signal, under `catalog.products:read`).
      required: [status]
      properties:
        status:
          type: [string, 'null']
          description: e.g. in_stock, low_stock, out_of_stock, stale.
    StockSummary:
      type: object
      required: [total_quantity, total_reserved]
      properties:
        total_quantity: { type: integer }
        total_reserved: { type: integer }
    ProductAxisSummary:
      type: object
      description: An axis selected on the product, with its selected values (superset detail shape).
      required: [code, label, position, values]
      properties:
        code: { type: string }
        label: { type: [string, 'null'] }
        position: { type: integer }
        values:
          type: array
          items:
            type: object
            required: [value, label, position]
            properties:
              value: { type: string }
              label: { type: [string, 'null'] }
              position: { type: integer }
    EmbeddedVariant:
      type: object
      description: A variant as embedded in the product superset detail.
      required: [tu_id, sku, axis_values, price, position, is_active, availability]
      properties:
        tu_id: { type: string }
        sku: { type: [string, 'null'] }
        barcode: { type: [string, 'null'] }
        axis_values:
          type: object
          description: Map of axis code → selected value.
          additionalProperties: { type: string }
        price: { $ref: '#/components/schemas/VariantPrice' }
        position: { type: integer }
        is_active: { type: boolean }
        availability: { $ref: '#/components/schemas/VariantAvailability' }
    ProductCreate:
      type: object
      required: [sku, title]
      properties:
        sku: { type: string, maxLength: 100 }
        title: { type: string, maxLength: 255 }
        type: { type: [string, 'null'], maxLength: 100, description: 'Defaults to "simple".' }
        price_value: { type: [number, 'null'], minimum: 0 }
        price_currency: { type: [string, 'null'], enum: [USD, EUR] }
        tax_class: { type: [string, 'null'], maxLength: 255 }
        short_description: { type: [string, 'null'], maxLength: 255 }
        long_description: { type: [string, 'null'] }
        is_active: { type: [boolean, 'null'], description: Defaults to false. }
        default_language: { type: [string, 'null'], description: Existing global language code (e.g. en-US); unknown code → 422. }
        versions: { $ref: '#/components/schemas/ProductVersions' }
    ProductUpdate:
      type: object
      description: Partial update; every field is optional. `title` cannot be nulled.
      properties:
        sku: { type: string, maxLength: 100 }
        type: { type: string, maxLength: 100 }
        price_value: { type: [number, 'null'], minimum: 0 }
        price_currency: { type: [string, 'null'], enum: [USD, EUR] }
        tax_class: { type: [string, 'null'], maxLength: 255 }
        title: { type: string, maxLength: 255 }
        short_description: { type: [string, 'null'], maxLength: 255 }
        long_description: { type: [string, 'null'] }
        is_active: { type: boolean }
        versions: { $ref: '#/components/schemas/ProductVersions' }
    ProductVersions:
      type: [object, 'null']
      description: |
        Per-language translations. Each key is an existing global language code;
        an unknown code → 422.
      additionalProperties:
        type: object
        properties:
          title: { type: [string, 'null'], maxLength: 255 }
          short_description: { type: [string, 'null'], maxLength: 255 }
          long_description: { type: [string, 'null'] }

    # ── Axes ──
    Axis:
      type: object
      required: [id, code, label, position, is_active, values]
      properties:
        id: { type: integer }
        code: { type: string }
        label: { type: string }
        position: { type: integer }
        is_active: { type: boolean }
        values:
          type: array
          items: { $ref: '#/components/schemas/AxisValue' }
    AxisValue:
      type: object
      required: [id, value, label, position, is_active]
      properties:
        id: { type: integer }
        value: { type: string }
        label: { type: [string, 'null'] }
        position: { type: integer }
        is_active: { type: boolean }
    EffectiveAxis:
      type: object
      description: |
        An axis effectively selected on a product. `id` is the brand axis id;
        `position` is the per-product position; `values` are the selected brand
        values.
      required: [id, code, label, position, is_active, values]
      properties:
        id: { type: integer }
        code: { type: string }
        label: { type: string }
        position: { type: integer }
        is_active: { type: boolean }
        values:
          type: array
          items: { $ref: '#/components/schemas/AxisValue' }
    AxisCreate:
      type: object
      required: [code, label]
      properties:
        code:
          type: string
          maxLength: 50
          pattern: '^[a-zA-Z0-9._-]+$'
          description: Immutable, unique per brand.
        label: { type: string, maxLength: 255 }
        position: { type: [integer, 'null'], minimum: 0 }
        is_active: { type: boolean, description: Defaults to true. }
        values:
          type: [array, 'null']
          items:
            type: object
            required: [value]
            properties:
              value: { type: string, maxLength: 100, pattern: '^[a-zA-Z0-9._-]+$' }
              label: { type: [string, 'null'], maxLength: 255 }
              position: { type: [integer, 'null'], minimum: 0 }
    AxisUpdate:
      type: object
      description: '`code` is immutable — sending it is a 422.'
      properties:
        label: { type: string, maxLength: 255 }
        position: { type: integer, minimum: 0 }
        is_active: { type: boolean }
    AxisValueCreate:
      type: object
      required: [value]
      properties:
        value: { type: string, maxLength: 100, pattern: '^[a-zA-Z0-9._-]+$' }
        label: { type: [string, 'null'], maxLength: 255 }
        position: { type: [integer, 'null'], minimum: 0 }
    AxisValueUpdate:
      type: object
      description: '`value` is immutable — sending it is a 422.'
      properties:
        label: { type: [string, 'null'], maxLength: 255 }
        position: { type: integer, minimum: 0 }
        is_active: { type: boolean }
    ProductAxesReplace:
      type: object
      required: [axes]
      properties:
        axes:
          type: array
          description: Array order is the per-product axis position. An empty array detaches everything.
          items:
            type: object
            required: [code, values]
            properties:
              code: { type: string, maxLength: 50, pattern: '^[a-zA-Z0-9._-]+$' }
              values:
                type: array
                minItems: 1
                items: { type: string, maxLength: 100, pattern: '^[a-zA-Z0-9._-]+$' }

    # ── Variants ──
    Variant:
      type: object
      description: |
        Full variant shape (AdminVariantResponse). `availability.quantity` is
        stripped when the key lacks `catalog.stock:read` (amendment A4).
      required: [id, tu_id, product_id, sku, price, axis_values, availability, is_active]
      properties:
        id: { type: integer }
        tu_id: { type: string }
        product_id: { type: integer }
        sku: { type: [string, 'null'] }
        barcode: { type: [string, 'null'] }
        price_value: { type: [number, 'null'] }
        price_currency: { type: [string, 'null'] }
        position: { type: integer }
        is_active: { type: boolean }
        axis_values:
          type: object
          description: Map of axis code → selected value.
          additionalProperties: { type: string }
        axis_values_detail:
          type: array
          items:
            type: object
            properties:
              axis_code: { type: string }
              axis_label: { type: [string, 'null'] }
              value: { type: string }
              label: { type: [string, 'null'] }
        price: { $ref: '#/components/schemas/VariantPrice' }
        availability: { $ref: '#/components/schemas/VariantAvailability' }
        created_at: { type: [string, 'null'], format: date-time }
        updated_at: { type: [string, 'null'], format: date-time }
    VariantPrice:
      type: object
      required: [value, currency, inherited]
      properties:
        value: { type: [number, 'null'] }
        currency: { type: [string, 'null'] }
        inherited:
          type: boolean
          description: True when the variant has no own price and inherits the parent product's.
    VariantAvailability:
      type: object
      required: [status]
      properties:
        status: { type: [string, 'null'] }
        quantity:
          type: [integer, 'null']
          description: Present only with `catalog.stock:read` (amendment A4).
    VariantCreate:
      type: object
      required: [sku, axis_values]
      properties:
        sku: { type: string, maxLength: 100 }
        axis_values:
          type: object
          description: One value per axis of the product's effective selection.
          additionalProperties: { type: string, maxLength: 100 }
        price:
          type: object
          properties:
            value: { type: [number, 'null'], description: 'null → inherit the parent product price.' }
        barcode: { type: [string, 'null'], maxLength: 64 }
        position: { type: integer, minimum: 0 }
        is_active: { type: boolean, description: Defaults to true. }
    VariantUpdate:
      type: object
      description: '`axis_values` is immutable — sending it is a 422.'
      properties:
        sku: { type: string, maxLength: 100 }
        barcode: { type: [string, 'null'], maxLength: 64 }
        price:
          type: object
          properties:
            value: { type: [number, 'null'], description: 'null → clear the override (inherit parent price).' }
        position: { type: integer, minimum: 0 }
        is_active: { type: boolean }
    VariantGenerate:
      type: object
      properties:
        dry_run: { type: boolean, description: Preview without writing. }
        activate: { type: boolean, description: Activate generated variants. Defaults to true. }
        sku_template:
          type: string
          maxLength: 100
          description: '`{sku}` → product SKU (or tu_id); `{axis_code}` → slugified value. Falls back to an auto SKU.'
    VariantGenerateDryRun:
      type: object
      required: [total_combinations, would_create, skipped_existing]
      properties:
        total_combinations: { type: integer }
        would_create: { type: integer }
        skipped_existing: { type: integer }
    VariantGenerateResult:
      type: object
      required: [total_combinations, created, skipped_existing, variants]
      properties:
        total_combinations: { type: integer }
        created: { type: integer }
        skipped_existing: { type: integer }
        variants:
          type: array
          items: { $ref: '#/components/schemas/Variant' }

    # ── Stock ──
    StockLocation:
      type: object
      required: [external_code, name, is_active, is_default, pointofsale_tu_id]
      properties:
        external_code: { type: string }
        name: { type: [string, 'null'], description: Mapped from the location label. }
        is_active: { type: boolean }
        is_default: { type: boolean, description: True for the per-brand `__default__` sentinel. }
        pointofsale_tu_id: { type: [string, 'null'], description: Attached POS (read only in v1). }
    StockLocationCreate:
      type: object
      required: [external_code]
      properties:
        external_code: { type: string, maxLength: 191, pattern: '^[a-zA-Z0-9._-]+$' }
        name: { type: [string, 'null'], maxLength: 191 }
        is_active: { type: [boolean, 'null'], description: Defaults to true. }
    StockLocationUpdate:
      type: object
      description: Only `name` and `is_active` are mutable.
      properties:
        name: { type: [string, 'null'], maxLength: 191 }
        is_active: { type: boolean }
    FlatStockLevel:
      type: object
      required: [quantity, reserved, available]
      properties:
        product:
          oneOf:
            - type: 'null'
            - type: object
              properties:
                tu_id: { type: string }
                sku: { type: [string, 'null'] }
        variant:
          oneOf:
            - type: 'null'
            - type: object
              properties:
                tu_id: { type: string }
                sku: { type: [string, 'null'] }
        location:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/LocationRef'
        quantity: { type: integer }
        reserved: { type: integer }
        available: { type: integer, description: 'max(0, quantity - reserved).' }
        source_updated_at: { type: [string, 'null'], format: date-time }
    LocationRef:
      type: object
      properties:
        external_code: { type: string }
        name: { type: [string, 'null'] }
    StockLevel:
      type: object
      required: [quantity, reserved, available]
      properties:
        location:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/LocationRef'
        quantity: { type: integer }
        reserved: { type: integer }
        available: { type: integer }
        source_updated_at: { type: [string, 'null'], format: date-time }
    ProductStock:
      type: object
      required: [product_tu_id, variants, product_levels]
      properties:
        product_tu_id: { type: string }
        availability:
          oneOf:
            - type: 'null'
            - type: object
              properties:
                status: { type: [string, 'null'] }
                aggregate_available_qty: { type: [integer, 'null'] }
                synced_at: { type: [string, 'null'], format: date-time }
        variants:
          type: array
          items:
            type: object
            properties:
              tu_id: { type: string }
              sku: { type: [string, 'null'] }
              is_active: { type: boolean }
              availability:
                type: object
                properties:
                  status: { type: [string, 'null'] }
                  quantity: { type: [integer, 'null'] }
                  synced_at: { type: [string, 'null'], format: date-time }
              levels:
                type: array
                items: { $ref: '#/components/schemas/StockLevel' }
        product_levels:
          type: array
          items: { $ref: '#/components/schemas/StockLevel' }
    StockMovement:
      type: object
      required: [delta, reason, occurred_at]
      properties:
        delta: { type: integer }
        reason: { type: [string, 'null'], description: 'e.g. manual, feed.' }
        note: { type: [string, 'null'] }
        occurred_at: { type: [string, 'null'], format: date-time }
        location:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/LocationRef'
        variant:
          oneOf:
            - type: 'null'
            - type: object
              properties:
                tu_id: { type: string }
                sku: { type: [string, 'null'] }
    StockAdjust:
      type: object
      required: [quantity]
      properties:
        variant:
          type: [string, 'null']
          maxLength: 191
          description: Variant tu_id. Required for a variant-parent product; forbidden for a simple one.
        location:
          type: [string, 'null']
          maxLength: 191
          description: Location external_code. Null resolves to the `__default__` sentinel.
        quantity: { type: integer, minimum: 0, description: The absolute quantity to set. }
        expected_quantity:
          type: [integer, 'null']
          minimum: 0
          description: Optimistic lock — a mismatch is a 409 stock_version_conflict.
        note: { type: [string, 'null'], maxLength: 255 }
    StockAdjustResult:
      type: object
      required: [level, movement]
      properties:
        level:
          type: object
          properties:
            quantity: { type: integer }
            reserved: { type: integer }
            available: { type: integer }
            source_updated_at: { type: [string, 'null'], format: date-time }
            location:
              oneOf:
                - type: 'null'
                - $ref: '#/components/schemas/LocationRef'
            variant:
              oneOf:
                - type: 'null'
                - type: object
                  properties:
                    tu_id: { type: string }
                    sku: { type: [string, 'null'] }
        movement:
          description: Null when the quantity did not change.
          oneOf:
            - type: 'null'
            - type: object
              properties:
                delta: { type: integer }
                reason: { type: string }
                note: { type: [string, 'null'] }
                occurred_at: { type: string, format: date-time }

    # ── Stock feeds ──
    StockFeed:
      type: object
      required: [source, lines]
      properties:
        source: { type: string, maxLength: 191, description: Caller-supplied provenance label (never forced). }
        mode: { type: string, enum: [snapshot], default: snapshot, description: 'Only snapshot is supported in v1 (delta → 422).' }
        generated_at: { type: [string, 'null'], format: date-time }
        lines:
          type: array
          minItems: 1
          description: 'Max lines per batch is configurable (catalog.stock.feed_max_lines, default 10000).'
          items:
            type: object
            required: [sku, quantity]
            properties:
              sku: { type: string, maxLength: 100 }
              location_code: { type: [string, 'null'], maxLength: 191, description: Defaults to the brand's default location. }
              quantity: { type: integer, minimum: 0 }
              reserved: { type: [integer, 'null'], minimum: 0 }
              updated_at: { type: [string, 'null'], format: date-time }
    StockFeedAck:
      type: object
      required: [feed_id, status, received_lines, report_url]
      properties:
        feed_id: { type: string, description: Feed tu_id. }
        status: { type: string, example: queued }
        received_lines: { type: integer }
        report_url: { type: string }
    StockFeedReport:
      type: object
      required: [feed_id, source, status, report_url, lines]
      properties:
        feed_id: { type: string }
        source: { type: [string, 'null'] }
        status: { type: string, description: 'e.g. queued, processing, completed.' }
        totals:
          type: object
          description: Applied/rejected/skipped/parked counters once processed (empty before completion).
          additionalProperties: true
        received_at: { type: [string, 'null'], format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
        report_url: { type: string }
        lines:
          type: object
          description: Paginated line results (Laravel length-aware paginator).
          properties:
            data:
              type: array
              items:
                type: object
                properties:
                  sku: { type: string }
                  location_code: { type: [string, 'null'] }
                  result: { type: [string, 'null'], description: 'e.g. applied, rejected, skipped, parked.' }
                  message: { type: [string, 'null'] }
            current_page: { type: integer }
            per_page: { type: integer }
            total: { type: integer }
            last_page: { type: integer }
          additionalProperties: true
