openapi: 3.1.0

# ─────────────────────────────────────────────────────────────────────────────
# Ext Ops Panel – Custom Metrics Contract (native v2).
#
# This document describes the endpoint that an OBSERVED extension's own service
# IMPLEMENTS, and that the Ext Ops Panel PULLS on a schedule / on demand. The
# panel is a generic renderer: each metric carries its own presentation (where
# and how to show it on the dashboard and in the Telegram status). See the human
# reference at https://extops.dev/docs/custom-metrics and the canonical prose
# contract in docs/custom-metrics-contract.md.
#
# CONTRACT_VERSION: 2.0.0   (single sync marker – see info.version /
#   info.x-contract-version below; enforced by scripts/check-contract-sync.sh)
# ─────────────────────────────────────────────────────────────────────────────

info:
  title: Ext Ops Panel – Custom Metrics Contract (source endpoint)
  version: 2.0.0
  x-contract-version: "2.0.0"
  summary: The metrics endpoint your extension's service implements; Ext Ops Panel pulls it.
  description: |
    This is the contract for the metrics endpoint that YOUR extension's own
    backend service exposes. Ext Ops Panel acts as the CLIENT: it pulls this
    endpoint (never the other way round) and renders whatever the payload
    declares. Secrets stay on the panel backend; the dashboard extension never
    receives them.

    Two pull modes share the SAME response shape:
      * on-demand (B) – triggered by a user action (opening Detail), rate limited
        to <= 1 request per minute per source, available on all plans including Free;
      * monitoring (C) – on a schedule (Trial/Paid only), composes the Telegram
        status and edge alerts.

    Only the native v2 schema (schema_version "2.0") is supported. Unknown
    `kind` / `render` values are ignored by the renderer (forward compatible).
  x-note: >-
    The `kind` and `render` enums are an OPEN set. A renderer ignores unknown
    values rather than failing, so a source may ship a newer kind/render than a
    given panel build understands. The enums below are the values the current
    panel build renders natively (contract sections 3 and 4).
  contact:
    name: Ext Ops Panel
    url: https://extops.dev/docs/custom-metrics
  license:
    name: Documentation (c) Ext Ops Panel

servers:
  - url: https://your-extension-service.example.com
    description: The source service you host and register with the panel (placeholder host).

security:
  - bearer: []
  - apiKeyHeader: []
  - {} # auth "none" is also allowed (contract section 9)

