# [ASSUMPTION: drop-in fields verified against ADR-007 only; appendix E absent — this is the
# working contract until appendix E lands and golden-baseline tests are run against §3/§6.]
# All [ДОПУЩЕНИЕ]/[ASSUMPTION] markers below flag pending appendix-E verification per ADR-007.

openapi: 3.1.0
info:
  title: Lupa Search Response API
  version: "1.0.0-draft"
  description: >
    Contract for Lupa (ADR-007). Drop-in compatible with multisearch.io on fields
    query/total/results.item_groups/corrected. Fields filters and fallback are
    optional Lupa extensions; the client MUST ignore unknown fields.
  x-adr: ADR-007
  x-status: Proposed (draft for gate)
  contact:
    name: Lupa Tech Lead

servers:
  - url: https://api.lupa.io
    description: Edge / Backoffice API host (search, suggest, events, analytics)
  - url: https://cdn.lupa.io
    description: CDN host (bootstrap bundle only)

tags:
  - name: search
  - name: suggest
  - name: events
  - name: analytics
  - name: bootstrap

paths:
  /v1/search:
    get:
      tags: [search]
      operationId: search
      summary: Product search with category grouping, facets, correction, and fallback
      description: >
        Read-only to OpenSearch (hot-path invariant, start.md §6). p95 < 100 ms cache-miss.
        Correction order: keyboard (Edge) -> OS analyzer (cross_script/morphology) -> fallback.
      security:
        - widgetKey: []
      parameters:
        - $ref: '#/components/parameters/Q'
        - $ref: '#/components/parameters/Locale'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Facets'
        - $ref: '#/components/parameters/FilterParam'
        - $ref: '#/components/parameters/Origin'
      responses:
        '200':
          description: >
            Success. Body always contains query/total/results. corrected — only when
            correction fires. filters — only with ?facets=true and available facets.
            fallback — only when total=0 and a meaningful fallback exists.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-Cache: { $ref: '#/components/headers/XCache' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SearchResponse' }
              examples:
                withFacets: { $ref: '#/components/examples/SearchWithFacets' }
                noResultsFallback: { $ref: '#/components/examples/SearchFallback' }
                keyboardCorrected: { $ref: '#/components/examples/SearchKeyboardCorrected' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/suggest:
    get:
      tags: [suggest]
      operationId: suggest
      summary: Autocomplete by prefix (top-N popular queries + prefix-match)
      description: >
        Edge cache 5 min (start.md F-11, §5). p95 < 30 ms. Top-N source — ClickHouse,
        prefix-match — against categories and products.
      security:
        - widgetKey: []
      parameters:
        - name: q
          in: query
          required: true
          description: Prefix. Empty or shorter than min_chars -> empty suggestions array, not 400.
          schema: { type: string, minLength: 0, maxLength: 256 }
        - $ref: '#/components/parameters/Locale'
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 20, default: 10 }
        - $ref: '#/components/parameters/Origin'
      responses:
        '200':
          description: List of suggestions (may be empty).
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-Cache: { $ref: '#/components/headers/XCache' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuggestResponse' }
              example:
                query: "сере"
                suggestions:
                  - { text: "сережки", type: query, score: 4120 }
                  - { text: "сережки золоті", type: query, score: 980 }
                  - { text: "Сережки", type: category, category_id: "earrings", url: "https://shop.example/c/earrings" }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/events:
    post:
      tags: [events]
      operationId: ingestEvents
      summary: Receive a batch of events from the widget
      description: >
        Authentication by public tracker_id + Origin-allowlist + server-side sampling
        (start.md F-07/§5, anti-spoof). 202 is returned only after durable receipt in NATS
        JetStream (ADR-003). Idempotency by event_id (dedup in ClickHouse). Closed
        enum event_type finalized in ADR-003 — MVP set fixed here.
      security:
        - trackerId: []
      parameters:
        - $ref: '#/components/parameters/Origin'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EventBatch' }
            example:
              tracker_id: "8b1f-uuid"
              visitor_id: "rnd-anon-id"
              events:
                - { event_id: "11111111-1111-1111-1111-111111111111", event_type: "find", ts: "2026-05-27T10:00:00Z", query: "сережки", total: 42 }
                - { event_id: "22222222-2222-2222-2222-222222222222", event_type: "noresults", ts: "2026-05-27T10:00:05Z", query: "ктулху" }
                - { event_id: "33333333-3333-3333-3333-333333333333", event_type: "click_offer", ts: "2026-05-27T10:00:10Z", query: "сережки", offer_id: "SKU-001", position: 1 }
      responses:
        '202':
          description: >
            Batch accepted durably. accepted/rejected reflect partial validation (ADR-003):
            invalid events in a batch are rejected individually; valid ones are accepted.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventBatchAck' }
              example:
                accepted: 2
                rejected: 1
                request_id: "req_abc123"
                rejected_details:
                  - { index: 2, code: "invalid_event", message: "missing offer_id for click_offer" }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/analytics/report:
    get:
      tags: [analytics]
      operationId: analyticsReport
      summary: OLAP aggregates for the dashboard (top queries / noresults / clicks, etc.)
      description: >
        Dashboard section mapping to report types (start.md F-08 v0.2, core <=4 sections):
        top_queries, top_noresults, top_clicks, conversion. Phase 3 adds top_corrections (F-08 remaining).
        For trend views (F-18): use granularity=hour|day with top_queries or top_noresults —
        rows contain key=bucket ISO timestamp, count=searches, extra.noresults for noresult count.
      security:
        - analyticsKey: []
      parameters:
        - name: report
          in: query
          required: true
          description: >
            Aggregate type (dashboard section -> query mapping).
            D-007-06 additive: top_corrections added Phase 3 Plan 03.
          schema:
            type: string
            enum: [top_queries, top_noresults, top_clicks, conversion, top_corrections]
        - name: tracker_id
          in: query
          required: true
          description: >
            Locale/index for the report. The tenant is derived from analytics_key;
            tracker_id MUST belong to the same tenant, otherwise 403.
          schema: { type: string }
        - name: from
          in: query
          required: true
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: true
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 1000, default: 50 }
        - name: granularity
          in: query
          required: false
          description: For trend breakdown (F-18, Could). On MVP — total.
          schema: { type: string, enum: [total, hour, day], default: total }
        - name: format
          in: query
          required: false
          description: >
            Response format (D-007-06 additive, REQ-F17 CSV export).
            json — JSON body (default, unchanged). csv — text/csv body with
            Content-Disposition: attachment and a filename.
            Every CSV cell is guarded against formula injection (=,+,-,@ → prefix ').
          schema: { type: string, enum: [json, csv], default: json }
      responses:
        '200':
          description: Aggregate of the requested type.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalyticsReport' }
              example:
                report: top_queries
                tracker_id: "zv-ua"
                from: "2026-05-01T00:00:00Z"
                to: "2026-05-27T00:00:00Z"
                rows:
                  - { key: "сережки", count: 4120, noresults_rate: 0.03 }
                  - { key: "ланцюжок", count: 2980, noresults_rate: 0.11 }
            text/csv:
              schema:
                type: string
                description: >
                  RFC-4180 CSV with a header row. Columns vary by report type:
                  top_queries/top_noresults — key,count,noresults_rate;
                  top_clicks — offer_id,clicks; conversion — metric,numerator,denominator,value;
                  top_corrections — correction,count,kind;
                  trend (granularity=hour|day) — bucket,searches,noresults.
                  All cells are formula-injection-guarded (T-03-02-01).
              example: "key,count,noresults_rate\r\nсережки,4120,0.03\r\nланцюжок,2980,0.11"
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/analytics/goals:
    get:
      tags: [analytics]
      operationId: analyticsGoals
      summary: Conversions / goals (correction formula from ADR-003)
      description: >
        Returns conversion metrics including "correction conversion"
        (spellcheck+keyboard -> non-noresults), formula from ADR-003 (numerator/denominator/window).
      security:
        - analyticsKey: []
      parameters:
        - name: tracker_id
          in: query
          required: true
          schema: { type: string }
        - name: from
          in: query
          required: true
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: true
          schema: { type: string, format: date-time }
        - name: goal
          in: query
          required: false
          description: Specific goal; empty -> all available.
          schema:
            type: string
            enum: [correction_conversion, search_to_click, click_to_cart]
      responses:
        '200':
          description: Conversion metrics.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalyticsGoals' }
              example:
                tracker_id: "zv-ua"
                from: "2026-05-01T00:00:00Z"
                to: "2026-05-27T00:00:00Z"
                goals:
                  - { goal: correction_conversion, value: 0.72, numerator: 1180, denominator: 1639 }
                  - { goal: search_to_click, value: 0.41, numerator: 34200, denominator: 83400 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/theme-settings:
    get:
      tags: [analytics]
      operationId: getThemeSettings
      summary: Get the current widget theme for a tracker (D-007-06 additive)
      description: >
        Returns the current widget theme (css_vars, breakpoints, powered_by) for the
        given tracker_id. Requires session-token Bearer auth (BL-03, never analytics_key
        in the browser). tracker_id must belong to the authenticated tenant (cross-tenant 403).
        REQ-F16 widget fine-tuning.
      security:
        - analyticsKey: []
      parameters:
        - name: tracker_id
          in: query
          required: true
          description: >
            Locale tracker_id. Must belong to the authenticated tenant; otherwise 403.
          schema: { type: string }
      responses:
        '200':
          description: Current widget theme settings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ThemeSettings' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    put:
      tags: [analytics]
      operationId: putThemeSettings
      summary: Save widget theme settings (D-007-06 additive)
      description: >
        Persists css_vars / breakpoints / powered_by to the Postgres theme table, then
        writes through to CF KV (key_version bumped → CDN ETag busts). Session-token Bearer
        auth (BL-03). Sanitization: unknown --lupa-* keys or values with ;{}\n → 400.
        Paid-tier gate: powered_by=false is only accepted when tenant.plan='paid'; free-tier
        attempts are silently coerced to powered_by=true. REQ-F16.
      security:
        - analyticsKey: []
      parameters:
        - name: tracker_id
          in: query
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ThemeSettingsPatch' }
            example:
              css_vars:
                '--lupa-color-primary': '#c8a04e'
                '--lupa-font-size-base': '15px'
              breakpoints:
                mobile: 480
                tablet: 768
              powered_by: false
      responses:
        '200':
          description: Saved widget theme settings (after sanitization + paid-tier gate).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ThemeSettings' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /{tracker_id}.js:
    servers:
      - url: https://cdn.lupa.io
    get:
      tags: [bootstrap]
      operationId: bootstrapBundle
      summary: Widget bootstrap bundle (shared bundle + per-tenant config injection)
      description: >
        start.md F-01. Single round-trip, <=50KB gzipped. Config (keys/locale/theme/
        breakpoints) injected from CF KV. Cache invalidated on revoke/key change (F-09).
      parameters:
        - name: tracker_id
          in: path
          required: true
          description: Public UUID of tenant/locale. Unknown -> 404 (see §4).
          schema: { type: string }
      responses:
        '200':
          description: JS bundle with injected config.
          headers:
            Cache-Control:
              schema: { type: string, example: "public, max-age=60, s-maxage=300" }
            ETag:
              schema: { type: string }
          content:
            application/javascript:
              schema: { type: string }
        '404':
          description: Unknown tracker_id. Body is a JS no-op comment, not JSON (safe for script tags — does not throw).
          content:
            application/javascript:
              schema: { type: string, example: "/* lupa: unknown tracker */" }

components:

  securitySchemes:
    widgetKey:
      type: apiKey
      in: header
      name: X-Lupa-Widget-Key
      description: >
        Tenant widget_key. Effectively public (embedded in the bundle), protected by Origin-allowlist.
        The server additionally checks the Origin header against the tenant's allowlist.
        Also accepted via query (?widget_key=) for compatibility — see parameters.
    trackerId:
      type: apiKey
      in: header
      name: X-Lupa-Tracker-Id
      description: >
        Public tracker_id for /v1/events. Anti-spoof protection — Origin-allowlist +
        server-side sampling (start.md §5). HMAC batch signing — optional (ADR-010).
        Also accepted in the batch body (tracker_id) for drop-in simplicity.
    analyticsKey:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: >
        Tenant analytics_key (Bearer). Stored hashed on the server (F-09).
        Used only by the dashboard, not the widget.

  parameters:
    Q:
      name: q
      in: query
      required: true
      description: >
        Search query (text). Accepted AS TEXT ONLY; OpenSearch query_string DSL
        is NOT exposed (input-bounds, §5). Empty q -> behavior per §4 (200 + fallback.top_overall).
      schema: { type: string, minLength: 0, maxLength: 256 }
    Locale:
      name: locale
      in: query
      required: false
      description: >
        Locale/index. Allowed on MVP: ua, ru, en. Not provided -> tenant default locale
        from config (not 400). Unknown locale -> 400 invalid_locale.
      schema: { type: string, enum: [ua, ru, en] }
    Page:
      name: page
      in: query
      required: false
      description: Page number, 1-based. Name chosen as drop-in-neutral and human-readable.
      schema: { type: integer, minimum: 1, default: 1 }
    PerPage:
      name: per_page
      in: query
      required: false
      description: Page size. Upper cap protects against expensive requests (input-bounds, §5).
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    Facets:
      name: facets
      in: query
      required: false
      description: Whether to return the filters block (F-12). Default false to minimize latency.
      schema: { type: boolean, default: false }
    FilterParam:
      name: filter
      in: query
      required: false
      explode: true
      description: >
        Applied facet filters. Format field:value (terms/toggle) or
        field:min..max (range). Repeatable (?filter=metal:gold&filter=price:0..5000).
      schema:
        type: array
        items: { type: string, maxLength: 128 }
    Origin:
      name: Origin
      in: header
      required: false
      description: Standard CORS Origin. Checked against the tenant's Origin-allowlist.
      schema: { type: string }

  headers:
    XRequestId:
      description: Request identifier for log/trace correlation (F-20).
      schema: { type: string }
    XCache:
      description: HIT|MISS (edge cache). For separate p95 HIT/MISS measurement (§5/§8.1).
      schema: { type: string, enum: [HIT, MISS] }

  schemas:

    SearchResponse:
      type: object
      required: [query, total, results]
      additionalProperties: true   # tolerance: future additive fields (D-007-06)
      description: >
        [ASSUMPTION: verify against appendix E when it lands — drop-in core fields
        query/total/results.item_groups/corrected must match multisearch.io 1:1]
      properties:
        query:
          type: string
          description: "[DROP-IN] Echo of query (after normalization/correction — see corrected)."
        total:
          type: integer
          minimum: 0
          description: "[DROP-IN] Total number of products found (before pagination)."
        results:
          $ref: '#/components/schemas/Results'
        corrected:
          $ref: '#/components/schemas/Corrected'
        filters:
          type: array
          description: "[LUPA EXTENSION] Facets. Only with ?facets=true. multisearch.io widget ignores."
          items: { $ref: '#/components/schemas/Filter' }
        fallback:
          $ref: '#/components/schemas/Fallback'

    Results:
      type: object
      required: [item_groups]
      description: >
        [DROP-IN] Container for grouped results.
        [ASSUMPTION: grouping structure is exactly results.item_groups[] (group=category) — verify vs appendix E]
      properties:
        item_groups:
          type: array
          description: Groups = categories. Each contains items[].
          items: { $ref: '#/components/schemas/ItemGroup' }

    ItemGroup:
      type: object
      required: [category, items]
      description: "[DROP-IN] Result group by category."
      properties:
        category:
          $ref: '#/components/schemas/Category'
        total:
          type: integer
          minimum: 0
          description: Number of products in this category (may exceed len(items) due to intra-group pagination).
        items:
          type: array
          items: { $ref: '#/components/schemas/Item' }

    Category:
      type: object
      required: [id, title]
      properties:
        id: { type: string }
        title: { type: string }
        url: { type: string, format: uri-reference }

    Item:
      type: object
      required: [id, title, url, available]
      description: "[DROP-IN] Product card. Fields id/title/price/url/image/available/category."
      properties:
        id:
          type: string
          description: Stable offer.id from YML feed (dedup/versioning key).
        title:
          type: string
        price:
          $ref: '#/components/schemas/Price'
        url:
          type: string
          format: uri-reference-reference
        image:
          type: string
          format: uri-reference-reference
          description: URL of the main image. May be absent for some offers.
        available:
          type: boolean
          description: Availability (available field from feed; updated via stock delta <=15 min).
        category:
          $ref: '#/components/schemas/Category'

    Price:
      type: object
      required: [amount, currency]
      description: >
        Price object.
        [ASSUMPTION: multisearch.io may return price as a number/string —
        verify vs appendix E; on divergence, align to their form, keeping amount/currency as Lupa extension]
      properties:
        amount: { type: number, minimum: 0 }
        currency: { type: string, description: "ISO-4217, e.g. UAH" }
        old_amount:
          type: number
          minimum: 0
          description: Old price (for displaying discount), optional.

    Corrected:
      type: object
      required: [original, corrected, kind]
      description: >
        [DROP-IN core + LUPA EXTENSION kind] Present only when correction fires.
        kind=keyboard — Lupa extension (F-06); spellcheck/cross_script — F-14.
      properties:
        original:
          type: string
          description: Original user input.
        corrected:
          type: string
          description: Corrected query actually used for search.
        kind:
          type: string
          enum: [keyboard, spellcheck, cross_script]
          description: >
            keyboard — layout misfire (latin->cyrillic, Edge); spellcheck — typo;
            cross_script — RU<->UA cross-script (F-14).

    Filter:
      type: object
      required: [field, label, type]
      description: "[LUPA EXTENSION, F-12] One facet. multisearch.io widget ignores the entire filters array."
      properties:
        field:
          type: string
          description: Machine field name (from <param> in the feed), e.g. metal.
        label:
          type: string
          description: Human-readable label for UI, e.g. Metal.
        type:
          type: string
          enum: [terms, range, toggle]
        values:
          type: array
          description: For type=terms — options with counts.
          items:
            type: object
            required: [value, count]
            properties:
              value: { type: string }
              label: { type: string }
              count: { type: integer, minimum: 0 }
              selected: { type: boolean, default: false }
        range:
          type: object
          description: For type=range — bounds.
          properties:
            min: { type: number }
            max: { type: number }
            selected_min: { type: number }
            selected_max: { type: number }
            unit: { type: string }
        toggle:
          type: object
          description: For type=toggle — boolean facet (e.g. in stock).
          properties:
            count: { type: integer, minimum: 0 }
            selected: { type: boolean, default: false }

    Fallback:
      type: object
      required: [kind, items]
      description: >
        [LUPA EXTENSION, F-13] Present only when total=0. kind defines the meaning of items.
        multisearch.io widget ignores; Lupa widget renders "Maybe you were looking for X"
        or "Category hits".
      properties:
        kind:
          type: string
          enum: [did_you_mean, category_top_sellers, top_overall]
        message:
          type: string
          description: Ready-made localized label for UI (optional).
        suggested_query:
          type: string
          description: Only for kind=did_you_mean — the suggested query.
        category:
          $ref: '#/components/schemas/Category'
        items:
          type: array
          description: Substitute products (category hits / overall hits / did_you_mean products).
          items: { $ref: '#/components/schemas/Item' }

    SuggestResponse:
      type: object
      required: [query, suggestions]
      properties:
        query: { type: string }
        suggestions:
          type: array
          items:
            type: object
            required: [text, type]
            properties:
              text: { type: string }
              type: { type: string, enum: [query, category, product] }
              score: { type: number }
              category_id: { type: string }
              product_id: { type: string }
              url: { type: string, format: uri-reference }

    EventBatch:
      type: object
      required: [tracker_id, events]
      properties:
        tracker_id: { type: string }
        visitor_id:
          type: string
          description: Random anonymous id (no PII, GDPR §5).
        events:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: '#/components/schemas/Event' }

    Event:
      type: object
      required: [event_id, event_type, ts]
      description: >
        Closed enum event_type finalized in ADR-003. Per-type required fields
        (e.g. offer_id for click_offer, corrected_from+corrected_to for spellcheck/keyboard)
        validated server-side; invalid -> rejected.
      properties:
        event_id:
          type: string
          format: uuid
          description: Idempotency key (dedup in ClickHouse, ADR-003).
        event_type:
          type: string
          enum: [find, noresults, click_offer, spellcheck, keyboard, add2cart_offers]
        ts: { type: string, format: date-time }
        query: { type: string }
        total: { type: integer }
        offer_id: { type: string }
        position: { type: integer }
        corrected_from:
          type: string
          description: >
            Original (uncorrected) query. Required for event_type=spellcheck and
            event_type=keyboard (mirrors event.go validation). Absent for other types.
        corrected_to:
          type: string
          description: >
            Corrected query that was actually searched. Required for event_type=spellcheck
            and event_type=keyboard. Absent for other types.
        corrected_kind:
          type: string
          enum: [keyboard, spellcheck, cross_script]
          description: >
            Correction kind — aligns with the Corrected.kind field in SearchResponse.
            Optional (sent when available).

    EventBatchAck:
      type: object
      required: [accepted, rejected, request_id]
      properties:
        accepted: { type: integer, minimum: 0 }
        rejected: { type: integer, minimum: 0 }
        request_id: { type: string }
        rejected_details:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              code: { type: string }
              message: { type: string }

    AnalyticsReport:
      type: object
      required: [report, tracker_id, from, to, rows]
      properties:
        report:
          type: string
          # D-007-06 additive: top_corrections added Phase 3. Trend rows (key=bucket ISO) use
          # the same shape: key=bucket, count=searches, extra.noresults for noresult count.
          enum: [top_queries, top_noresults, top_clicks, conversion, top_corrections]
        tracker_id: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        rows:
          type: array
          items:
            type: object
            required: [key, count]
            properties:
              key: { type: string }
              count: { type: integer }
              noresults_rate: { type: number }
              extra: { type: object, additionalProperties: true }

    AnalyticsGoals:
      type: object
      required: [tracker_id, from, to, goals]
      properties:
        tracker_id: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        goals:
          type: array
          items:
            type: object
            required: [goal, value]
            properties:
              goal: { type: string, enum: [correction_conversion, search_to_click, click_to_cart] }
              value: { type: number }
              numerator: { type: integer }
              denominator: { type: integer }

    ThemeSettings:
      type: object
      required: [css_vars, breakpoints, powered_by, plan]
      description: >
        Widget theme settings (REQ-F16). css_vars contains only known --lupa-* CSS custom
        properties. powered_by reflects the "Powered by Lupa" toggle (free tier always true).
      properties:
        css_vars:
          type: object
          description: CSS custom property overrides. Keys must be known --lupa-* variables.
          additionalProperties: { type: string }
        breakpoints:
          type: object
          description: Responsive breakpoint configuration (mobile/tablet/results_per_bp).
          additionalProperties: true
        powered_by:
          type: boolean
          description: >
            "Powered by Lupa" badge toggle. false only on paid plan; free tier always true.
        plan:
          type: string
          description: Tenant plan — 'free' | 'paid'. Controls powered_by editability.

    ThemeSettingsPatch:
      type: object
      required: [css_vars, breakpoints, powered_by]
      description: >
        Theme settings update body. Sanitized server-side: unknown keys or unsafe values
        (containing ;{}\n) → 400. powered_by=false coerced to true on free-tier tenants.
      properties:
        css_vars:
          type: object
          description: >
            CSS custom property overrides. Only known --lupa-* keys accepted (30-key allowlist).
            Values must not contain ; { } or newlines (T-03-01-01 CSS injection guard).
          additionalProperties: { type: string }
        breakpoints:
          type: object
          description: Responsive breakpoint configuration.
          additionalProperties: true
        powered_by:
          type: boolean
          description: >
            "Powered by Lupa" badge toggle. Set false to hide the badge (paid plan only).
            Free-tier requests with powered_by=false are coerced to true by the server.

    Error:
      type: object
      required: [error]
      additionalProperties: false
      properties:
        error:
          type: object
          required: [code, message, request_id]
          additionalProperties: false
          properties:
            code:
              type: string
              description: >
                Machine code. Vocabulary: bad_request, invalid_locale, query_too_long,
                unauthorized, forbidden_origin, invalid_key, not_found, rate_limited,
                upstream_unavailable.
              enum:
                - bad_request
                - invalid_locale
                - query_too_long
                - unauthorized
                - forbidden_origin
                - invalid_key
                - not_found
                - rate_limited
                - upstream_unavailable
            message:
              type: string
              description: Human-readable description (for logs/debug, not for end customers).
            request_id:
              type: string
              description: Matches X-Request-Id (F-20, correlation).

  responses:
    BadRequest:
      description: Invalid request (empty/too-long q in strict mode, bad locale/filter).
      headers: { X-Request-Id: { $ref: '#/components/headers/XRequestId' } }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: query_too_long, message: "q exceeds 256 chars", request_id: "req_1" } }
    Unauthorized:
      description: Missing or invalid key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: invalid_key, message: "missing or invalid key", request_id: "req_2" } }
    Forbidden:
      description: Origin not in allowlist / key does not match tracker_id / wrong tenant.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: forbidden_origin, message: "origin not allowed", request_id: "req_3" } }
    NotFound:
      description: Unknown resource/tracker_id on JSON endpoints.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: not_found, message: "unknown tracker_id", request_id: "req_4" } }
    TooManyRequests:
      description: Rate limit / quota exceeded (F-19).
      headers:
        Retry-After:
          schema: { type: integer, description: Seconds until next attempt. }
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: rate_limited, message: "rate limit exceeded", request_id: "req_5" } }
    ServiceUnavailable:
      description: >
        Upstream (OpenSearch) unavailable. Widget renders graceful fallback degrading
        to the store's native <form> (start.md §5 Resilience). /v1/events continues accepting.
      headers:
        Retry-After:
          schema: { type: integer }
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: upstream_unavailable, message: "search backend unavailable", request_id: "req_6" } }

  examples:
    SearchWithFacets:
      summary: "q=сережки золоті&locale=ua&facets=true"
      value:
        query: "сережки золоті"
        total: 37
        results:
          item_groups:
            - category: { id: "earrings", title: "Сережки", url: "/c/earrings" }
              total: 37
              items:
                - id: "SKU-1001"
                  title: "Сережки золоті з фіанітами"
                  price: { amount: 4200, currency: "UAH", old_amount: 5100 }
                  url: "/p/SKU-1001"
                  image: "https://cdn.shop/img/1001.jpg"
                  available: true
                  category: { id: "earrings", title: "Сережки" }
        filters:
          - field: "metal"
            label: "Метал"
            type: "terms"
            values:
              - { value: "gold", label: "Золото", count: 30, selected: false }
              - { value: "silver", label: "Срібло", count: 7, selected: false }
          - field: "price"
            label: "Ціна"
            type: "range"
            range: { min: 800, max: 18000, unit: "UAH" }
          - field: "in_stock"
            label: "В наявності"
            type: "toggle"
            toggle: { count: 31, selected: false }
    SearchFallback:
      summary: "q=ктулху (0 results) -> fallback"
      value:
        query: "ктулху"
        total: 0
        results:
          item_groups: []
        fallback:
          kind: "category_top_sellers"
          message: "Нічого не знайдено. Можливо, вас зацікавить:"
          category: { id: "bestsellers", title: "Хіти продажів", url: "/c/best" }
          items:
            - id: "SKU-2001"
              title: "Ланцюжок золотий"
              price: { amount: 6200, currency: "UAH" }
              url: "/p/SKU-2001"
              image: "https://cdn.shop/img/2001.jpg"
              available: true
    SearchKeyboardCorrected:
      summary: "q=ctht;rb -> keyboard correction"
      value:
        query: "сережки"
        total: 37
        corrected: { original: "ctht;rb", corrected: "сережки", kind: "keyboard" }
        results:
          item_groups:
            - category: { id: "earrings", title: "Сережки" }
              total: 37
              items: []
