openapi: 3.0.3

info:
  title: ClosedLoop AI Public API
  version: "1.8.0"
  x-logo:
    url: https://app.closedloop.sh/favicon.svg
    altText: ClosedLoop AI
  description: |
    # ClosedLoop AI Public API

    Programmatic access to your team's **product insights**, the structured intelligence
    ClosedLoop AI extracts from customer conversations (Gong, Fireflies, Slack, …) and
    structured feedback (surveys, webhooks).

    Pull insights, themes, features, customer context and analytics into your own tools.

    ## Two ways to integrate

    | | **This REST API** | **MCP** (live today) |
    |---|---|---|
    | Best for | server-to-server, dashboards, ETL, scripts | AI assistants (Claude, Cursor, …), agents |
    | Shape | resource endpoints, JSON | tool calls over the Model Context Protocol |
    | Auth | `X-API-Key` header | OAuth |

    Most agentic use cases are already covered by MCP. Reach for this REST API when you
    want plain HTTP resources.

    ## Base URLs (region-scoped)

    Your API key belongs to a **team**, and each team lives in exactly one region. Use the
    base URL for **your** region. A key issued in the EU only works against the EU host.

    | Region | Base URL |
    |---|---|
    | United States | `https://api.closedloop.sh/v1` |
    | European Union | `https://eu.api.closedloop.sh/v1` |

    ## Authentication

    Create a key in **Settings → API Keys** in the app, then send it on every request:

    ```
    X-API-Key: clai_live_xxxxxxxxxxxxxxxxxxxx
    ```

    Keys are **team-scoped**: every response is automatically limited to your team's data.
    Never expose a key in client-side code.

    ## Conventions

    - **Read-only**: every v1 endpoint is a `GET`; nothing is mutated. Feedback is ingested
      through your connected integrations, not this API.
    - **Pagination**: list endpoints accept `limit` (default 50, max 200) and `offset`.
      `/insights` also supports fixed-watermark cursor export with `updated_since`.
    - **Errors**: non-2xx responses return a JSON `{ error, code, hint? }` body. User-safe
      messages only; never raw internals.
    - **Rate limits**: the public API has no general fixed per-key quota. Semantic theme
      search uses implementation-defined protective rate and concurrency safeguards.
      Clients must handle `429` and honor `Retry-After`; these safeguards are enforced
      independently by API replica and are not a fleet-wide numeric quota.
    - **Linking insights and context**: both carry the same resolved `customer_id`, so you
      can line up an insight with the context (churn, competitor, satisfaction) for the same
      customer. Filter either list by `customer_id`.
    - **Time anchors**: timestamps are ISO 8601 (UTC).

  contact:
    name: ClosedLoop AI Support
    email: support@closedloop.sh
    url: https://closedloop.sh
  license:
    name: Proprietary (ClosedLoop Labs LLC)
    url: https://closedloop.sh/terms

servers:
  - url: https://api.closedloop.sh/v1
    description: United States (production)
  - url: https://eu.api.closedloop.sh/v1
    description: European Union (production)

security:
  - ApiKeyAuth: []

tags:
  - name: Insights
    description: |
      Individual **product insights**: structured, AI-processed feedback items
      (pain point, severity, workaround, competitor gap, evidence).
  - name: Products
    description: The current product, product-area, and feature-area vocabulary for the team.
  - name: Themes
    description: |
      **Themes**: top-level problem clusters of related insights, RIC-scored. The
      "what should we build?" surface. Each theme rolls up one or more buildable features.
  - name: Features
    description: |
      **Features**: the buildable, shippable children of a theme. Filter by `theme_id`.
      Same theme → features hierarchy you see on the roadmap.
  - name: Customers
    description: The people and accounts who gave feedback, with CRM context.
  - name: Context
    description: |
      **Customer context**, the strategic intelligence behind your customers: churn
      reasons, competitive mentions, satisfaction, pricing perception and more, surfaced
      from conversations. This is the `/context` surface in the app. Each record resolves
      to a customer (`customer_id`), so it lines up with insights for the same customer.
  - name: Competitors
    description: |
      Competitive pressure derived from your customers' own words. Competitors with a
      **mention trend over time** (not a static total), and a searchable feed of the exact
      mentions, each showing the customer behind it.
  - name: Analytics
    description: Trends, cohorts and facet counts over the full dataset.
  - name: Integrations
    description: Connected data sources for your team.
  - name: Usage
    description: Credit consumption and usage records for billing transparency.
  - name: Meta
    description: Service metadata.

x-tagGroups:
  - name: Prioritization
    tags: [Insights, Products, Themes, Features]
  - name: Customers & Context
    tags: [Customers, Context, Competitors]
  - name: Reporting
    tags: [Analytics]
  - name: Account
    tags: [Integrations, Usage]
  - name: Meta
    tags: [Meta]