paths:
  /metrics:
    get:
      operationId: getCustomMetrics
      summary: Return the current custom-metrics payload (native v2).
      description: |
        Ext Ops Panel PULLS this endpoint; your service IMPLEMENTS it. The panel
        calls it with GET at most once per minute per source (on-demand mode) or
        on the monitoring schedule for Trial/Paid plans. The response body is the
        v2 Payload: metrics plus their layout (dashboard tabs/sections and the
        Telegram lines). Return the freshest snapshot you have; the panel handles
        history and rendering. On failure the panel degrades gracefully (last
        snapshot / hidden) without breaking the first-party base metrics.
      security:
        - bearer: []
        - apiKeyHeader: []
        - {}
      responses:
        "200":
          description: Current metrics payload.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Payload"
        "401":
          description: Missing or invalid credentials (when auth is bearer / api key).
        "429":
          description: Rate limited (the panel throttles itself to <= 1/min; sources may also enforce).

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: >-
        Bearer token issued by the source service. The panel stores it encrypted
        at rest and sends it as `Authorization: Bearer <token>`. Placeholder only –
        never commit a real token.
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-Api-Key
      description: >-
        Static API key sent in a request header (contract auth mode
        `api_key_header`). Stored encrypted at rest on the panel backend.

  schemas:

    Payload:
      type: object
      description: Top-level pull response of the source service (contract section 1).
      required:
        - schema_version
        - metrics
      properties:
        schema_version:
          type: string
          const: "2.0"
          description: Payload schema version. Required. Must be "2.0" for native v2.
        generated_at:
          type: string
          format: date-time
          description: When the source composed this payload (freshness / idle detection).
        tabs:
          type: array
          description: Dashboard tabs (title, order, optional icon). Tab "base" is the panel's own.
          items:
            $ref: "#/components/schemas/Tab"
        sections:
          type: array
          description: Sections that group metrics inside a tab.
          items:
            $ref: "#/components/schemas/Section"
        telegram:
          $ref: "#/components/schemas/TelegramMeta"
        metrics:
          type: array
          description: The metrics themselves (value + placement blocks). See Metric.
          items:
            $ref: "#/components/schemas/Metric"

    Tab:
      type: object
      description: A dashboard tab in the Detail tab-bar (contract section 1).
      required:
        - id
        - title
      properties:
        id:
          type: string
          description: Stable tab id referenced by Section.tab and Metric.dashboard.tab.
        title:
          type: string
          description: Human-readable tab title (shown when active; tooltip/aria when collapsed).
        order:
          type: integer
          description: Sort order within the tab-bar (ties broken by array order).
        icon:
          type: string
          description: |
            Optional tab icon. Either a Lucide icon name in kebab-case ASCII
            (matching ^[a-z][a-z0-9-]*$) OR a single emoji. A well-formed but
            unknown Lucide name degrades to the default icon (`square`); anything
            that is not a well-formed name renders as text (emoji). The panel
            ships a curated subset of 37 Lucide names (see x-lucide-icon-names).
          examples:
            - layout-dashboard
            - trending-up
            - "💰"
      x-lucide-icon-names:
        - layout-dashboard
        - activity
        - gauge
        - layers
        - plug
        - dollar-sign
        - banknote
        - wallet
        - credit-card
        - receipt
        - bar-chart-3
        - line-chart
        - pie-chart
        - trending-up
        - trending-down
        - users
        - user
        - alert-triangle
        - bell
        - clock
        - git-branch
        - package
        - box
        - database
        - server
        - cpu
        - zap
        - globe
        - puzzle
        - funnel
        - filter
        - square
        - circle
        - hash
        - percent
        - tag
        - flag

    Section:
      type: object
      description: A titled group of metrics inside a tab (contract section 1).
      required:
        - tab
        - id
      properties:
        tab:
          type: string
          description: Id of the owning Tab.
        id:
          type: string
          description: Section id referenced by Metric.dashboard.section.
        title:
          type: string
          description: Human-readable section title.
        order:
          type: integer
          description: Sort order within the tab.

    TelegramMeta:
      type: object
      description: Telegram message layout – header plus logical lines (contract sections 1 and 5).
      properties:
        header:
          type: string
          description: >-
            Header template of the message, e.g.
            "{name} • {cws.users} • {cws.rating}★ ({cws.rating_count})".
        lines:
          type: array
          description: Logical lines the composer fills from metrics sharing each line id.
          items:
            $ref: "#/components/schemas/TelegramLine"

    TelegramLine:
      type: object
      description: One logical line of the Telegram status (contract sections 1 and 5).
      required:
        - id
      properties:
        id:
          type: string
          description: Line id referenced by Metric.telegram.line. "base" is the panel's own.
        order:
          type: integer
          description: Sort order of lines in the message.
        prefix:
          type: string
          description: >-
            Text printed before the line's fields; may contain placeholders such
            as "{sev} Conv " (contract section 5).
        when:
          $ref: "#/components/schemas/WhenCondition"

    WhenCondition:
      type: string
      description: |
        Visibility condition for a metric field or a whole Telegram line
        (contract section 7). Open set; unknown values behave as "always".
      enum:
        - always
        - nonzero
        - severity>=warn
        - changed
      default: always

    Severity:
      type: string
      description: >-
        Severity of a metric – maps to color / emoji ({sev}: 🟢 ok, 🔵 info,
        🟡 warn, 🔴 crit). See contract section 5.
      enum:
        - ok
        - info
        - warn
        - crit

    Metric:
      type: object
      description: |
        A single metric: its typed value plus two independent placement blocks
        (dashboard and telegram). Either block may be omitted or hidden via
        `show:false` – a metric can appear on one surface only. See contract
        sections 2, 3, 5, 7.
      required:
        - id
        - kind
      properties:
        id:
          type: string
          description: Stable metric id (unique within the payload).
        kind:
          type: string
          description: |
            Data type of the metric – selects which value fields apply (contract
            section 3). OPEN set: unknown kinds are ignored by the renderer.
          enum:
            - number
            - delta
            - percent
            - duration
            - bytes
            - rating
            - money
            - status
            - series
            - intraday
            - hour_histogram
            - funnel
            - list
            - text
            - breakdown
        label:
          type: string
          description: Canonical human label of the metric.
        severity:
          $ref: "#/components/schemas/Severity"
        shareable:
          type: boolean
          default: true
          description: |
            Per-metric data-sharing opt-out (contract section 2.1, EP-084). Optional;
            defaults to true. When false the metric is OWNER-ONLY: on a project shared
            with read-only observers (the custom_metrics category) it is stripped on the
            server and never reaches the observer / public view. Use for financial or
            otherwise private metrics. Independent of dashboard.show / telegram.show
            (placement) and of `when` (display condition) — this governs ACCESS.

        # ── value fields by kind (contract section 3). Present depending on kind. ──
        value:
          description: >-
            Primary value. number/percent/rating/money/text/delta use it; percent
            is 0–100; text is a string.
        delta:
          type: number
          description: Change vs previous period. Used by delta/number/money (rendered "1200 (+72)").
        unit:
          type: string
          description: Optional unit suffix for number.
        seconds:
          type: number
          description: Duration in seconds (kind duration) – alternative to minutes.
        minutes:
          type: number
          description: Duration in minutes (kind duration) – alternative to seconds.
        bytes:
          type: number
          description: Size in bytes (kind bytes) – alternative to kb.
        kb:
          type: number
          description: Size in kilobytes (kind bytes) – alternative to bytes.
        count:
          type: integer
          description: Sample count for rating ("5.0★ (1)").
        currency:
          type: string
          description: ISO currency code for money.
        state:
          type: string
          description: State string for kind status (paired with severity).
        points:
          type: array
          description: Time series points for kind series.
          items:
            type: object
            properties:
              date:
                type: string
              count:
                type: number
        value_kind:
          type: string
          enum:
            - duration
          description: >-
            kind intraday only: what the values ARE. `duration` means SECONDS and is
            rendered humanized (10m 32s) on the axis, the readout and the a11y table;
            omitted means a plain compact number.
        series:
          type: array
          description: >-
            Named multi-series for kind series or kind intraday (identity only – panel
            owns color). A series carries `points` (daily, kind series) OR `intraday`
            (today buckets, kind intraday), not both.
          items:
            type: object
            properties:
              name:
                type: string
              points:
                type: array
                items:
                  type: object
              intraday:
                type: array
                description: >-
                  kind intraday: today buckets. Each item is {minute, value, weight?} —
                  `minute` counts from the UTC day start, `value` null means NO DATA (the
                  line breaks instead of dropping to 0) and the optional `weight` is how
                  many observations the value aggregates (dots mode scales by it).
                description: >-
                  Today's fixed-width buckets for kind intraday. `minute` = minutes from the
                  UTC day start (0, 15, …, 1425). `value` null (or a MISSING bucket) means NO
                  DATA – the line breaks there instead of dropping to 0 (a 0 would lie for an
                  average). Send only non-empty buckets; the panel densifies.
                items:
                  type: object
                  properties:
                    minute:
                      type: integer
                    value:
                      type: number
                      nullable: true
        step_min:
          type: integer
          default: 15
          description: >-
            Bucket width in minutes for kind intraday (default 15 → 96 buckets/UTC day).
            Advisory `tz` may accompany it, but the buckets themselves are UTC by contract.
        weekdays:
          type: array
          description: hour_histogram weekday buckets – see contract section 3 for the full shape.
          items:
            type: object
        steps:
          type: array
          description: Funnel steps (kind funnel).
          items:
            type: object
            properties:
              key:
                type: string
              label:
                type: string
              value:
                type: number
        items:
          type: array
          description: |
            Items for kind list ({text, count?, severity?}) or kind breakdown
            ({label, value, unit?, sub?/money?}). Untrusted label/sub render as text.
          items:
            type: object

        # ── DASHBOARD placement: where and how in the extension (contract section 4). ──
        dashboard:
          type: object
          description: Where and how to show the metric on the dashboard. Omit to hide there.
          properties:
            show:
              type: boolean
              description: Show on the dashboard. `false` keeps the block but hides the tile.
            tab:
              type: string
              description: Owning Tab id.
            section:
              type: string
              description: Owning Section id.
            order:
              type: integer
              description: Sort order within the section.
            name:
              type: string
              description: Dashboard name (may differ from the metric label).
            render:
              type: string
              description: |
                Visual to use (usually matches kind, but may differ – e.g. show a
                percent as a badge). OPEN set: unknown renders are ignored.
                Color is assigned BY THE PANEL from brand tokens; a source-supplied
                `color` is advisory and ignored (the panel owns the palette).
              enum:
                - kpi
                - badge
                - chart
                - intraday
                - hour_histogram
                - funnel
                - table
                - text
                - status
                - list
                - breakdown
            options:
              type: object
              description: >-
                Render options (e.g. chart series {key,label,order} and defaultMode,
                table columns, breakdown items). Series carry identity only, not color.
                For render `intraday`: `mode` = `bars` (stacked) | `lines` (smoothed,
                the default).

        # ── TELEGRAM placement: where and how in the message (contract sections 2, 5, 7). ──
        telegram:
          type: object
          description: Where and how to show the metric in the Telegram message. Omit to hide there.
          properties:
            show:
              type: boolean
              description: Include in the Telegram message.
            line:
              type: string
              description: Telegram line id (from telegram.lines) this metric belongs to.
            pos:
              type: integer
              description: Position within the line (ties broken by array order).
            format:
              type: string
              description: >-
                Value template with placeholders, e.g. "today {value}" or
                "{value} ({delta:+})" (contract section 5).
            sep:
              type: string
              description: Separator printed before the next field on the line.
            when:
              $ref: "#/components/schemas/WhenCondition"
