openapi: 3.1.0

# ─────────────────────────────────────────────────────────────────────────────
# Ext Ops Panel – Monitoring Contract (push heartbeat + uptime pings).
#
# This document describes the report intake endpoint that Ext Ops Panel
# IMPLEMENTS and that an agent on YOUR service's host (a cron script such as
# monitor.sh) CALLS on a declared schedule. Missing calls are themselves a
# signal (dead-man's switch). The payload reuses the metadata-driven v2 shape
# of the custom-metrics contract (/custom-metrics-openapi.yaml), plus the
# reserved `overall` metric. See the human reference at
# https://extops.dev/docs/monitoring and the canonical prose contract in
# docs/monitoring-contract.md.
#
# MONITORING_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 – Monitoring Contract (report intake endpoint)
  version: 2.0.0
  x-contract-version: "2.0.0"
  summary: The heartbeat report endpoint the panel implements; your monitoring agent (a heartbeat) pushes to it.
  description: |
    Monitoring puts an extension under constant watch with a computed traffic
    light on its list card and Telegram reactions. It has two independently
    enabled components:

      * push heartbeat – an agent on your service's host POSTs a report to
        this endpoint at a declared interval;
      * uptime pings – the panel itself periodically checks availability of
        public URLs you configure (status-code class only, never the content).

    HEARTBEATS AND PINGS (v2.0). Monitoring lives at the extension
    REGISTRATION level as two independent flat lists that share one slot pool:
    HEARTBEATS (push producers, formerly "stands") and UPTIME PINGS (pull
    checks). Each heartbeat POSTs to this endpoint with its OWN bearer token
    and declares its own presentation (tabs/sections + Telegram lines); the
    bearer token identifies the heartbeat. Uptime pings belong to the
    registration, not to a heartbeat. The panel merges everything into ONE
    aggregate traffic light (worst-of), ONE Detail surface and ONE summary
    Telegram status message. Heartbeats and pings share a unified per-extension
    slot pool (one heartbeat = one slot; one active ping = one slot; sum <= the
    plan's max_slots).

    TRAFFIC LIGHT SEMANTICS. The per-extension aggregate is the worst-of
    roll-up across every enabled heartbeat's hb_component and every active
    registration ping's light, where severities order as ok < warn < crit and
    map to green | yellow | red. The heartbeat component
    is `max(reported, watchdog)`: `reported` is the severity of the `overall`
    metric from the last ACCEPTED report; `watchdog` is a freshness floor
    computed from the panel's own receive clock: 1 missed report escalates to
    yellow (warn), 2 or more missed reports escalate to red (crit), with
    `grace = max(60s, 25% of the declared interval)` to absorb cron and
    network jitter. A rejected report (401/422/429) never refreshes the
    watchdog, so a broken agent honestly escalates. States `off` (monitoring
    not enabled / nothing configured) and `paused` (plan gate or manual pause)
    sit outside the formula and render as a neutral gray dot.

    LIMITS. Payload at most 64 KB; at most 200 metrics per report; rate limit
    of at most 2 reports per minute per source (excess gets 429 and is not
    counted as a heartbeat).

    RESERVED IDS. The metric id `overall` (kind `status`) is REQUIRED in every
    report – its severity (ok | warn | crit; intake aliases: warning -> warn,
    error -> crit, critical -> crit) feeds the aggregate. The metric id
    `issues` (kind `list`) is RECOMMENDED – its items feed alert texts. The
    metric-id namespace `ping:` and the Telegram line id `pings` are reserved
    for the panel's own first-party ping metrics and are FORBIDDEN in the
    pushed payload (422). The dashboard tab id `monitoring` is the panel-owned
    base monitoring tab: metrics may address it without declaring it in
    `tabs` (a declaration is ignored – the panel owns its title/icon/order).
  x-uptime-pings: |
    Uptime pings are a flat list at the extension REGISTRATION level (not nested
    under any heartbeat), executed by the panel, not described by a path here;
    their semantics are part of this contract version. For each configured ping the
    panel performs GET with a 10s timeout, follows at most 5 redirects
    (each hop re-checked by the SSRF guard, https only), reads no body beyond
    a minimal limit, and treats a FINAL 2xx-class status as success. A final
    3xx (redirect loop / limit), 4xx, 5xx, timeout or DNS/TLS/network error is
    a failure. A failed check is confirmed by one retry after ~15s before it
    counts (anti-blip). Consecutive confirmed failures drive the per-ping
    light: 0 -> ok, 1 -> warn, 2 or more -> crit; the first success resets to
    ok. The number of pings per extension and the minimum interval are plan
    limits (Trial/Indie 3, Studio 10, Fleet 25; min interval 5m/5m/1m/1m).
    Results are materialized as first-party metrics `ping:<name>` on the base
    `monitoring` tab and the reserved Telegram line `pings`.
  contact:
    name: Ext Ops Panel
    url: https://extops.dev/docs/monitoring
  license:
    name: Documentation (c) Ext Ops Panel

servers:
  - url: https://extops.dev
    description: Ext Ops Panel API (the panel implements this endpoint; your agent calls it).

security:
  - monitoringToken: []

paths:
  /api/v1/monitoring/report:
    post:
      operationId: postMonitoringReport
      summary: Push a heartbeat report (metadata-driven v2 payload + required `overall`).
      description: |
        Your monitoring agent CALLS this endpoint; Ext Ops Panel implements it.
        Send the freshest snapshot at your declared interval. An accepted
        report upserts the last snapshot (the panel is visualization-only and
        keeps no metric history), refreshes the watchdog clock and immediately
        recomputes the aggregate traffic light. Rejected reports (401 / 422 /
        429) do NOT refresh the watchdog and do NOT touch the snapshot; the
        last 422 reason is stored and shown in the monitoring settings.

        A report with `generated_at` older than the current snapshot is still
        accepted (the call itself counts for the watchdog) but the snapshot is
        not rolled back.

        Reports are accepted while monitoring is enabled, including the
        `paused` state (snapshot is stored, but the traffic light and alerts
        stay frozen).
      security:
        - monitoringToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportPayload"
      responses:
        "200":
          description: Report accepted. The agent may log this for self-diagnostics.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportResponse"
        "401":
          description: Missing, invalid or revoked monitoring token. Not counted as a heartbeat.
        "422":
          description: |
            Invalid payload, rejected as a whole. Causes: broken v2 schema; no
            valid `overall` metric (kind `status`, severity ok | warn | crit or
            an accepted alias); a metric id in the reserved `ping:` namespace;
            the reserved Telegram line id `pings`. Not counted as a heartbeat;
            the reason is stored and visible in the monitoring settings.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "429":
          description: Rate limited (more than 2 reports per minute per source). Not counted as a heartbeat.

components:
  securitySchemes:
    monitoringToken:
      type: http
      scheme: bearer
      description: >-
        Monitoring bearer token, issued once when a heartbeat is enabled (or
        rotated). It resolves the heartbeat (and its extension registration).
        The panel stores only a hash at rest; a revoked token gets 401 and the
        watchdog escalates.

  schemas:

    ReportPayload:
      type: object
      description: |
        The pushed report – the same metadata-driven v2 shape as the
        custom-metrics contract (schema_version "2.0"): layout (tabs, sections,
        Telegram lines) plus the metrics array. Monitoring specifics:
          * the `metrics` array MUST contain the reserved `overall` metric
            (see OverallMetric) – otherwise the report is rejected with 422;
          * the `issues` metric (kind `list`) is recommended;
          * metric ids prefixed `ping:` and the Telegram line id `pings` are
            reserved for the panel and rejected with 422;
          * metrics may address the panel-owned base tab id `monitoring`
            without declaring it in `tabs`;
          * `telegram.lines` compose a SEPARATE monitoring status message
            (distinct from the custom-metrics status message).
        Unknown `kind` / `render` values are ignored (forward compatible), but
        a missing valid `overall` rejects the whole report.
      required:
        - schema_version
        - metrics
      properties:
        schema_version:
          type: string
          const: "2.0"
          description: Payload schema version. Required. Must be "2.0" (native v2).
        generated_at:
          type: string
          format: date-time
          description: >-
            When the agent composed this report. Informational: the watchdog
            uses the panel's own receive clock, so agent clock skew is harmless.
        tabs:
          type: array
          description: >-
            Dashboard tabs declared by the agent (e.g. "stand"). The base tab
            id `monitoring` is panel-owned and must NOT be declared (a
            declaration is ignored).
          items:
            $ref: "#/components/schemas/Tab"
        sections:
          type: array
          description: Sections that group metrics inside a tab (agent tabs or the base `monitoring` tab).
          items:
            $ref: "#/components/schemas/Section"
        telegram:
          $ref: "#/components/schemas/TelegramMeta"
        metrics:
          type: array
          maxItems: 200
          description: >-
            The metrics (at most 200). MUST include the reserved `overall`
            metric; MUST NOT include ids in the reserved `ping:` namespace.
          items:
            $ref: "#/components/schemas/Metric"

    ReportResponse:
      type: object
      description: Returned on 200 – lets the agent self-diagnose.
      required:
        - accepted
        - effective
      properties:
        accepted:
          type: boolean
          const: true
          description: The report was accepted and the snapshot upserted.
        effective:
          $ref: "#/components/schemas/TrafficLightState"
        next_expected_by:
          type: string
          format: date-time
          description: >-
            When the panel expects the next report (interval + grace). Missing
            it escalates the watchdog: 1 missed report -> yellow, 2 or more -> red.

    ValidationError:
      type: object
      description: Returned on 422 – the report was rejected as a whole.
      required:
        - error
      properties:
        error:
          type: string
          description: Stable machine error code (e.g. invalid_report).
        errors:
          type: array
          description: Human-readable list of validation errors.
          items:
            type: string

    TrafficLightState:
      type: string
      description: |
        Aggregate monitoring state of an extension. `green` – all components
        ok; `yellow` – worst component warn; `red` – worst component crit;
        `off` – monitoring not enabled or nothing configured; `paused` –
        enabled but frozen (plan gate / manual). Both `off` and `paused`
        render as a neutral gray dot on the list card.
      enum:
        - "off"
        - paused
        - green
        - yellow
        - red

    Severity:
      type: string
      description: |
        Canonical monitoring severity (ok < warn < crit) – maps to
        green | yellow | red. Required for the `overall` metric. Intake
        aliases are normalized: warning -> warn, error -> crit,
        critical -> crit. For non-reserved metrics the wider custom-metrics
        severity set (including `info`) is also accepted, as in the
        custom-metrics contract.
      enum:
        - ok
        - warn
        - crit

    OverallMetric:
      type: object
      description: |
        The REQUIRED reserved metric – the overall verdict of the tick. Its
        severity is the `reported` input of the aggregate. `state` is a
        human-readable word. The `dashboard` / `telegram` placement blocks are
        optional: the aggregate is always shown on the card and the base
        `monitoring` tab regardless of them.
      required:
        - id
        - kind
        - severity
      properties:
        id:
          type: string
          const: overall
        kind:
          type: string
          const: status
        state:
          type: string
          description: Human-readable state word (e.g. "ok", "degraded").
        severity:
          $ref: "#/components/schemas/Severity"

    IssuesMetric:
      type: object
      description: |
        The RECOMMENDED reserved metric `issues` (kind `list`) – the current
        problem list. The panel quotes up to 5 items in the Telegram alert text
        and shows them in Detail.
      properties:
        id:
          type: string
          const: issues
        kind:
          type: string
          const: list
        value:
          type: object
          properties:
            items:
              type: array
              items:
                type: object
                properties:
                  text:
                    type: string
                  severity:
                    $ref: "#/components/schemas/Severity"

    Tab:
      type: object
      description: >-
        A dashboard tab in the Detail tab-bar, same as the custom-metrics
        contract. Reserved id: `monitoring` (panel-owned base tab – do not declare).
      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.
        order:
          type: integer
          description: Sort order within the tab-bar.
        icon:
          type: string
          description: >-
            Optional tab icon – a Lucide name in kebab-case ASCII or a single
            emoji, exactly as in the custom-metrics contract.

    Section:
      type: object
      description: >-
        A titled group of metrics inside a tab. Sections may target agent tabs
        or the base `monitoring` tab. The section id `pings` inside the
        `monitoring` tab is panel-owned (Uptime, first).
      required:
        - tab
        - id
      properties:
        tab:
          type: string
          description: Id of the owning tab (`monitoring` addresses the base 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: >-
        Layout of the SEPARATE monitoring status message – header plus logical
        lines. The line id `pings` is reserved for the panel's uptime-ping
        line and is rejected with 422 when pushed.
      properties:
        header:
          type: string
          description: Header template of the monitoring status message, e.g. "{sev} {name} monitoring".
        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 monitoring status message.
      required:
        - id
      properties:
        id:
          type: string
          description: Line id referenced by Metric.telegram.line. Reserved (forbidden in push) – `pings`.
        order:
          type: integer
          description: Sort order of lines in the message.
        prefix:
          type: string
          description: Text printed before the line's fields, e.g. "🖥 ".
        when:
          type: string
          description: Visibility condition, as in the custom-metrics contract (open set; unknown = always).

    Metric:
      type: object
      description: |
        A single metric – the same shape as the custom-metrics contract
        (typed value + independent `dashboard` / `telegram` placement blocks;
        kinds and value fields per /custom-metrics-openapi.yaml). Monitoring
        additions: the reserved ids `overall` (required, see OverallMetric)
        and `issues` (recommended, see IssuesMetric); the forbidden `ping:` id
        namespace; `dashboard.tab: "monitoring"` addresses the panel-owned
        base tab without declaring it.
      required:
        - id
        - kind
      properties:
        id:
          type: string
          description: >-
            Stable metric id, unique within the payload. Reserved: `overall`
            (required), `issues` (recommended); the `ping:` prefix is forbidden (422).
        kind:
          type: string
          description: >-
            Data type of the metric – open set, exactly as in the
            custom-metrics contract (number, delta, percent, duration, bytes,
            rating, money, status, series, hour_histogram, funnel, list, text,
            breakdown). Unknown kinds are ignored.
        label:
          type: string
          description: Canonical human label of the metric.
        severity:
          $ref: "#/components/schemas/Severity"
        value:
          description: Primary value; shape depends on kind (see the custom-metrics contract, section 3).
        state:
          type: string
          description: State string for kind status (paired with severity).
        dashboard:
          type: object
          description: >-
            Where and how to show the metric on the dashboard – same fields as
            the custom-metrics contract ({show, tab, section, order, name,
            render, options}). `tab: "monitoring"` targets the base monitoring tab.
          properties:
            show:
              type: boolean
            tab:
              type: string
            section:
              type: string
            order:
              type: integer
            name:
              type: string
            render:
              type: string
              description: Open set, as in the custom-metrics contract (kpi, badge, chart, status, list, ...).
            options:
              type: object
        telegram:
          type: object
          description: >-
            Where and how to show the metric in the monitoring status message –
            same fields as the custom-metrics contract ({show, line, pos,
            format, sep, when}). The line id `pings` is reserved (422).
          properties:
            show:
              type: boolean
            line:
              type: string
            pos:
              type: integer
            format:
              type: string
            sep:
              type: string
            when:
              type: string