paths:
  /insights:
    get:
      operationId: listInsights
      tags: [Insights]
      summary: List / search insights
      x-mint:
        metadata:
          description: "Search processed product insights by customer, severity, product scope, date, or text, with offset or fixed-watermark incremental pagination."
      description: |
        Search processed product insights. Filter by category, severity, customer,
        product, product feature, product area, feature area, date range, or a
        free-text query.

        Offset pagination is the default. For a stable incremental export, start with
        `updated_since`; then follow `next_cursor` using only `cursor` and optionally
        `limit`. The fixed export window is `updated_since < updated_at <= sync_until`.

        Product filtering returns insights with a recorded product association. Some
        insights remain unassigned when ClosedLoop AI cannot determine a reliable
        automatic match, so filtered results may not include every potentially relevant
        insight. Responses expose the IDs accepted by all four product-scope filters:
        `products[].id`, `product_features[].id`, `product_area_id`, and `feature_area_id`.
      parameters:
        - $ref: '#/components/parameters/Query'
        - name: category
          in: query
          schema: { type: string, example: "bug" }
          description: Feedback category (e.g. bug, feature request, usability).
        - name: severity
          in: query
          schema: { type: string, enum: [critical, high, medium, low] }
        - name: customer_id
          in: query
          schema: { type: string, format: uuid }
          description: Filter to insights from one customer (same key as on context).
        - $ref: '#/components/parameters/ProductId'
        - $ref: '#/components/parameters/ProductFeatureId'
        - $ref: '#/components/parameters/ProductAreaId'
        - $ref: '#/components/parameters/FeatureAreaId'
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
          example: "2026-05-01T00:00:00Z"
          description: |
            Start a fixed-watermark incremental export after this insight-row update
            timestamp. Cannot be combined with `offset` or `cursor`.

            Must be a timezone-qualified ISO 8601 date-time - the offset is
            required, so `2026-05-01T00:00:00` (no `Z` or `+hh:mm`) is rejected
            with `400 VALIDATION_ERROR`, as is a date-only value.
        - name: cursor
          in: query
          schema: { type: string, maxLength: 8192 }
          description: |
            Opaque continuation cursor returned by the prior incremental page. A cursor
            request may include only `cursor` and `limit`. It is bound to the team, exact
            API key, filters, and watermark.
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of insights
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Insight' }
                  pagination:
                    oneOf:
                      - $ref: '#/components/schemas/Pagination'
                      - $ref: '#/components/schemas/CursorPagination'
              examples:
                offset:
                  summary: Offset page
                  value:
                    data:
                      - id: "123e4567-e89b-42d3-a456-426614174000"
                        title: "Scheduled exports are needed for reporting"
                        category: "feature request"
                        severity: "medium"
                        customer_id: "c0ffee00-0000-4000-8000-000000000001"
                        customer_name: "Acme Co"
                        source: "gong_call_abc123"
                        source_date: "2026-05-14T10:30:00Z"
                        created_at: "2026-05-14T10:32:00Z"
                        updated_at: "2026-05-15T08:12:00Z"
                        feature_area_id: "523e4567-e89b-42d3-a456-426614174004"
                        feature_area_name: "Exports"
                        product_area_id: "423e4567-e89b-42d3-a456-426614174003"
                        product_area_name: "Operations"
                        products:
                          - id: "223e4567-e89b-42d3-a456-426614174001"
                            name: "Core Platform"
                            status: "active"
                        product_features:
                          - id: "323e4567-e89b-42d3-a456-426614174002"
                            title: "Scheduled exports"
                            product_id: "223e4567-e89b-42d3-a456-426614174001"
                    pagination: { total: 1, limit: 50, offset: 0 }
                incremental:
                  summary: Incremental page
                  value:
                    data: []
                    pagination:
                      limit: 200
                      has_more: false
                      next_cursor: null
                      sync_until: "2026-05-14T10:30:00.123456Z"
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/InsightsUnavailable' }

  /insights/{id}:
    get:
      operationId: getInsight
      tags: [Insights]
      summary: Get an insight
      description: |
        Full insight detail with evidence: pain point, workaround, competitor gap,
        willingness to pay, feature area, product associations, and the verbatim quote.
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Insight detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InsightDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /products:
    get:
      operationId: listProducts
      tags: [Products]
      summary: List products
      description: |
        Current product vocabulary for the authenticated team. Draft and active products
        are returned by default. Use these immutable IDs with `product_id` filters.
      parameters:
        - name: include_retired
          in: query
          schema: { type: boolean, default: false }
          description: Include retired products as well as draft and active products.
      responses:
        '200':
          description: Product vocabulary
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Product' }
              example:
                data:
                  - id: "123e4567-e89b-42d3-a456-426614174000"
                    name: "Core Platform"
                    description: "Shared platform capabilities"
                    aliases: ["Platform"]
                    key_features: ["Permissions", "Exports"]
                    url: "https://example.com/products/core"
                    status: "active"
                    source: "manual"
                    sort_order: 0
                    created_at: "2026-04-02T00:00:00Z"
                    updated_at: "2026-05-01T00:00:00Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /areas:
    get:
      operationId: listAreas
      tags: [Products]
      summary: List product and feature areas
      x-mint:
        metadata:
          description: "List active product and feature areas with all-time insight totals and earliest feedback dates, or include archived areas when needed."
      description: |
        Current product-area and feature-area vocabulary. Each area includes its all-time
        insight total and earliest feedback date, so an empty filtered result can be
        distinguished from a scope with little or no coverage. Active areas are returned
        by default.
      parameters:
        - name: include_archived
          in: query
          schema: { type: boolean, default: false }
          description: Include archived product areas and feature areas.
      responses:
        '200':
          description: Nested area vocabulary with all-time coverage totals
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ProductArea' }
              example:
                data:
                  - id: "223e4567-e89b-42d3-a456-426614174000"
                    name: "Operations"
                    description: "Operational workflows"
                    status: "active"
                    sort_order: 0
                    insight_count: 84
                    feedback_since: "2026-01-12T09:00:00Z"
                    feature_areas:
                      - id: "323e4567-e89b-42d3-a456-426614174000"
                        name: "Exports"
                        description: "Data export workflows"
                        status: "active"
                        sort_order: 0
                        insight_count: 84
                        feedback_since: "2026-01-12T09:00:00Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /coverage:
    get:
      operationId: getCoverage
      tags: [Analytics]
      summary: Check evidence coverage
      x-mint:
        metadata:
          description: "Check whether a product or area scope has enough evidence for reliable analysis, including coverage dates and windowed insight totals."
      description: |
        Classifies whether evidence in a product or area scope is informative, too thin,
        or predates team coverage. The current density threshold is 50 distinct insights.
        `policy_version` makes future policy changes explicit.

        `scope.feedback_since` and `scope.all_time_insight_count` ignore the requested date
        window but keep the product and area filters. Windowed counts apply every filter.
      parameters:
        - $ref: '#/components/parameters/ProductId'
        - $ref: '#/components/parameters/ProductFeatureId'
        - $ref: '#/components/parameters/ProductAreaId'
        - $ref: '#/components/parameters/FeatureAreaId'
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
      responses:
        '200':
          description: Coverage verdict and supporting totals
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Coverage' }
              example:
                verdict: "informative"
                reason: "density_threshold_met"
                reads_as: "84 insights across 6 themes cover the requested scope, so a weak match is worth noticing — but it still is not proof that nobody wants it."
                policy_version: "1"
                density_threshold: 50
                filters:
                  product_id: null
                  product_feature_id: null
                  product_area_id: "223e4567-e89b-42d3-a456-426614174000"
                  feature_area_id: null
                date_range: { from: null, to: null }
                scope:
                  insight_count: 84
                  all_time_insight_count: 84
                  theme_count: 6
                  customer_count: 31
                  deal_blocker_count: 4
                  feedback_since: "2026-01-12T09:00:00Z"
                team:
                  insight_count: 508
                  feedback_since: "2025-11-03T12:00:00Z"
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /themes:
    get:
      operationId: listThemes
      tags: [Themes]
      summary: List / search themes
      x-mint:
        metadata:
          description: "List or search top-level product themes by status, product scope, evidence date, or meaning, with deterministic sorting and pagination."
      description: |
        Top-level problem clusters, RIC-scored, most impactful first. Each theme rolls up
        one or more buildable features. Filter by status, product scope, date, or search
        text. Evidence filters recompute `insight_count`, `unique_customer_count`, and
        `deal_blocker_count` from distinct canonical evidence across the theme and its
        active features; themes with no matching evidence are omitted.

        Search is lexical by default: the complete `q` value must occur as one
        case-insensitive substring in the theme title or description. Set
        `search_mode=semantic` to match by meaning instead. Semantic results are ordered
        by similarity descending, then immutable theme ID; this relevance order overrides
        `sort`. If query embedding generation is unavailable, the request returns `503`
        by default. Set `allow_fallback=true` to permit lexical fallback. Always inspect
        the response's `search_mode` and `min_similarity` fields to determine what was
        applied.

        By default, merged themes are excluded. Set `include_retired=true` to add valid
        merged themes to the selected status set. Each returned merged theme has a
        `replacement_theme_id` that points directly to the final surviving theme; callers
        never need to follow a chain. Search and other filters still apply to the retired
        theme's own stored record and evidence.

        The default response is CURATED, not a census: each team has an adaptive
        size floor, computed nightly from that team's own distribution, and very
        small items below it are excluded — the same set the ClosedLoop AI app
        shows. `pagination.total` counts the curated set. Set `show_all=true` for
        every row regardless of size. Teams with too little data to compute a
        floor are never filtered.

        A theme's size is measured through its live features, so the floor cannot
        be evaluated for a finished theme. `status=shipped` and
        `status=declined` therefore return their rows in full, unfiltered by
        size. `include_retired=true` exempts only the retired rows it adds —
        the live themes alongside them stay filtered.
        `/features` differs — a feature is sized by its own insight count, so its
        floor applies whatever its status.
      parameters:
        - name: show_all
          in: query
          schema: { type: boolean, default: false }
          description: "Include themes below the team's adaptive size floor. Default false."
        - name: q
          in: query
          schema: { type: string, maxLength: 500 }
          description: Search text. Required when search_mode is semantic.
        - name: search_mode
          in: query
          schema:
            type: string
            enum: [lexical, semantic]
            default: lexical
          description: Lexical substring matching, or semantic matching against theme embeddings.
        - name: min_similarity
          in: query
          schema:
            type: number
            format: float
            minimum: 0.3
            maximum: 1
            default: 0.5
          description: Minimum cosine similarity for semantic search. Valid only when search_mode is semantic.
        - name: allow_fallback
          in: query
          schema: { type: boolean, default: false }
          description: Permit lexical fallback if semantic search is unavailable. Valid only when search_mode is semantic.
        - name: include_retired
          in: query
          schema: { type: boolean, default: false }
          description: Add valid merged themes, each with its final replacement_theme_id. Other filters still apply.
        - name: status
          in: query
          schema: { type: string, enum: [active, declined, shipped, parked] }
        - name: sort
          in: query
          schema: { type: string, enum: [ric_score, insight_count, unique_customer_count, created_at], default: ric_score }
        - $ref: '#/components/parameters/ProductId'
        - $ref: '#/components/parameters/ProductFeatureId'
        - $ref: '#/components/parameters/ProductAreaId'
        - $ref: '#/components/parameters/FeatureAreaId'
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of themes
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, search_mode, min_similarity]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Theme' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
                  search_mode:
                    type: string
                    enum: [lexical, semantic]
                    description: Search mode actually applied. Lexical indicates lexical/default search or an explicitly allowed fallback.
                  min_similarity:
                    type: number
                    format: float
                    nullable: true
                    description: Applied semantic threshold, or null when search_mode is lexical.
              example:
                data:
                  - id: "a1b2c3d4-e5f6-4a7b-8c9d-000000000001"
                    title: "Payment interruption recovery"
                    description: "Customers need clearer recovery paths after failed transactions."
                    ric_score: 7.8
                    status: "active"
                pagination: { total: 1, limit: 50, offset: 0 }
                search_mode: "semantic"
                min_similarity: 0.5
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ThemesUnavailable' }

  /themes/{id}:
    get:
      operationId: getTheme
      tags: [Themes]
      summary: Get a theme
      x-mint:
        metadata:
          description: "Get one product theme with its severity breakdown, affected customers, supporting insights, buildable features, and merge destination."
      description: |
        Theme detail with its evidence: severity breakdown, affected customers, top
        supporting insights, and its features. If this theme was merged into another,
        the response is `410 Gone` with the final surviving `replacement_theme_id`.
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Theme detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ThemeDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/ThemeRetired' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /features:
    get:
      operationId: listFeatures
      tags: [Features]
      summary: List / search features
      x-mint:
        metadata:
          description: "List or search buildable features by theme, product, area, status, or text, with scoped evidence counts and deterministic sorting."
      description: |
        Buildable features: the shippable children of a theme. Filter by `theme_id`,
        `product_id`, `feature_area_id`, `product_area_id`, `product_feature_id`,
        status, or search text.
        Product fields are null when a feature has not been assigned to a product.

        `feature_area_id` is a subject area from `/areas`, NOT a feature ID from this
        endpoint. The two are different kinds of ID: a feature ID identifies one
        buildable feature and is what `/features/{id}` takes, while a feature-area ID
        identifies the area an insight is filed under. `feature_area_id` therefore
        selects features by the area of their supporting insights.

        With an AREA filter — `feature_area_id`, `product_area_id`, or both —
        `insight_count` and `unique_customer_count` are RECOMPUTED from the distinct
        insights in that scope and the distinct customers behind them; features with
        no matching evidence are omitted, and `pagination.total` counts what is
        returned. `sort=insight_count` and `sort=unique_customer_count` then rank on
        those scoped values. `sort=ric_score` is unchanged by the filter: it stays the
        feature's stored OVERALL score across every area, so a feature can outrank
        another that has more evidence in the selected scope. Every sort resolves ties
        on the immutable feature ID, so paging is deterministic. With NEITHER area
        filter the counts are the feature's stored all-area totals. Every filter on
        this endpoint combines with AND.

        The default response is CURATED, not a census: each team has an adaptive
        size floor, computed nightly from that team's own distribution, and very
        small items below it are excluded — the same set the ClosedLoop AI app
        shows. `pagination.total` counts the curated set. Set `show_all=true` for
        every row regardless of size. Teams with too little data to compute a
        floor are never filtered.

        Unlike `/themes`, this floor applies at every status: a feature is sized
        by its own insight count, which a finished feature still has. That size is the
        feature's OVERALL insight count, so the floor is unaffected by the area
        filters and `show_all=true` does not widen the selected area. The floor and
        the published count therefore read different numbers: a returned feature can
        carry a scoped `insight_count` below the floor, because the floor judged its
        overall size, and a feature whose evidence sits entirely in the selected area
        is still hidden when its overall count is below the floor.
      parameters:
        - name: show_all
          in: query
          schema: { type: boolean, default: false }
          description: "Include features below the team's adaptive size floor. Default false."
        - $ref: '#/components/parameters/Query'
        - name: search_mode
          in: query
          schema:
            type: string
            enum: [lexical, semantic]
            default: lexical
          description: |
            Lexical substring matching against the feature's own title and
            description, or semantic matching against feature embeddings.

            Semantic search returns rows best-match-first and therefore OVERRIDES
            `sort`. Relevance order and size order are mutually exclusive: a caller
            relying on `sort=insight_count` for scope-aware ordering loses it when
            semantic is enabled.

            Semantic requests share ONE rate budget with `/themes`; they are not
            separately allowanced.
        - name: min_similarity
          in: query
          schema:
            type: number
            format: float
            minimum: 0.3
            maximum: 1
            default: 0.5
          description: |
            Minimum cosine similarity for semantic search. Valid only when
            search_mode is semantic.
        - name: allow_fallback
          in: query
          schema: { type: boolean, default: false }
          description: |
            Permit falling back to lexical when an embedding cannot be generated.
            Default false, so such a request fails with 503 rather than silently
            returning a different result set. The response always reports the mode
            that actually ran.
        - name: theme_id
          in: query
          schema: { type: string, format: uuid }
          description: Return only the features under this theme.
        - $ref: '#/components/parameters/ProductId'
        - $ref: '#/components/parameters/FeatureAreaId'
        - name: product_area_id
          in: query
          schema: { type: string, format: uuid }
          description: |
            Return only features with supporting insights filed anywhere under this
            parent product area, with `insight_count` and `unique_customer_count`
            recomputed over exactly that evidence. This is `feature_area_id` one
            level up: a product area is the parent of many feature areas, so it is
            the coarser cut of the same question. Combines with every other filter
            using AND, so pairing it with `feature_area_id` narrows to that one
            child area — and yields nothing when that area sits under a different
            product area.

            ARCHIVED child areas are excluded, which `feature_area_id` does not do:
            naming an archived area directly still counts its evidence, while its
            parent product area does not. A product area whose children are all
            archived therefore returns an empty list. `/areas` with
            `include_archived=true` sums archived children into a product area's
            `insight_count`, so that total can exceed what this filter returns.
        - name: product_feature_id
          in: query
          schema: { type: string, format: uuid }
          description: |
            Return only the feature with this ID. On THIS endpoint the parameter
            names the listed resource itself, so it selects that one feature;
            on `/insights` and `/themes` the same parameter instead means
            "linked to this feature". Combines with every other filter using
            AND, so pairing it with `feature_area_id` answers "does this feature
            have evidence in that area, and how much" in one call. Use
            `/features/{id}` to fetch one feature with its evidence, severity
            breakdown and affected customers.
        - name: status
          in: query
          schema: { type: string, enum: [active, declined, shipped, parked] }
        - name: sort
          in: query
          schema: { type: string, enum: [ric_score, insight_count, unique_customer_count, created_at], default: ric_score }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of features
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, search_mode, min_similarity]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Feature' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
                  search_mode:
                    type: string
                    enum: [lexical, semantic]
                    description: "Search mode actually applied. Lexical indicates lexical/default search or an explicitly allowed fallback."
                  min_similarity:
                    type: number
                    format: float
                    nullable: true
                    description: "Applied semantic threshold, or null when the applied mode was lexical."

        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /features/{id}:
    get:
      operationId: getFeature
      tags: [Features]
      summary: Get a feature
      description: |
        Feature detail with its evidence: severity breakdown, affected customers, and top
        supporting insights.
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Feature detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FeatureDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /customers:
    get:
      tags: [Customers]
      summary: List / search customers
      description: |
        Customers who gave feedback, with CRM context (plan, ARR, active/churned) and
        **account-family** hierarchy: a parent account and its child properties.
      parameters:
        - $ref: '#/components/parameters/Query'
        - name: is_active_customer
          in: query
          schema: { type: boolean }
          description: "Filter on the tri-state customer status. true returns only confirmed active customers; false returns only accounts resolved as not active. Accounts whose status could not be resolved (is_active_customer null) are excluded by BOTH values."
        - name: parent_id
          in: query
          schema: { type: string, format: uuid }
          description: Return the child customers under this parent account (account family).
        - name: parents_only
          in: query
          schema: { type: boolean }
          description: Return only top-level parent accounts, rolling up child properties.
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of customers
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Customer' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /customers/{id}:
    get:
      tags: [Customers]
      summary: Get a customer
      x-mint:
        metadata:
          description: "Get one customer profile with its account summary, product insights, churn state, deals, people, and chronological activity timeline."
      description: |
        Customer profile with history: their insights, churn state, deals, and a timeline.
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Customer detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /context:
    get:
      tags: [Context]
      summary: List customer context
      x-mint:
        metadata:
          description: "List strategic customer context such as churn reasons, competitor mentions, and satisfaction, filtered by customer, type, or date."
      description: |
        Strategic intelligence records: churn reasons, competitive mentions, satisfaction
        and more. Filter by `type`, `customer_id`, or date range. Shares `customer_id` with
        insights, so you can line the two up for the same customer.
      parameters:
        - name: type
          in: query
          schema: { type: string }
          description: >-
            Context type. Common values: churn_risk, churn_reason, competitor_mention,
            satisfaction, win_factor, expansion_opportunity, pricing_perception,
            decision_criteria, general_dislike, deal_blocker (30+ total).
        - name: customer_id
          in: query
          schema: { type: string, format: uuid }
          description: Filter to context for one customer (same key as on insights).
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of context records
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Context' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /competitors:
    get:
      tags: [Competitors]
      summary: List / search competitors
      x-mint:
        metadata:
          description: "List competitors mentioned by customers, including pressure trends and direction, with name search and links to the underlying evidence."
      description: |
        Competitors your customers mention. Each carries a **pressure time series**
        (mention volume per period) plus a trend direction. A raw total tells you nothing;
        the shape (rising vs. cooling) is what matters. Search by name; for the underlying
        quotes use `/competitors/mentions`.
      parameters:
        - $ref: '#/components/parameters/Query'
        - name: granularity
          in: query
          schema: { type: string, enum: [day, week, month], default: week }
          description: Bucket size for each competitor's pressure series.
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of competitors, each with a pressure time series
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Competitor' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /competitors/mentions:
    get:
      tags: [Competitors]
      summary: Search competitor mentions
      x-mint:
        metadata:
          description: "Search verbatim competitor mentions by competitor, customer, text, or date, with source details and newest evidence returned first."
      description: |
        Every competitor mention across your customers, newest first. One row per mention
        with the **competitor**, the **customer** who said it, the verbatim quote,
        the source, and when. Filter by competitor, customer, free text, or date. This is the
        evidence behind the pressure trends.
      parameters:
        - $ref: '#/components/parameters/Query'
        - name: competitor
          in: query
          schema: { type: string }
          description: "Filter by competitor: id or name."
        - name: customer_id
          in: query
          schema: { type: string, format: uuid }
          description: Filter to mentions from one customer.
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of competitor mentions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CompetitorMention' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /trends:
    get:
      tags: [Analytics]
      summary: Time-series trends
      description: |
        Zero-filled time series for a metric over a window. Carries a `feedback_since`
        coverage boundary so early empty buckets aren't misread as "zero activity".
      parameters:
        - name: metric
          in: query
          required: true
          schema: { type: string, enum: [insights, themes, features, context, competitor_mentions] }
        - name: granularity
          in: query
          schema: { type: string, enum: [day, week, month], default: week }
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
      responses:
        '200':
          description: Trend series
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrendSeries' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /facets:
    get:
      tags: [Analytics]
      summary: Facet counts
      description: |
        Available filter values and their counts over the full dataset (categories,
        severities, sources). Useful for building filter UIs.
      responses:
        '200':
          description: Facet map
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: array
                  items:
                    type: object
                    properties:
                      value: { type: string }
                      count: { type: integer }
                example:
                  category: [{ value: "bug", count: 214 }, { value: "feature request", count: 508 }]
                  severity: [{ value: "high", count: 96 }, { value: "medium", count: 402 }]
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /integrations:
    get:
      tags: [Integrations]
      summary: List integrations
      x-mint:
        metadata:
          description: "List the data-source integrations connected to the current ClosedLoop AI team, including each integration's configuration and sync status."
      description: Connected data sources for your team and their sync status.
      responses:
        '200':
          description: Integration list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Integration' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /integrations/{id}:
    get:
      tags: [Integrations]
      summary: Get an integration
      x-mint:
        metadata:
          description: "Get one connected data-source integration for the current ClosedLoop AI team, including its configuration, state, and synchronization status."
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: Integration detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Integration' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /usage:
    get:
      tags: [Usage]
      summary: List usage records
      x-mint:
        metadata:
          description: "List ClosedLoop AI credit-consumption records for the current team, filtered by date and returned with pagination for billing transparency."
      description: Credit consumption records for billing transparency.
      parameters:
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of usage records
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/UsageRecord' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '503': { $ref: '#/components/responses/AuthenticationUnavailable' }

  /health:
    get:
      tags: [Meta]
      summary: Health check
      x-mint:
        metadata:
          description: "Check whether the ClosedLoop AI API is reachable and ready to accept requests; this public liveness endpoint requires no authentication."
      security: []
      description: Liveness probe. No authentication required.
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "ok" }
                  version: { type: string, example: "1.0.0" }
                  region: { type: string, example: "us" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Team-scoped API key created in the app (Settings → API Keys).
        Sent as `X-API-Key: <key>` on every request. The header name `apikey`
        is also accepted as an alias.

  parameters:
    PathId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: Resource UUID.
    Limit:
      name: limit
      in: query
      schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
      description: Page size (max 200).
    Offset:
      name: offset
      in: query
      schema: { type: integer, default: 0, minimum: 0 }
      description: Number of records to skip.
    Query:
      name: q
      in: query
      schema: { type: string }
      description: Free-text search over titles and content.
    ProductId:
      name: product_id
      in: query
      schema: { type: string, format: uuid }
      description: Filter by one product ID from `/products`.
    ProductFeatureId:
      name: product_feature_id
      in: query
      schema: { type: string, format: uuid }
      description: Filter by one buildable product feature ID from `/features`.
    ProductAreaId:
      name: product_area_id
      in: query
      schema: { type: string, format: uuid }
      description: Filter by one parent product-area ID from `/areas`.
    FeatureAreaId:
      name: feature_area_id
      in: query
      schema: { type: string, format: uuid }
      description: |
        Filter by one child feature-area ID from `/areas`. A feature area is the subject
        area an insight is filed under; it is NOT a buildable feature ID from
        `/features`, and the two are never interchangeable.
    DateFrom:
      name: date_from
      in: query
      schema: { type: string, format: date, pattern: '^\d{4}-\d{2}-\d{2}$' }
      example: "2026-05-01"
      description: |
        Inclusive start date, as a real calendar date in `YYYY-MM-DD`.
        Any other form is rejected with `400 VALIDATION_ERROR` - including
        `2026-5-1`, `2026/05/01`, `May 1, 2026`, a bare `2026`, a date-time, and
        impossible dates such as `2026-02-30`.
    DateTo:
      name: date_to
      in: query
      schema: { type: string, format: date, pattern: '^\d{4}-\d{2}-\d{2}$' }
      example: "2026-05-31"
      description: |
        Inclusive end date, as a real calendar date in `YYYY-MM-DD`.
        Validated by the same rule as `date_from`.

  responses:
    ValidationError:
      description: Invalid query parameter, filter combination, or cursor
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            uuid:
              summary: Invalid filter ID
              value: { error: "product_id must be a UUID.", code: "VALIDATION_ERROR" }
            cursor:
              summary: Invalid cursor
              value: { error: "cursor is invalid or expired", code: "VALIDATION_ERROR" }
    Unauthorized:
      description: |
        Missing or invalid API key. `code` is `NO_API_KEY` when the `X-API-Key`
        header is absent, or `INVALID_API_KEY` when a key is present but invalid
        or inactive.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing:
              summary: No key provided
              value: { error: "API key required.", code: "NO_API_KEY", hint: "Send your key in the X-API-Key header." }
            invalid:
              summary: Key invalid or inactive
              value: { error: "Invalid or inactive API key.", code: "INVALID_API_KEY", hint: "Create a key in Settings → API Keys." }
    NotFound:
      description: Resource not found (or not in your team)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: "Not found.", code: "NOT_FOUND" }
    ThemeRetired:
      description: Theme was merged into another theme
      content:
        application/json:
          schema: { $ref: '#/components/schemas/RetiredTheme' }
          example:
            error: "Theme has been merged into another theme."
            code: "THEME_RETIRED"
            replacement_theme_id: "a1b2c3d4-e5f6-4a7b-8c9d-000000000001"
    AuthenticationUnavailable:
      description: API-key authentication infrastructure is temporarily unavailable
      headers:
        Retry-After:
          schema: { type: integer, example: 10 }
          description: Wait 10 seconds before retrying authentication.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            authentication:
              summary: Authentication dependency failure
              value:
                error: "API key authentication is temporarily unavailable. Please retry shortly."
                code: "API_KEY_AUTH_UNAVAILABLE"
    ThemesUnavailable:
      description: API-key authentication or semantic theme search is temporarily unavailable
      headers:
        Retry-After:
          schema: { type: integer }
          examples:
            authentication: { value: 10 }
            semantic_search: { value: 5 }
          description: Wait 10 seconds after an authentication failure, or 5 seconds after a semantic-search failure.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            authentication:
              summary: Authentication dependency failure
              value:
                error: "API key authentication is temporarily unavailable. Please retry shortly."
                code: "API_KEY_AUTH_UNAVAILABLE"
            semantic_search:
              summary: Query embedding generation failed without allowed fallback
              value:
                error: "Semantic search is temporarily unavailable."
                code: "SEMANTIC_SEARCH_UNAVAILABLE"
                hint: "Retry the request, or set allow_fallback=true to permit lexical search."
    InsightsUnavailable:
      description: API-key authentication or incremental insight export is temporarily unavailable
      headers:
        Retry-After:
          schema: { type: integer }
          examples:
            authentication: { value: 10 }
            export: { value: 5 }
          description: Wait 10 seconds after an authentication failure, or 5 seconds after an export failure.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            authentication:
              summary: Authentication dependency failure
              value:
                error: "API key authentication is temporarily unavailable. Please retry shortly."
                code: "API_KEY_AUTH_UNAVAILABLE"
            export:
              summary: Safe incremental-export watermark unavailable
              value:
                error: "Incremental export is temporarily unavailable."
                code: "EXPORT_UNAVAILABLE"
                hint: "Retry the request in a few seconds."
    RateLimited:
      description: Semantic theme search rate or concurrency limit reached
      headers:
        Retry-After:
          schema: { type: integer, example: 1 }
          description: Suggested delay in seconds before retrying.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: "Too many semantic search requests. Please retry shortly."
            code: "RATE_LIMIT_EXCEEDED"

  schemas:
    Error:
      type: object
      required: [error, code]
      properties:
        error: { type: string, description: "User-safe message." }
        code: { type: string, example: "INVALID_API_KEY" }
        hint: { type: string, description: "Optional next step." }

    RetiredTheme:
      type: object
      required: [error, code, replacement_theme_id]
      properties:
        error: { type: string, example: "Theme has been merged into another theme." }
        code: { type: string, enum: [THEME_RETIRED] }
        replacement_theme_id:
          type: string
          format: uuid
          description: Immutable ID of the final surviving theme after following the merge chain.

    Pagination:
      type: object
      required: [total, limit, offset]
      properties:
        total: { type: integer, description: "Total matching records (full set, not the page).", example: 508 }
        limit: { type: integer, example: 50 }
        offset: { type: integer, example: 0 }

    CursorPagination:
      type: object
      required: [limit, has_more, next_cursor, sync_until]
      properties:
        limit: { type: integer, example: 200 }
        has_more: { type: boolean, example: true }
        next_cursor:
          type: string
          nullable: true
          description: Opaque cursor for the next page; null on the final page.
        sync_until:
          type: string
          format: date-time
          example: "2026-05-14T10:30:00.123456Z"
          description: Fixed upper watermark. Commit it as the next checkpoint only after the final page.

    ProductAssociation:
      type: object
      required: [id, name, status]
      properties:
        id: { type: string, format: uuid, description: "Immutable product UUID." }
        name: { type: string, example: "Core Platform" }
        status: { type: string, enum: [draft, active, retired], example: "active" }

    ProductFeatureAssociation:
      type: object
      required: [id, title, product_id]
      properties:
        id:
          type: string
          format: uuid
          description: "Product-feature UUID accepted by the product_feature_id filter."
        title: { type: string, example: "Scheduled exports" }
        product_id:
          type: string
          format: uuid
          nullable: true
          description: "Associated product UUID, or null when the feature is not assigned to a product."

    InsightSummary:
      type: object
      description: Compact insight evidence embedded in theme and feature detail responses.
      required: [id, title, severity, source, created_at]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        category: { type: string }
        severity: { type: string, enum: [critical, high, medium, low] }
        customer_id: { type: string, format: uuid, nullable: true }
        customer_name: { type: string }
        source: { type: string }
        source_date: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    Insight:
      type: object
      required: [id, title, severity, source, created_at, updated_at, products, product_features]
      properties:
        id: { type: string, format: uuid, example: "123e4567-e89b-42d3-a456-426614174000", description: "Immutable UUID; never reassigned to another insight." }
        title: { type: string, example: "Users want a dark mode toggle" }
        category: { type: string, example: "feature request" }
        severity: { type: string, enum: [critical, high, medium, low], example: "medium" }
        customer_id:
          type: string
          format: uuid
          nullable: true
          description: "Resolved customer: the shared key that links an insight to its context (same customer)."
          example: "c0ffee00-0000-4000-8000-000000000001"
        customer_name: { type: string, example: "Acme Co" }
        source: { type: string, example: "gong_call_abc123" }
        source_date: { type: string, format: date-time, example: "2026-05-14T10:30:00Z" }
        created_at: { type: string, format: date-time, example: "2026-05-14T10:32:00Z" }
        updated_at: { type: string, format: date-time, example: "2026-05-15T08:12:00Z", description: "Insight-row update timestamp used by incremental export." }
        feature_area_id: { type: string, format: uuid, nullable: true }
        feature_area_name: { type: string, nullable: true, example: "Exports" }
        product_area_id: { type: string, format: uuid, nullable: true }
        product_area_name: { type: string, nullable: true, example: "Operations" }
        products:
          type: array
          description: |
            Recorded product associations. Empty when the insight is unassigned. Some
            insights cannot be matched automatically with enough confidence.
          items: { $ref: '#/components/schemas/ProductAssociation' }
        product_features:
          type: array
          description: |
            Recorded buildable-feature associations, ordered by title and ID. Empty when
            the insight has no reliable feature match.
          items: { $ref: '#/components/schemas/ProductFeatureAssociation' }

    InsightDetail:
      allOf:
        - $ref: '#/components/schemas/Insight'
        - type: object
          properties:
            content: { type: string, description: "Insight content (always English).", example: "The customer wants a dark mode option for night-time use." }
            quote: { type: string, description: "Verbatim supporting quote.", example: "I'd honestly pay more if you just had a dark theme." }
            pain_point: { type: string, example: "Bright interface is hard to use in low light." }
            workaround: { type: string, example: "Uses a browser dark-mode extension." }
            competitor_gap: { type: string, example: "Competitor X ships native dark mode." }
            willingness_to_pay: { type: string, example: "High" }
            feature_area: { type: string, example: "UI/UX" }
            source_url: { type: string, format: uri, example: "https://gong.io/calls/abc123" }

    Product:
      type: object
      required: [id, name, aliases, key_features, status, source, sort_order, created_at, updated_at]
      properties:
        id: { type: string, format: uuid, description: "Immutable product UUID." }
        name: { type: string, example: "Core Platform" }
        description: { type: string, nullable: true }
        aliases:
          type: array
          items: { type: string }
        key_features:
          type: array
          items: { type: string }
        url: { type: string, format: uri, nullable: true }
        status: { type: string, enum: [draft, active, retired] }
        source: { type: string, enum: [research, manual, pm_tool, crm] }
        sort_order: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    FeatureArea:
      type: object
      required: [id, name, status, sort_order, insight_count]
      properties:
        id: { type: string, format: uuid, description: "Immutable feature-area UUID." }
        name: { type: string, example: "Exports" }
        description: { type: string, nullable: true }
        status: { type: string, example: "active" }
        sort_order: { type: integer, example: 0 }
        insight_count: { type: integer, example: 84, description: "All-time insights assigned directly to this feature area." }
        feedback_since: { type: string, format: date-time, nullable: true, description: "Earliest feedback assigned to this feature area." }

    ProductArea:
      type: object
      required: [id, name, status, sort_order, insight_count, feature_areas]
      properties:
        id: { type: string, format: uuid, description: "Immutable product-area UUID." }
        name: { type: string, example: "Operations" }
        description: { type: string, nullable: true }
        status: { type: string, example: "active" }
        sort_order: { type: integer, example: 0 }
        insight_count: { type: integer, example: 84, description: "Sum of all-time insight counts across returned child feature areas." }
        feedback_since: { type: string, format: date-time, nullable: true, description: "Earliest feedback across returned child feature areas." }
        feature_areas:
          type: array
          items: { $ref: '#/components/schemas/FeatureArea' }

    Coverage:
      type: object
      required: [verdict, reason, reads_as, policy_version, density_threshold, filters, date_range, scope, team]
      properties:
        verdict:
          type: string
          enum: [informative, too_thin, pre_coverage]
          description: Whether absence or weak evidence in the requested scope can be interpreted meaningfully.
        reason:
          type: string
          enum: [no_team_feedback, window_before_team_coverage, empty_scope_window, below_density_threshold, density_threshold_met]
        reads_as: { type: string, description: "Human-readable interpretation of the verdict." }
        policy_version: { type: string, example: "1" }
        density_threshold: { type: integer, example: 50 }
        filters:
          type: object
          required: [product_id, product_feature_id, product_area_id, feature_area_id]
          properties:
            product_id: { type: string, format: uuid, nullable: true }
            product_feature_id: { type: string, format: uuid, nullable: true }
            product_area_id: { type: string, format: uuid, nullable: true }
            feature_area_id: { type: string, format: uuid, nullable: true }
        date_range:
          type: object
          required: [from, to]
          properties:
            from: { type: string, nullable: true }
            to: { type: string, nullable: true }
        scope:
          type: object
          required: [insight_count, all_time_insight_count, theme_count, customer_count, deal_blocker_count, feedback_since]
          properties:
            insight_count: { type: integer, description: "Distinct insights in the filtered date window." }
            all_time_insight_count: { type: integer, description: "Distinct insights for the product/area scope across all time." }
            theme_count: { type: integer, description: "Distinct active top-level themes reached by the filtered insights." }
            customer_count: { type: integer, description: "Distinct customers represented by the filtered insights." }
            deal_blocker_count: { type: integer, description: "Filtered insights marked as deal blockers." }
            feedback_since: { type: string, format: date-time, nullable: true, description: "Earliest feedback for the product/area scope across all time." }
        team:
          type: object
          required: [insight_count, feedback_since]
          properties:
            insight_count: { type: integer, description: "All insights for the authenticated team." }
            feedback_since: { type: string, format: date-time, nullable: true, description: "Earliest feedback for the authenticated team." }

    Theme:
      type: object
      required: [id, title, ric_score, status]
      properties:
        id: { type: string, format: uuid, example: "a1b2c3d4-e5f6-4a7b-8c9d-000000000001", description: "Immutable UUID; never reassigned to another theme." }
        replacement_theme_id:
          type: string
          format: uuid
          description: Present only for a valid merged theme returned by a list request; points directly to the final surviving theme.
        title: { type: string, example: "Dark mode across the product" }
        description: { type: string, example: "Customers repeatedly ask for a dark theme for night-time and accessibility." }
        ric_score: { type: number, example: 7.8, description: "Reach × Impact × Confidence." }
        reach: { type: number, example: 0.62 }
        impact: { type: number, example: 0.8 }
        confidence: { type: number, example: 0.9 }
        insight_count: { type: integer, example: 34, description: "Supporting insights across the theme." }
        unique_customer_count: { type: integer, example: 19 }
        deal_blocker_count: { type: integer, example: 4, description: "Distinct customers represented by deal-blocker evidence." }
        feature_count: { type: integer, example: 4, description: "Buildable features under this theme." }
        status: { type: string, enum: [active, declined, shipped, parked], example: "active" }
        created_at: { type: string, format: date-time, example: "2026-04-02T00:00:00Z" }
        updated_at: { type: string, format: date-time, example: "2026-06-01T00:00:00Z" }

    ThemeDetail:
      allOf:
        - $ref: '#/components/schemas/Theme'
        - type: object
          properties:
            severity_breakdown:
              type: object
              example: { critical: 1, high: 8, medium: 20, low: 5 }
            features:
              type: array
              description: The buildable features under this theme.
              items: { $ref: '#/components/schemas/Feature' }
            top_insights:
              type: array
              items: { $ref: '#/components/schemas/InsightSummary' }
            affected_customers:
              type: array
              items: { $ref: '#/components/schemas/Customer' }

    Feature:
      type: object
      required: [id, theme_id, title, ric_score, status]
      properties:
        id: { type: string, format: uuid, example: "b2c3d4e5-f6a7-4b8c-9d0e-000000000002" }
        theme_id: { type: string, format: uuid, example: "a1b2c3d4-e5f6-4a7b-8c9d-000000000001", description: "The parent theme." }
        title: { type: string, example: "Dark mode toggle in settings" }
        description: { type: string, example: "A user-controllable dark / light toggle in the settings panel." }
        ric_score: { type: number, example: 6.9 }
        insight_count: { type: integer, example: 12, description: "Supporting insights across every area. On `GET /features` with `feature_area_id`, this is instead recomputed from the insights in the selected area only. See `/features/{id}` for how the detail response derives it." }
        unique_customer_count: { type: integer, example: 9, description: "Distinct customers behind those insights, scoped the same way as `insight_count`." }
        product_id: { type: string, format: uuid, nullable: true, description: "Assigned product, if any." }
        product_name: { type: string, nullable: true, example: "Core Platform" }
        status: { type: string, enum: [active, declined, shipped, parked], example: "active" }
        created_at: { type: string, format: date-time, example: "2026-04-05T00:00:00Z" }
        updated_at: { type: string, format: date-time, example: "2026-06-01T00:00:00Z" }

    FeatureDetail:
      allOf:
        - $ref: '#/components/schemas/Feature'
        - type: object
          properties:
            insight_count:
              type: integer
              description: "Distinct supporting insights across every area, counted from the feature's current evidence. This detail response is never scoped by `feature_area_id`, and it can differ from the same field on `GET /features` without a filter, which reports the stored rollup."
            unique_customer_count:
              type: integer
              description: "Distinct customers behind those insights, counted the same way as `insight_count` above."
            deal_blocker_count:
              type: integer
              description: "Distinct customers represented by deal-blocker evidence."
            severity_breakdown:
              type: object
              example: { critical: 0, high: 3, medium: 7, low: 2 }
            top_insights:
              type: array
              items: { $ref: '#/components/schemas/InsightSummary' }
            affected_customers:
              type: array
              items: { $ref: '#/components/schemas/Customer' }

    Customer:
      type: object
      required: [id, name]
      properties:
        id: { type: string, format: uuid, example: "c0ffee00-0000-4000-8000-000000000001" }
        name: { type: string, example: "Acme Co" }
        parent_id: { type: string, format: uuid, nullable: true, description: "Parent account (account family), if this is a child property." }
        parent_name: { type: string, nullable: true, description: "Parent account name." }
        domain: { type: string, example: "acme.com" }
        industry: { type: string, example: "Hospitality" }
        employee_count: { type: integer, example: 250 }
        arr: { type: number, nullable: true, example: 48000, description: "Annualized recurring revenue: SUM of won-deal annual_value (team currency)." }
        currency: { type: string, example: "USD" }
        is_active_customer: { type: boolean, nullable: true, example: true, description: "Tri-state. true = a confirmed active customer; false = resolved and not an active customer; null = the account could not be resolved, so the status is unknown. Never read null as false." }
        churn_state: { type: string, enum: [active, at_risk, churned], example: "active" }
        insight_count: { type: integer, example: 23 }
        last_seen: { type: string, format: date-time, example: "2026-06-10T09:00:00Z" }

    Context:
      type: object
      required: [id, type, content, created_at]
      properties:
        id: { type: string, format: uuid, example: "51617100-0000-4000-8000-000000000001" }
        type: { type: string, example: "churn_reason", description: "Context type: see the /context 'type' filter for common values (30+)." }
        content: { type: string, example: "They're evaluating Competitor X because our reporting is too slow." }
        customer_id:
          type: string
          format: uuid
          nullable: true
          description: "Resolved customer: same key as on insights; correlate the two by this."
          example: "c0ffee00-0000-4000-8000-000000000001"
        customer_name: { type: string, example: "Acme Co" }
        source: { type: string, example: "gong_call_def456" }
        created_at: { type: string, format: date-time, example: "2026-05-20T14:00:00Z" }

    Competitor:
      type: object
      required: [id, name]
      properties:
        id: { type: string, format: uuid, example: "0e11e100-0000-4000-8000-000000000001" }
        name: { type: string, example: "Competitor X" }
        customer_count: { type: integer, example: 12, description: "Distinct customers who mentioned this competitor." }
        total_mentions: { type: integer, example: 41, description: "Every mention naming this competitor, including mentions that name several. Context only; read `pressure` for the trend." }
        trend: { type: string, enum: [rising, steady, falling], example: "rising", description: "Direction of the pressure series." }
        pressure:
          type: array
          description: Mention volume per period is the pressure trend. A single sum says nothing; the change over time matters.
          items:
            type: object
            properties:
              period: { type: string, example: "2026-05-11" }
              mentions: { type: integer, example: 7 }
        first_mentioned: { type: string, format: date-time, example: "2026-03-02T00:00:00Z" }
        last_mentioned: { type: string, format: date-time, example: "2026-06-18T00:00:00Z" }
        summary: { type: string, example: "Most-cited alternative on lost deals; wins on native reporting." }

    CompetitorMention:
      type: object
      required: [id, competitor_id, mentioned_at]
      properties:
        id: { type: string, format: uuid, example: "51617100-0000-4000-8000-000000000009" }
        competitor_id: { type: string, format: uuid, example: "0e11e100-0000-4000-8000-000000000001", description: "The FIRST competitor named. Read `competitors` when a mention names more than one." }
        competitor_name: { type: string, example: "Competitor X" }
        competitors:
          type: array
          description: "Every competitor this mention names, in the order the customer said them. A single mention often compares several."
          items:
            type: object
            properties:
              id: { type: string, format: uuid, example: "0e11e100-0000-4000-8000-000000000001" }
              name: { type: string, example: "Competitor X" }
        customer_id: { type: string, format: uuid, nullable: true, example: "c0ffee00-0000-4000-8000-000000000001" }
        customer_name: { type: string, example: "Acme Co" }
        quote: { type: string, description: "Verbatim mention.", example: "We're also trialing Competitor X because their reporting is faster." }
        source: { type: string, example: "gong_call_def456" }
        mentioned_at: { type: string, format: date-time, example: "2026-05-20T14:00:00Z" }

    TrendSeries:
      type: object
      properties:
        metric: { type: string, example: "insights" }
        granularity: { type: string, example: "week" }
        feedback_since: { type: string, format: date-time, example: "2026-02-01T00:00:00Z" }
        note: { type: string, example: "Buckets before feedback_since are empty due to coverage, not zero activity." }
        points:
          type: array
          items:
            type: object
            properties:
              period: { type: string, example: "2026-05-11" }
              value: { type: integer, example: 34 }

    Integration:
      type: object
      required: [id, type, name, status]
      properties:
        id: { type: string, format: uuid, example: "in7e6600-0000-4000-8000-000000000001" }
        type: { type: string, example: "gong" }
        name: { type: string, example: "Gong Production" }
        status: { type: string, enum: [active, inactive, error, pending], example: "active" }
        last_sync_at: { type: string, format: date-time, example: "2026-06-10T10:00:00Z" }
        created_at: { type: string, format: date-time, example: "2026-01-01T00:00:00Z" }

    UsageRecord:
      type: object
      required: [id, type, credits, timestamp]
      properties:
        id: { type: string, format: uuid, example: "05a6e100-0000-4000-8000-000000000001" }
        type: { type: string, example: "feedback_processing" }
        credits: { type: integer, example: 5 }
        source: { type: string, example: "gong_call_abc123" }
        timestamp: { type: string, format: date-time, example: "2026-05-14T10:32:00Z" }
