openapi: 3.1.0

# ============================================================================
# Uptime Cairn — REST API v1
#
# STATUS: DRAFT. Not frozen. See docs/api/README.md for the conventions this
# spec follows, the open questions that still need a human decision, and the
# freeze process. Once frozen, this file is immutable except through the
# documented change process.
#
# Every operation carries an `x-cairn-phase` extension naming the roadmap phase
# in which it ships. Phase 1 contract tests select `x-cairn-phase: 1`.
# ============================================================================

info:
  title: Uptime Cairn API
  version: 1.0.0-draft
  summary: All-in-one open source uptime monitoring and reporting platform.
  description: |
    The complete public surface of Uptime Cairn. The dashboard is the first
    client of this API, never a privileged one — there are no internal
    endpoints the UI can reach that an API key cannot.

    ## Authentication

    Two schemes, either of which authenticates any non-public operation:

    - `ApiKeyAuth` — `Authorization: Bearer cairn_<key>`. Scoped, expiring,
      revocable. The scheme for automation, CI/CD, and Terraform.
    - `SessionCookie` — the browser session the dashboard uses after
      `POST /api/v1/auth/login`. Cookie-authenticated state-changing requests
      additionally require a CSRF token in `X-Cairn-CSRF-Token`.

    Operations tagged `Public` require no authentication: the status page read
    path, subscriber confirmation, and push-monitor ingest.

    ## Pagination

    Every list endpoint paginates by opaque cursor, keyed on `(updated_at, id)`,
    per ADR-004 — uniformly, with no exception for installs small enough that
    sending everything would "work". Offsets are unstable against rows that
    change while a user is paginating, and status changes constantly.

    Cursor responses carry no total count. A count for the current filter comes
    from `GET /api/v1/monitors/membership`, which a client polling for live-view
    freshness is already calling.

    The client is never sent full state. Every list endpoint caps its page size,
    and the dashboard paginates, filters, and searches server-side.

    ## Live updates

    A client subscribes to exactly the monitor IDs on its screen and receives
    `MonitorStatusDiff` messages for those and nothing else — so push volume is
    bounded by viewport size, never by total monitor count. Filtered-view
    membership is kept fresh by polling the membership endpoint above rather than
    by the server evaluating live predicates per connected client.

    The transport is NATS in scaled mode and an in-process bus in solo mode,
    presenting the same message shapes either way. See docs/api/README.md.

    ## Errors

    Errors are `application/problem+json` per RFC 9457. Field-level validation
    failures add an `errors` array naming the offending JSON pointers.

    ## Compatibility

    `/api/v1` carries a written compatibility promise: additive changes only
    within v1. Fields may be added, never removed or retyped; enum values may be
    added, so clients must tolerate unknown values. Breaking changes go to
    `/api/v2`. See docs/api/README.md for the deprecation policy.
  license:
    name: AGPL-3.0-or-later
    identifier: AGPL-3.0-or-later
  contact:
    name: Uptime Cairn
    url: https://uptimecairn.dev
  x-security-contact: security@uptimecairn.dev

externalDocs:
  description: Project documentation
  url: https://uptimecairn.dev/docs

servers:
  - url: "{scheme}://{host}"
    description: An Uptime Cairn install.
    variables:
      scheme:
        default: http
        enum: [http, https]
      host:
        default: localhost:3000

security:
  - ApiKeyAuth: []
  - SessionCookie: []

tags:
  - name: System
    description: Liveness, readiness, build metadata, and Prometheus metrics.
  - name: Setup
    description: First-run bootstrap. Available only until an admin exists.
  - name: Authentication
    description: Session login, logout, and TOTP two-factor enrolment.
  - name: API Keys
    description: Scoped, expiring, revocable keys for automation.
  - name: Monitors
    description: The checks themselves — creation, configuration, and control.
  - name: Monitor History
    description: Heartbeats, rolled-up history, and uptime summaries.
  - name: Groups
    description: Organisational grouping of monitors.
  - name: Tags
    description: Cross-cutting labels used for filtering, status pages, and reports.
  - name: Notification Channels
    description: Alert destinations and webhook payload templating.
  - name: Maintenance Windows
    description: Scheduled suppression windows attached to monitors, groups, or tags.
  - name: Incidents
    description: Incident records and their public timeline.
  - name: Status Pages
    description: Public status page configuration and subscribers.
  - name: Public
    description: Unauthenticated read and ingest paths.
  - name: Outbound Webhooks
    description: Event subscriptions delivered for every state change.
  - name: Import
    description: Uptime Kuma migration, including multi-instance merge.
  - name: Settings
    description: Instance-wide configuration.
  - name: Users
    description: User accounts and roles.
  - name: Teams
    description: Team membership and role assignment. Ships in Phase 3.
  - name: On-Call
    description: Schedules, rotations, and escalation policies. Ships in Phase 3.
  - name: Reports
    description: Report templates, schedules, and generated runs. Ships in Phase 2.

paths:

  # --------------------------------------------------------------------------
  # System
  # --------------------------------------------------------------------------

  /healthz:
    get:
      tags: [System]
      operationId: getLiveness
      summary: Liveness probe
      description: Returns 200 while the process is running. Never authenticated, never rate-limited.
      security: []
      x-cairn-phase: 1
      responses:
        "200":
          description: The process is alive.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }

  /readyz:
    get:
      tags: [System]
      operationId: getReadiness
      summary: Readiness probe
      description: |
        Returns 200 once migrations have completed, the datastore is reachable, and
        the scheduler has started. Returns 503 otherwise, with the failing checks named.
      security: []
      x-cairn-phase: 1
      responses:
        "200":
          description: Ready to serve traffic.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }
        "503":
          description: Not ready.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }

  /metrics:
    get:
      tags: [System]
      operationId: getPrometheusMetrics
      summary: Prometheus metrics
      description: |
        Prometheus text exposition format. Exposes per-monitor status, response time,
        certificate days-remaining, and the instance's own internal metrics.

        Access is controlled by instance settings: either unauthenticated on a bound
        metrics address, or requiring an API key holding `metrics:read`.
      x-cairn-phase: 1
      responses:
        "200":
          description: Metrics in Prometheus text exposition format.
          content:
            text/plain:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/system/info:
    get:
      tags: [System]
      operationId: getSystemInfo
      summary: Instance metadata
      description: Version, build, deployment mode, and enabled capabilities.
      x-cairn-phase: 1
      responses:
        "200":
          description: Instance metadata.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SystemInfo" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/overview:
    get:
      tags: [System]
      operationId: getOverview
      summary: Dashboard summary counts
      description: |
        Aggregate counts for the dashboard header — status tallies, active incidents,
        active maintenance, upcoming expiries. Exists so the dashboard can render a
        summary without fetching every monitor, which is the failure mode this
        project is built to avoid.
      x-cairn-phase: 1
      responses:
        "200":
          description: Summary counts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Overview" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/probes:
    get:
      tags: [System]
      operationId: listProbes
      summary: List the probes this install can place a monitor on
      description: |
        Read-only. Enrolment, revocation, and the operator screens that go with them
        are Phase 4 behind the protocol's credential exchange; what Phase 1 needs is
        the ability to *name* a probe, because a monitor can be pinned to one and a
        client filling in `probe_id` has to be able to find out what the choices are.

        Unpaginated on purpose: the count is bounded by hosts an operator runs a probe
        on, which is a number in the tens even for the largest install this product is
        sized for. In solo mode it is one.

        Reads under `monitors:read` rather than a scope of its own — it exists to fill
        in a monitor field, and it returns no credential. A probe's token hash is not
        in the response and not in the struct behind it.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:read]
      responses:
        "200":
          description: Every probe, embedded first.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Probe" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/live:
    get:
      tags: [System]
      operationId: streamUpdates
      summary: Live updates for the monitors on screen
      description: |
        A Server-Sent Events stream carrying scoped diffs for exactly the monitor
        ids named in `monitor_ids`, plus the global summary counts, which every
        stream receives regardless of scope.

        This is the live half of
        [ADR-004](../adr/004-ui-state-synchronisation.md). A client subscribes to
        the rows it is showing; a monitor nobody is looking at produces no traffic
        to anybody, which is what makes the full-state-broadcast failure mode
        structurally impossible rather than merely tuned around. Filtered-view
        *membership* is not pushed — it is kept fresh by polling
        `/monitors/membership`, and the staleness window for a monitor that
        changes status off-screen and would now match an active filter is bounded
        by that interval.

        The summary is computed server-side and pushed independently. It is
        deliberately not something the client sums from what it happens to be
        subscribed to: that would make a filtered view report "3 down" because
        three of the down monitors were on this page.

        Ordinary API surface under an ordinary scope. The dashboard gets no
        privileged channel — anything holding `monitors:read` can open a stream
        and watch the same diffs, with `curl -N`.

        ### Events

        | `event:` | `data:` |
        |---|---|
        | `stream` | `{"stream_id": "..."}` — sent first. Needed to change scope. |
        | `summary` | `MonitorSummary`. Sent on open and whenever the counts move. |
        | `monitor` | `MonitorLiveUpdate`. One monitor's diff. |

        A comment frame (`: keepalive`) goes down an idle stream every 15 seconds.
        Not for the browser, which is happy to wait — for the proxies between it
        and here, which close a connection that has been quiet for a minute and
        produce a dashboard that stops updating with no error anywhere.

        ### Backpressure

        A subscriber that cannot keep up loses messages rather than blocking.
        Heartbeat ingest must never queue behind a browser, and a backgrounded tab
        that comes back gets the current state from its next page fetch anyway.
        Losses are counted and logged server-side, because from the browser's side
        a dropped update is a row that simply stopped moving.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:read]
      parameters:
        - name: monitor_ids
          in: query
          description: |
            The monitors this stream carries. Comma-separated. May be empty: a view
            that has not loaded its rows yet still wants the summary channel.

            Bounded, and the bound is ADR-004's second invariant written as a
            number — client payload and render cost must track viewport size, never
            total monitor count. A client subscribing to every monitor in the
            install has reintroduced the full-state broadcast one id at a time.
          schema: { type: string }
      responses:
        "200":
          description: The event stream.
          content:
            text/event-stream:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "501":
          description: This build has no live-update bus wired in. Poll `/monitors/membership`.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /api/v1/live/{streamId}/scope:
    put:
      tags: [System]
      operationId: setStreamScope
      summary: Change what an open stream carries
      description: |
        Replaces a stream's subscription in place. The stream keeps running.

        This exists because paginating a list is the most ordinary thing a user
        does, and a channel that had to be closed and reopened on every page turn
        would spend its life reconnecting — which is the objection ADR-004 raises
        against the framings it rejects, answered directly rather than worked
        around.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:read]
      parameters:
        - name: streamId
          in: path
          required: true
          description: From the `stream` event the stream sent when it opened.
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [monitor_ids]
              properties:
                monitor_ids:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        "204": { description: The stream now carries these monitors. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: |
            That stream is closed or never existed. The two are the same thing to a
            client, and the answer to both is to open a new stream.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  # --------------------------------------------------------------------------
  # Setup and authentication
  # --------------------------------------------------------------------------

  /api/v1/setup/status:
    get:
      tags: [Setup]
      operationId: getSetupStatus
      summary: Whether first-run setup is still required
      security: []
      x-cairn-phase: 1
      responses:
        "200":
          description: Setup state.
          content:
            application/json:
              schema:
                type: object
                required: [setup_required]
                properties:
                  setup_required:
                    type: boolean
                    description: True until the first administrator account exists.

  /api/v1/setup:
    post:
      tags: [Setup]
      operationId: completeSetup
      summary: Create the first administrator account
      description: |
        Available only while `setup_required` is true. Returns 409 once an account
        exists, so the window cannot be reopened by a later caller.
      security: []
      x-cairn-phase: 1
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetupRequest" }
      responses:
        "201":
          description: Administrator created and a session established.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/auth/login:
    post:
      tags: [Authentication]
      operationId: login
      summary: Establish a browser session
      description: |
        Rate-limited. When the account has TOTP enabled and no `totp_code` is supplied,
        responds 401 with `type` ending `/totp-required`; the client resubmits with the code.
      security: []
      x-cairn-phase: 1
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoginRequest" }
      responses:
        "200":
          description: Authenticated. Sets the session cookie.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/auth/logout:
    post:
      tags: [Authentication]
      operationId: logout
      summary: Destroy the current session
      x-cairn-phase: 1
      responses:
        "204": { description: Session destroyed. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/auth/session:
    get:
      tags: [Authentication]
      operationId: getSession
      summary: Describe the caller
      description: Identifies the authenticated principal, whether a session or an API key.
      x-cairn-phase: 1
      responses:
        "200":
          description: The current principal.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/auth/totp:
    post:
      tags: [Authentication]
      operationId: enrolTotp
      summary: Begin TOTP enrolment
      description: |
        Returns a secret and provisioning URI. Enrolment is not active until confirmed
        via `POST /api/v1/auth/totp/confirm`.
      x-cairn-phase: 1
      responses:
        "200":
          description: Enrolment material.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TotpEnrolment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      tags: [Authentication]
      operationId: disableTotp
      summary: Disable TOTP
      description: Requires the current password, and a valid code or recovery code.
      x-cairn-phase: 1
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TotpDisableRequest" }
      responses:
        "204": { description: TOTP disabled. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/auth/totp/confirm:
    post:
      tags: [Authentication]
      operationId: confirmTotp
      summary: Activate TOTP enrolment
      description: Confirms enrolment with a generated code and issues recovery codes.
      x-cairn-phase: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [totp_code]
              properties:
                totp_code: { type: string, pattern: "^[0-9]{6}$" }
      responses:
        "200":
          description: TOTP active. Recovery codes are shown once and never again.
          content:
            application/json:
              schema:
                type: object
                required: [recovery_codes]
                properties:
                  recovery_codes:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  # --------------------------------------------------------------------------
  # API keys
  # --------------------------------------------------------------------------

  /api/v1/api-keys:
    get:
      tags: [API Keys]
      operationId: listApiKeys
      summary: List API keys
      description: The key material itself is never returned after creation — only its prefix.
      x-cairn-phase: 1
      x-cairn-scopes: [api_keys:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
      responses:
        "200":
          description: A page of API keys.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [API Keys]
      operationId: createApiKey
      summary: Create an API key
      description: |
        The plaintext key is returned exactly once, in this response, and is not
        recoverable afterwards. A key may not be granted a scope its creator does not hold.
      x-cairn-phase: 1
      x-cairn-scopes: [api_keys:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApiKeyCreate" }
      responses:
        "201":
          description: Key created. `key` is shown once and never again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyCreated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/api-keys/{apiKeyId}:
    parameters:
      - $ref: "#/components/parameters/ApiKeyId"
    get:
      tags: [API Keys]
      operationId: getApiKey
      summary: Retrieve an API key
      x-cairn-phase: 1
      x-cairn-scopes: [api_keys:read]
      responses:
        "200":
          description: The key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [API Keys]
      operationId: updateApiKey
      summary: Update an API key
      description: Name, scopes, and expiry may change. The key material may not.
      x-cairn-phase: 1
      x-cairn-scopes: [api_keys:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApiKeyUpdate" }
      responses:
        "200":
          description: The updated key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [API Keys]
      operationId: revokeApiKey
      summary: Revoke an API key
      description: Takes effect immediately. In-flight requests using the key are not honoured.
      x-cairn-phase: 1
      x-cairn-scopes: [api_keys:write]
      responses:
        "204": { description: Key revoked. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # --------------------------------------------------------------------------
  # Monitors
  # --------------------------------------------------------------------------

  /api/v1/monitors:
    get:
      tags: [Monitors]
      operationId: listMonitors
      summary: List monitors
      description: |
        Server-side paginated, filtered, sorted, and searched. This endpoint must remain
        responsive with 5,000 monitors configured — it is covered by the CI load-test gate.

        Filters combine with AND. Repeated array parameters combine with OR within the
        parameter, so `tag_id=a&tag_id=b` matches monitors carrying either tag.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
        - name: sort
          in: query
          description: |
            Result ordering. Constrained to the cursor key, because ADR-004 fixes
            cursor pagination on `(updated_at, id)` and a keyset cursor can only
            paginate the ordering it is keyed on.

            Sorting by name, status, or uptime is therefore **not offered** — see the
            note in docs/api/README.md. Sorting a page client-side would order 25 rows
            out of 5,000, which is worse than not offering it.
          schema:
            type: string
            default: -updated_at
            enum: [updated_at, -updated_at]
        - name: status
          in: query
          description: Filter by current status.
          schema:
            type: array
            items: { $ref: "#/components/schemas/MonitorStatus" }
          style: form
          explode: true
        - name: type
          in: query
          description: Filter by monitor type.
          schema:
            type: array
            items: { $ref: "#/components/schemas/MonitorType" }
          style: form
          explode: true
        - name: tag_id
          in: query
          schema:
            type: array
            items: { type: string, format: uuid }
          style: form
          explode: true
        - name: group_id
          in: query
          schema:
            type: array
            items: { type: string, format: uuid }
          style: form
          explode: true
        - name: enabled
          in: query
          schema: { type: boolean }
        - name: include
          in: query
          description: |
            Optionally embed related data. `last_heartbeat` and `uptime` cost extra
            per-row work; the dashboard list view requests them, exports do not.

            `heartbeats` embeds a bounded run of recent checks per monitor — the
            strip a list view draws under each row. It is resolved for the whole
            page in one statement rather than one request per row, which is the
            fan-out this parameter exists to prevent: a client that requests it
            per row has reintroduced exactly the cost the 5,000-monitor gate
            measures. Its length is `heartbeats_limit`, and it is bounded on
            purpose so the embed's cost tracks the viewport rather than the
            install (ADR-004).
          schema:
            type: array
            items: { type: string, enum: [last_heartbeat, heartbeats, uptime, tags, group] }
          style: form
          explode: false
        - name: heartbeats_limit
          in: query
          description: |
            How many recent checks `include=heartbeats` returns per monitor.
            Clamped to the maximum rather than refused, the same as `limit`.
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
      responses:
        "200":
          description: A page of monitors.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Monitor" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Monitors]
      operationId: createMonitor
      summary: Create a monitor
      description: |
        The monitor begins checking immediately unless `enabled` is false. Its first
        heartbeat is recorded on the next scheduler tick, not synchronously with this call.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MonitorWrite" }
      responses:
        "201":
          description: Monitor created.
          headers:
            Location:
              description: URL of the created monitor.
              schema: { type: string, format: uri-reference }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Monitor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/monitors/{monitorId}:
    parameters:
      - $ref: "#/components/parameters/MonitorId"
    get:
      tags: [Monitors]
      operationId: getMonitor
      summary: Retrieve a monitor
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:read]
      parameters:
        - name: include
          in: query
          schema:
            type: array
            items: { type: string, enum: [last_heartbeat, heartbeats, uptime, tags, group, certificate] }
          style: form
          explode: false
        - name: heartbeats_limit
          in: query
          description: How many recent checks `include=heartbeats` returns. Clamped, not refused.
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
      responses:
        "200":
          description: The monitor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Monitor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Monitors]
      operationId: updateMonitor
      summary: Update a monitor
      description: |
        Partial update. Omitted fields are left unchanged; explicit `null` clears a
        nullable field. A monitor's `type` is immutable — changing check semantics
        means creating a new monitor, so that its history stays meaningful.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MonitorUpdate" }
      responses:
        "200":
          description: The updated monitor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Monitor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Monitors]
      operationId: deleteMonitor
      summary: Delete a monitor
      description: |
        Deletes the monitor and its history. Irreversible. Monitors that are the parent
        of a dependency relationship cannot be deleted until their children are reassigned.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:write]
      responses:
        "204": { description: Monitor deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /api/v1/monitors/{monitorId}/pause:
    parameters:
      - $ref: "#/components/parameters/MonitorId"
    post:
      tags: [Monitors]
      operationId: pauseMonitor
      summary: Pause a monitor
      description: Stops checking and suppresses alerts. History is retained.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:write]
      responses:
        "200":
          description: The paused monitor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Monitor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/monitors/{monitorId}/resume:
    parameters:
      - $ref: "#/components/parameters/MonitorId"
    post:
      tags: [Monitors]
      operationId: resumeMonitor
      summary: Resume a paused monitor
      description: Resumes on the next scheduler tick. The monitor re-enters `pending` until it has a result.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:write]
      responses:
        "200":
          description: The resumed monitor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Monitor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/monitors/{monitorId}/check:
    parameters:
      - $ref: "#/components/parameters/MonitorId"
    post:
      tags: [Monitors]
      operationId: runMonitorCheck
      summary: Run a check now
      description: |
        Executes one check outside the normal schedule and returns its result. The result
        is recorded as a heartbeat and can trigger state changes and notifications like any other.
        Rate-limited per monitor to protect the target.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:write]
      responses:
        "200":
          description: The resulting heartbeat.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Heartbeat" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/monitors/membership:
    get:
      tags: [Monitors]
      operationId: getMonitorMembership
      summary: Membership signal for a filtered view
      description: |
        The reconciliation half of ADR-004. Live updates only reach the monitor IDs a
        client currently has on screen; a monitor that changes status *off* screen and
        would now match an active filter has no subscription telling the server anyone
        cares. This endpoint closes that gap without the server evaluating live
        predicates for every connected client.

        Accepts the same filter parameters as `listMonitors` and returns a cheap
        version and count for that filter. A client polls it on a short interval —
        5 seconds by default — and re-fetches the affected page boundary only when
        `version` or `count` changes. Staleness for filtered views is therefore bounded
        by the poll interval rather than requiring a manual refresh.

        This is ordinary API surface, not a dashboard-only internal: any client
        maintaining a filtered view can use it.

        Implementations must keep this an index-only read. It is polled once per active
        view per interval, so its cost scales with connected clients rather than monitor
        count — the one dimension the 5,000-monitor gate does not exercise by default.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:read]
      parameters:
        - $ref: "#/components/parameters/Search"
        - name: status
          in: query
          schema:
            type: array
            items: { $ref: "#/components/schemas/MonitorStatus" }
          style: form
          explode: true
        - name: type
          in: query
          schema:
            type: array
            items: { $ref: "#/components/schemas/MonitorType" }
          style: form
          explode: true
        - name: tag_id
          in: query
          schema:
            type: array
            items: { type: string, format: uuid }
          style: form
          explode: true
        - name: group_id
          in: query
          schema:
            type: array
            items: { type: string, format: uuid }
          style: form
          explode: true
        - name: enabled
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: The current membership signal for this filter.
          headers:
            Cache-Control:
              description: Always `no-store`. A cached membership signal is a stale view.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MembershipSignal" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/monitors/bulk:
    post:
      tags: [Monitors]
      operationId: bulkUpdateMonitors
      summary: Apply one operation to many monitors
      description: |
        Enable, disable, delete, retag, or regroup up to 1,000 monitors in one call —
        the operation an agency running a thousand monitors needs and cannot do by hand.

        Partial success is normal: the response reports each identifier's outcome
        separately rather than failing the batch.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MonitorBulkRequest" }
      responses:
        "200":
          description: Per-monitor outcomes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  # --------------------------------------------------------------------------
  # Monitor history
  # --------------------------------------------------------------------------

  /api/v1/monitors/{monitorId}/heartbeats:
    parameters:
      - $ref: "#/components/parameters/MonitorId"
    get:
      tags: [Monitor History]
      operationId: listHeartbeats
      summary: List raw heartbeats
      description: |
        Individual check results, newest first, within a time range. Raw heartbeats are
        retained per the configured retention policy; older ranges are served from
        rollups via `/history` instead. A range that falls wholly outside raw retention
        returns 200 with an empty page and a `Warning` header, not an error.
      x-cairn-phase: 1
      x-cairn-scopes: [heartbeats:read]
      parameters:
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: important_only
          in: query
          description: Return only state-change heartbeats — the events, not every tick.
          schema: { type: boolean, default: false }
        - name: status
          in: query
          schema:
            type: array
            items: { $ref: "#/components/schemas/HeartbeatStatus" }
          style: form
          explode: true
      responses:
        "200":
          description: A page of heartbeats.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Heartbeat" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/monitors/{monitorId}/history:
    parameters:
      - $ref: "#/components/parameters/MonitorId"
    get:
      tags: [Monitor History]
      operationId: getMonitorHistory
      summary: Rolled-up history
      description: |
        Bucketed history over an arbitrary past range, served from the tiered rollups
        described in ADR-002 (raw, 1m, 5m, 1h, 1d). This is what makes drilldown into
        old ranges cheap, and what the default UI uses instead of a week-long window.

        With `resolution=auto` the server picks the coarsest tier that yields a
        reasonable number of buckets for the requested range and echoes its choice in
        the response.
      x-cairn-phase: 1
      x-cairn-scopes: [heartbeats:read]
      parameters:
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - name: resolution
          in: query
          schema:
            allOf:
              - $ref: "#/components/schemas/HistoryResolution"
            default: auto
      responses:
        "200":
          description: Bucketed history.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HistoryResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/monitors/{monitorId}/uptime:
    parameters:
      - $ref: "#/components/parameters/MonitorId"
    get:
      tags: [Monitor History]
      operationId: getMonitorUptime
      summary: Uptime summary
      description: |
        Uptime ratio and response-time statistics over one or more named windows, with
        maintenance time excluded or included per `maintenance` — the distinction that
        decides whether an SLA figure is defensible.
      x-cairn-phase: 1
      x-cairn-scopes: [heartbeats:read]
      parameters:
        - name: window
          in: query
          description: Named windows to summarise. Repeat for several.
          schema:
            type: array
            items: { type: string, enum: ["1h", "24h", "7d", "30d", "90d", "365d"] }
            default: ["24h", "30d"]
          style: form
          explode: true
        - name: maintenance
          in: query
          description: How scheduled maintenance is treated in the ratio.
          schema:
            type: string
            enum: [exclude, count_as_up, count_as_down]
            default: exclude
      responses:
        "200":
          description: Uptime summary per window.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/monitors/{monitorId}/certificate:
    parameters:
      - $ref: "#/components/parameters/MonitorId"
    get:
      tags: [Monitor History]
      operationId: getMonitorCertificate
      summary: TLS certificate detail
      description: |
        The certificate observed on the most recent check, for monitors whose type
        performs a TLS handshake. Returns 404 for monitor types that never see one.
      x-cairn-phase: 1
      x-cairn-scopes: [monitors:read]
      responses:
        "200":
          description: Certificate detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CertificateInfo" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/push/{pushToken}:
    parameters:
      - name: pushToken
        in: path
        required: true
        description: The opaque token issued to a `push` monitor.
        schema: { type: string }
    get:
      tags: [Public]
      operationId: pushHeartbeatGet
      summary: Record a push heartbeat
      description: |
        The dead-man's-switch ingest. A `push` monitor goes down when this endpoint is
        not called within its expected interval plus grace period.

        GET is offered alongside POST because the callers are cron jobs and shell
        scripts, and `curl` with no flags must work. Unauthenticated by design — the
        token is the credential, so it should be treated as a secret.
      security: []
      x-cairn-phase: 1
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [up, down], default: up }
        - name: msg
          in: query
          description: Free-text message recorded on the heartbeat.
          schema: { type: string, maxLength: 1024 }
        - name: ping
          in: query
          description: Client-measured duration in milliseconds, recorded as the response time.
          schema: { type: number, minimum: 0 }
      responses:
        "200":
          description: Heartbeat recorded.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Public]
      operationId: pushHeartbeatPost
      summary: Record a push heartbeat with a body
      security: []
      x-cairn-phase: 1
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PushHeartbeat" }
      responses:
        "200":
          description: Heartbeat recorded.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # --------------------------------------------------------------------------
  # Groups and tags
  # --------------------------------------------------------------------------

  /api/v1/groups:
    get:
      tags: [Groups]
      operationId: listGroups
      summary: List monitor groups
      x-cairn-phase: 1
      x-cairn-scopes: [groups:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
      responses:
        "200":
          description: A page of groups.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Group" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Groups]
      operationId: createGroup
      summary: Create a group
      x-cairn-phase: 1
      x-cairn-scopes: [groups:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GroupWrite" }
      responses:
        "201":
          description: Group created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Group" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/groups/{groupId}:
    parameters:
      - $ref: "#/components/parameters/GroupId"
    get:
      tags: [Groups]
      operationId: getGroup
      summary: Retrieve a group
      x-cairn-phase: 1
      x-cairn-scopes: [groups:read]
      responses:
        "200":
          description: The group.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Group" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Groups]
      operationId: updateGroup
      summary: Update a group
      x-cairn-phase: 1
      x-cairn-scopes: [groups:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GroupWrite" }
      responses:
        "200":
          description: The updated group.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Group" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Groups]
      operationId: deleteGroup
      summary: Delete a group
      description: Monitors in the group are not deleted; they become ungrouped.
      x-cairn-phase: 1
      x-cairn-scopes: [groups:write]
      responses:
        "204": { description: Group deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/tags:
    get:
      tags: [Tags]
      operationId: listTags
      summary: List tags
      x-cairn-phase: 1
      x-cairn-scopes: [tags:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
      responses:
        "200":
          description: A page of tags.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Tag" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Tags]
      operationId: createTag
      summary: Create a tag
      x-cairn-phase: 1
      x-cairn-scopes: [tags:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TagWrite" }
      responses:
        "201":
          description: Tag created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/tags/{tagId}:
    parameters:
      - $ref: "#/components/parameters/TagId"
    get:
      tags: [Tags]
      operationId: getTag
      summary: Retrieve a tag
      x-cairn-phase: 1
      x-cairn-scopes: [tags:read]
      responses:
        "200":
          description: The tag.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Tags]
      operationId: updateTag
      summary: Update a tag
      x-cairn-phase: 1
      x-cairn-scopes: [tags:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TagWrite" }
      responses:
        "200":
          description: The updated tag.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Tags]
      operationId: deleteTag
      summary: Delete a tag
      description: The tag is removed from every monitor carrying it. Monitors are not deleted.
      x-cairn-phase: 1
      x-cairn-scopes: [tags:write]
      responses:
        "204": { description: Tag deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # --------------------------------------------------------------------------
  # Notification channels
  # --------------------------------------------------------------------------

  /api/v1/notification-channels:
    get:
      tags: [Notification Channels]
      operationId: listNotificationChannels
      summary: List notification channels
      description: Secrets in channel configuration are redacted on read and never returned in full.
      x-cairn-phase: 1
      x-cairn-scopes: [notifications:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
        - name: type
          in: query
          schema:
            type: array
            items: { $ref: "#/components/schemas/NotificationChannelType" }
          style: form
          explode: true
        - name: enabled
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: A page of channels.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/NotificationChannel" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Notification Channels]
      operationId: createNotificationChannel
      summary: Create a notification channel
      x-cairn-phase: 1
      x-cairn-scopes: [notifications:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NotificationChannelWrite" }
      responses:
        "201":
          description: Channel created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationChannel" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/notification-channels/{channelId}:
    parameters:
      - $ref: "#/components/parameters/ChannelId"
    get:
      tags: [Notification Channels]
      operationId: getNotificationChannel
      summary: Retrieve a notification channel
      x-cairn-phase: 1
      x-cairn-scopes: [notifications:read]
      responses:
        "200":
          description: The channel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationChannel" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Notification Channels]
      operationId: updateNotificationChannel
      summary: Update a notification channel
      description: |
        Redacted secret fields may be omitted to leave them unchanged. Supplying a new
        value replaces the stored secret.
      x-cairn-phase: 1
      x-cairn-scopes: [notifications:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NotificationChannelUpdate" }
      responses:
        "200":
          description: The updated channel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationChannel" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Notification Channels]
      operationId: deleteNotificationChannel
      summary: Delete a notification channel
      description: The channel is detached from every monitor that referenced it.
      x-cairn-phase: 1
      x-cairn-scopes: [notifications:write]
      responses:
        "204": { description: Channel deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/notification-channels/{channelId}/test:
    parameters:
      - $ref: "#/components/parameters/ChannelId"
    post:
      tags: [Notification Channels]
      operationId: testNotificationChannel
      summary: Send a test notification
      description: |
        Delivers a sample notification through the channel and reports the provider's
        response. Every channel has this button in the UI, because a channel that fails
        silently at 3am is worse than no channel at all.
      x-cairn-phase: 1
      x-cairn-scopes: [notifications:write]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                sample_event:
                  allOf:
                    - $ref: "#/components/schemas/EventType"
                  description: Which event shape to simulate. Defaults to `monitor.down`.
      responses:
        "200":
          description: Delivery outcome.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationTestResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/notification-channels/preview:
    post:
      tags: [Notification Channels]
      operationId: previewNotificationTemplate
      summary: Render a webhook template without sending it
      description: |
        Renders a webhook payload template against a sample or supplied context and
        returns the result, so the UI can show exactly what will be sent before the
        channel is saved. Template errors come back as a rendering error, not a 5xx.

        This endpoint does not require an existing channel and sends nothing.
      x-cairn-phase: 1
      x-cairn-scopes: [notifications:read]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplatePreviewRequest" }
      responses:
        "200":
          description: The rendered payload, or the template error that prevented rendering.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TemplatePreviewResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/notification-channels/template-variables:
    get:
      tags: [Notification Channels]
      operationId: listTemplateVariables
      summary: List available template variables
      description: |
        The interpolation variables available to webhook templates, with types and
        example values. Published as an endpoint so the UI's autocomplete and the
        documentation cannot drift from what the renderer actually supports.
      x-cairn-phase: 1
      x-cairn-scopes: [notifications:read]
      responses:
        "200":
          description: Available variables.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TemplateVariable" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # --------------------------------------------------------------------------
  # Maintenance windows
  # --------------------------------------------------------------------------

  /api/v1/maintenance-windows:
    get:
      tags: [Maintenance Windows]
      operationId: listMaintenanceWindows
      summary: List maintenance windows
      x-cairn-phase: 1
      x-cairn-scopes: [maintenance:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
        - name: state
          in: query
          schema:
            type: array
            items: { $ref: "#/components/schemas/MaintenanceState" }
          style: form
          explode: true
        - name: monitor_id
          in: query
          description: Only windows affecting this monitor, whether directly or via its group or tags.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: A page of maintenance windows.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/MaintenanceWindow" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Maintenance Windows]
      operationId: createMaintenanceWindow
      summary: Create a maintenance window
      x-cairn-phase: 1
      x-cairn-scopes: [maintenance:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MaintenanceWindowWrite" }
      responses:
        "201":
          description: Window created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MaintenanceWindow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/maintenance-windows/{maintenanceWindowId}:
    parameters:
      - $ref: "#/components/parameters/MaintenanceWindowId"
    get:
      tags: [Maintenance Windows]
      operationId: getMaintenanceWindow
      summary: Retrieve a maintenance window
      x-cairn-phase: 1
      x-cairn-scopes: [maintenance:read]
      responses:
        "200":
          description: The window.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MaintenanceWindow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Maintenance Windows]
      operationId: updateMaintenanceWindow
      summary: Update a maintenance window
      x-cairn-phase: 1
      x-cairn-scopes: [maintenance:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MaintenanceWindowWrite" }
      responses:
        "200":
          description: The updated window.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MaintenanceWindow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Maintenance Windows]
      operationId: deleteMaintenanceWindow
      summary: Delete a maintenance window
      description: |
        Deletes the schedule. History already annotated as under maintenance keeps that
        annotation — past uptime figures do not silently change because a window was removed.
      x-cairn-phase: 1
      x-cairn-scopes: [maintenance:write]
      responses:
        "204": { description: Window deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # --------------------------------------------------------------------------
  # Incidents
  # --------------------------------------------------------------------------

  /api/v1/incidents:
    get:
      tags: [Incidents]
      operationId: listIncidents
      summary: List incidents
      x-cairn-phase: 1
      x-cairn-scopes: [incidents:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
        - name: state
          in: query
          schema:
            type: array
            items: { $ref: "#/components/schemas/IncidentState" }
          style: form
          explode: true
        - name: impact
          in: query
          schema:
            type: array
            items: { $ref: "#/components/schemas/IncidentImpact" }
          style: form
          explode: true
        - name: monitor_id
          in: query
          schema: { type: string, format: uuid }
        - name: status_page_id
          in: query
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
      responses:
        "200":
          description: A page of incidents.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Incident" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Incidents]
      operationId: createIncident
      summary: Open an incident
      description: |
        In Phase 1 incidents are opened by a human or by an API client. Phase 3 adds
        automatic opening from failing checks; incidents created that way are marked
        `auto_opened` and are otherwise identical.
      x-cairn-phase: 1
      x-cairn-scopes: [incidents:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IncidentWrite" }
      responses:
        "201":
          description: Incident opened.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Incident" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/incidents/{incidentId}:
    parameters:
      - $ref: "#/components/parameters/IncidentId"
    get:
      tags: [Incidents]
      operationId: getIncident
      summary: Retrieve an incident
      x-cairn-phase: 1
      x-cairn-scopes: [incidents:read]
      responses:
        "200":
          description: The incident, including its timeline.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Incident" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Incidents]
      operationId: updateIncident
      summary: Update an incident
      description: |
        Edits incident metadata. Advancing the incident's state is done by posting an
        update to the timeline, so that every state change carries the note explaining it.
      x-cairn-phase: 1
      x-cairn-scopes: [incidents:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IncidentUpdateRequest" }
      responses:
        "200":
          description: The updated incident.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Incident" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Incidents]
      operationId: deleteIncident
      summary: Delete an incident
      description: Removes the incident and its timeline from status pages and history.
      x-cairn-phase: 1
      x-cairn-scopes: [incidents:write]
      responses:
        "204": { description: Incident deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/incidents/{incidentId}/updates:
    parameters:
      - $ref: "#/components/parameters/IncidentId"
    get:
      tags: [Incidents]
      operationId: listIncidentUpdates
      summary: List timeline entries
      x-cairn-phase: 1
      x-cairn-scopes: [incidents:read]
      responses:
        "200":
          description: The timeline, oldest first.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/IncidentTimelineEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Incidents]
      operationId: createIncidentUpdate
      summary: Post a timeline entry
      description: |
        Appends an update and, if `state` is supplied, advances the incident to it.
        Posting a `resolved` update closes the incident and stamps `resolved_at`.
        Notifies status page subscribers when `notify_subscribers` is true.
      x-cairn-phase: 1
      x-cairn-scopes: [incidents:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IncidentTimelineEntryWrite" }
      responses:
        "201":
          description: Timeline entry created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IncidentTimelineEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/incidents/{incidentId}/acknowledge:
    parameters:
      - $ref: "#/components/parameters/IncidentId"
    post:
      tags: [Incidents]
      operationId: acknowledgeIncident
      summary: Acknowledge an incident
      description: |
        Records who took ownership and when — the input to MTTA in Phase 2's post-mortem
        reports. Ships with the on-call work in Phase 3.
      x-cairn-phase: 3
      x-cairn-scopes: [incidents:write]
      responses:
        "200":
          description: The acknowledged incident.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Incident" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  # --------------------------------------------------------------------------
  # Status pages
  # --------------------------------------------------------------------------

  /api/v1/status-pages:
    get:
      tags: [Status Pages]
      operationId: listStatusPages
      summary: List status pages
      x-cairn-phase: 1
      x-cairn-scopes: [status_pages:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
        - name: published
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: A page of status pages.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/StatusPage" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Status Pages]
      operationId: createStatusPage
      summary: Create a status page
      x-cairn-phase: 1
      x-cairn-scopes: [status_pages:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusPageWrite" }
      responses:
        "201":
          description: Status page created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/status-pages/{statusPageId}:
    parameters:
      - $ref: "#/components/parameters/StatusPageId"
    get:
      tags: [Status Pages]
      operationId: getStatusPage
      summary: Retrieve a status page
      x-cairn-phase: 1
      x-cairn-scopes: [status_pages:read]
      responses:
        "200":
          description: The status page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Status Pages]
      operationId: updateStatusPage
      summary: Update a status page
      x-cairn-phase: 1
      x-cairn-scopes: [status_pages:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusPageWrite" }
      responses:
        "200":
          description: The updated status page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Status Pages]
      operationId: deleteStatusPage
      summary: Delete a status page
      description: The page and its subscriber list are removed. Monitors are unaffected.
      x-cairn-phase: 1
      x-cairn-scopes: [status_pages:write]
      responses:
        "204": { description: Status page deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/status-pages/{statusPageId}/subscribers:
    parameters:
      - $ref: "#/components/parameters/StatusPageId"
    get:
      tags: [Status Pages]
      operationId: listStatusPageSubscribers
      summary: List subscribers
      x-cairn-phase: 1
      x-cairn-scopes: [status_pages:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - name: confirmed
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: A page of subscribers.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Subscriber" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/status-pages/{statusPageId}/subscribers/{subscriberId}:
    parameters:
      - $ref: "#/components/parameters/StatusPageId"
      - name: subscriberId
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [Status Pages]
      operationId: deleteStatusPageSubscriber
      summary: Remove a subscriber
      x-cairn-phase: 1
      x-cairn-scopes: [status_pages:write]
      responses:
        "204": { description: Subscriber removed. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # --------------------------------------------------------------------------
  # Public status page surface
  # --------------------------------------------------------------------------

  /api/v1/public/status-pages/{slug}:
    parameters:
      - $ref: "#/components/parameters/StatusPageSlug"
    get:
      tags: [Public]
      operationId: getPublicStatusPage
      summary: Read a published status page
      description: |
        The unauthenticated read path a status page's own frontend consumes. Cacheable,
        and required to stay fast under load independently of the authenticated API —
        a status page that falls over during the outage it is reporting is worthless.

        Password-protected pages require the visitor token issued by
        `POST /api/v1/public/status-pages/{slug}/authenticate`.
      security: []
      x-cairn-phase: 1
      parameters:
        - name: X-Cairn-Status-Page-Token
          in: header
          required: false
          description: Visitor token for a password-protected page.
          schema: { type: string }
      responses:
        "200":
          description: The published page and its current state.
          headers:
            Cache-Control:
              schema: { type: string }
            ETag:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicStatusPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/public/status-pages/{slug}/authenticate:
    parameters:
      - $ref: "#/components/parameters/StatusPageSlug"
    post:
      tags: [Public]
      operationId: authenticatePublicStatusPage
      summary: Exchange a page password for a visitor token
      security: []
      x-cairn-phase: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string }
      responses:
        "200":
          description: A visitor token.
          content:
            application/json:
              schema:
                type: object
                required: [token, expires_at]
                properties:
                  token: { type: string }
                  expires_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/public/status-pages/{slug}/subscribers:
    parameters:
      - $ref: "#/components/parameters/StatusPageSlug"
    post:
      tags: [Public]
      operationId: subscribeToStatusPage
      summary: Subscribe to status page updates
      description: |
        Creates an unconfirmed subscription and sends a confirmation. Always responds
        201 whether or not the address was already subscribed, so the endpoint cannot
        be used to enumerate subscribers.
      security: []
      x-cairn-phase: 1
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubscribeRequest" }
      responses:
        "201": { description: Confirmation sent if the address was eligible. }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/public/subscriptions/{token}:
    parameters:
      - name: token
        in: path
        required: true
        description: The opaque token from a confirmation or unsubscribe link.
        schema: { type: string }
    post:
      tags: [Public]
      operationId: confirmSubscription
      summary: Confirm a subscription
      security: []
      x-cairn-phase: 1
      responses:
        "204": { description: Subscription confirmed. }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Public]
      operationId: unsubscribe
      summary: Unsubscribe
      description: One click, no login, no confirmation step.
      security: []
      x-cairn-phase: 1
      responses:
        "204": { description: Unsubscribed. }
        "404": { $ref: "#/components/responses/NotFound" }

  # --------------------------------------------------------------------------
  # Outbound webhooks
  # --------------------------------------------------------------------------

  /api/v1/webhooks:
    get:
      tags: [Outbound Webhooks]
      operationId: listWebhooks
      summary: List event subscriptions
      x-cairn-phase: 1
      x-cairn-scopes: [webhooks:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - name: enabled
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: A page of subscriptions.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Outbound Webhooks]
      operationId: createWebhook
      summary: Subscribe to events
      description: |
        Deliveries are signed with HMAC-SHA256 over the raw body, sent as
        `X-Cairn-Signature: t=<unix>,v1=<hex>`, and carry `X-Cairn-Event`,
        `X-Cairn-Delivery`, and `X-Cairn-Attempt` headers. The signing secret is
        returned once on creation.
      x-cairn-phase: 1
      x-cairn-scopes: [webhooks:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookWrite" }
      responses:
        "201":
          description: Subscription created. `secret` is shown once and never again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookCreated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/webhooks/{webhookId}:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
    get:
      tags: [Outbound Webhooks]
      operationId: getWebhook
      summary: Retrieve a subscription
      x-cairn-phase: 1
      x-cairn-scopes: [webhooks:read]
      responses:
        "200":
          description: The subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Outbound Webhooks]
      operationId: updateWebhook
      summary: Update a subscription
      x-cairn-phase: 1
      x-cairn-scopes: [webhooks:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookWrite" }
      responses:
        "200":
          description: The updated subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Outbound Webhooks]
      operationId: deleteWebhook
      summary: Delete a subscription
      x-cairn-phase: 1
      x-cairn-scopes: [webhooks:write]
      responses:
        "204": { description: Subscription deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/webhooks/{webhookId}/deliveries:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
    get:
      tags: [Outbound Webhooks]
      operationId: listWebhookDeliveries
      summary: List delivery attempts
      description: |
        The delivery log, newest first. Retained for a configurable window so that
        "did the alert actually leave the building?" has an answer that is not a guess.
      x-cairn-phase: 1
      x-cairn-scopes: [webhooks:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - name: outcome
          in: query
          schema:
            type: array
            items: { type: string, enum: [succeeded, failed, pending] }
          style: form
          explode: true
      responses:
        "200":
          description: A page of deliveries.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
      - name: deliveryId
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Outbound Webhooks]
      operationId: redeliverWebhook
      summary: Replay a delivery
      description: Re-sends the original payload with a new delivery identifier and a fresh signature.
      x-cairn-phase: 1
      x-cairn-scopes: [webhooks:write]
      responses:
        "202":
          description: Redelivery queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # --------------------------------------------------------------------------
  # Import
  # --------------------------------------------------------------------------

  /api/v1/imports/kuma:
    post:
      tags: [Import]
      operationId: importFromKuma
      summary: Import from one or more Uptime Kuma databases
      description: |
        Accepts one or several `kuma.db` files and reproduces monitors, tags,
        notification channels, and status pages. Supplying several files performs the
        multi-instance merge — the migration path for everyone currently sharding Kuma
        by hand across hosts.

        Import runs asynchronously. Poll the returned job for its status and report;
        nothing is silently dropped, and `dry_run` produces the same report without
        writing anything.
      x-cairn-phase: 1
      x-cairn-scopes: [imports:write]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: "#/components/schemas/KumaImportRequest" }
            encoding:
              files:
                contentType: application/octet-stream
              options:
                contentType: application/json
      responses:
        "202":
          description: Import accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportJob" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/imports/{importJobId}:
    parameters:
      - name: importJobId
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Import]
      operationId: getImportJob
      summary: Retrieve an import job and its report
      description: |
        The report names every source entity and its outcome — imported, renamed,
        skipped, or failed, with the reason. This is the artefact a migrating user
        reads to confirm nothing was lost.
      x-cairn-phase: 1
      x-cairn-scopes: [imports:write]
      responses:
        "200":
          description: The import job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportJob" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # --------------------------------------------------------------------------
  # Settings
  # --------------------------------------------------------------------------

  /api/v1/settings:
    get:
      tags: [Settings]
      operationId: getSettings
      summary: Retrieve instance settings
      x-cairn-phase: 1
      x-cairn-scopes: [settings:read]
      responses:
        "200":
          description: Current settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    patch:
      tags: [Settings]
      operationId: updateSettings
      summary: Update instance settings
      description: Partial update. Omitted sections and fields are left unchanged.
      x-cairn-phase: 1
      x-cairn-scopes: [settings:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Settings" }
      responses:
        "200":
          description: Updated settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  # --------------------------------------------------------------------------
  # Users
  # --------------------------------------------------------------------------

  /api/v1/users/me:
    get:
      tags: [Users]
      operationId: getCurrentUser
      summary: Retrieve the authenticated user
      x-cairn-phase: 1
      responses:
        "200":
          description: The current user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Users]
      operationId: updateCurrentUser
      summary: Update the authenticated user
      description: Changing the password requires `current_password` and invalidates all other sessions.
      x-cairn-phase: 1
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CurrentUserUpdate" }
      responses:
        "200":
          description: The updated user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/users:
    get:
      tags: [Users]
      operationId: listUsers
      summary: List users
      description: |
        Phase 1 installs hold exactly one administrator; this endpoint exists from v1 so
        that the multi-user work in Phase 3 adds roles rather than a new resource.
      x-cairn-phase: 1
      x-cairn-scopes: [users:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
      responses:
        "200":
          description: A page of users.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/User" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Users]
      operationId: createUser
      summary: Create a user
      x-cairn-phase: 3
      x-cairn-scopes: [users:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UserWrite" }
      responses:
        "201":
          description: User created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/users/{userId}:
    parameters:
      - $ref: "#/components/parameters/UserId"
    get:
      tags: [Users]
      operationId: getUser
      summary: Retrieve a user
      x-cairn-phase: 1
      x-cairn-scopes: [users:read]
      responses:
        "200":
          description: The user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Users]
      operationId: updateUser
      summary: Update a user
      x-cairn-phase: 3
      x-cairn-scopes: [users:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UserWrite" }
      responses:
        "200":
          description: The updated user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Users]
      operationId: deleteUser
      summary: Delete a user
      description: The last remaining owner cannot be deleted.
      x-cairn-phase: 3
      x-cairn-scopes: [users:write]
      responses:
        "204": { description: User deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  # --------------------------------------------------------------------------
  # Teams (Phase 3)
  # --------------------------------------------------------------------------

  /api/v1/teams:
    get:
      tags: [Teams]
      operationId: listTeams
      summary: List teams
      x-cairn-phase: 3
      x-cairn-scopes: [teams:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
      responses:
        "200":
          description: A page of teams.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Team" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Teams]
      operationId: createTeam
      summary: Create a team
      x-cairn-phase: 3
      x-cairn-scopes: [teams:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TeamWrite" }
      responses:
        "201":
          description: Team created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Team" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/teams/{teamId}:
    parameters:
      - $ref: "#/components/parameters/TeamId"
    get:
      tags: [Teams]
      operationId: getTeam
      summary: Retrieve a team
      x-cairn-phase: 3
      x-cairn-scopes: [teams:read]
      responses:
        "200":
          description: The team.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Team" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Teams]
      operationId: updateTeam
      summary: Update a team
      x-cairn-phase: 3
      x-cairn-scopes: [teams:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TeamWrite" }
      responses:
        "200":
          description: The updated team.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Team" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Teams]
      operationId: deleteTeam
      summary: Delete a team
      x-cairn-phase: 3
      x-cairn-scopes: [teams:write]
      responses:
        "204": { description: Team deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # --------------------------------------------------------------------------
  # On-call schedules and escalation (Phase 3)
  # --------------------------------------------------------------------------

  /api/v1/schedules:
    get:
      tags: [On-Call]
      operationId: listSchedules
      summary: List on-call schedules
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
      responses:
        "200":
          description: A page of schedules.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/OnCallSchedule" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [On-Call]
      operationId: createSchedule
      summary: Create an on-call schedule
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OnCallScheduleWrite" }
      responses:
        "201":
          description: Schedule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OnCallSchedule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/schedules/{scheduleId}:
    parameters:
      - $ref: "#/components/parameters/ScheduleId"
    get:
      tags: [On-Call]
      operationId: getSchedule
      summary: Retrieve an on-call schedule
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:read]
      responses:
        "200":
          description: The schedule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OnCallSchedule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [On-Call]
      operationId: updateSchedule
      summary: Update an on-call schedule
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OnCallScheduleWrite" }
      responses:
        "200":
          description: The updated schedule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OnCallSchedule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [On-Call]
      operationId: deleteSchedule
      summary: Delete an on-call schedule
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:write]
      responses:
        "204": { description: Schedule deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/schedules/{scheduleId}/on-call:
    parameters:
      - $ref: "#/components/parameters/ScheduleId"
    get:
      tags: [On-Call]
      operationId: getWhoIsOnCall
      summary: Who is on call
      description: Resolves the schedule, its rotations, and any overrides to the people on call over a window.
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:read]
      parameters:
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
      responses:
        "200":
          description: Resolved on-call shifts.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/OnCallShift" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/escalation-policies:
    get:
      tags: [On-Call]
      operationId: listEscalationPolicies
      summary: List escalation policies
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
      responses:
        "200":
          description: A page of escalation policies.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EscalationPolicy" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [On-Call]
      operationId: createEscalationPolicy
      summary: Create an escalation policy
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EscalationPolicyWrite" }
      responses:
        "201":
          description: Policy created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EscalationPolicy" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/escalation-policies/{escalationPolicyId}:
    parameters:
      - name: escalationPolicyId
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [On-Call]
      operationId: getEscalationPolicy
      summary: Retrieve an escalation policy
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:read]
      responses:
        "200":
          description: The policy.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EscalationPolicy" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [On-Call]
      operationId: updateEscalationPolicy
      summary: Update an escalation policy
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EscalationPolicyWrite" }
      responses:
        "200":
          description: The updated policy.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EscalationPolicy" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [On-Call]
      operationId: deleteEscalationPolicy
      summary: Delete an escalation policy
      x-cairn-phase: 3
      x-cairn-scopes: [schedules:write]
      responses:
        "204": { description: Policy deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # --------------------------------------------------------------------------
  # Reports (Phase 2)
  # --------------------------------------------------------------------------

  /api/v1/report-templates:
    get:
      tags: [Reports]
      operationId: listReportTemplates
      summary: List report templates
      x-cairn-phase: 2
      x-cairn-scopes: [reports:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - $ref: "#/components/parameters/Search"
      responses:
        "200":
          description: A page of templates.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ReportTemplate" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Reports]
      operationId: createReportTemplate
      summary: Create a report template
      x-cairn-phase: 2
      x-cairn-scopes: [reports:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReportTemplateWrite" }
      responses:
        "201":
          description: Template created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportTemplate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/report-templates/{reportTemplateId}:
    parameters:
      - $ref: "#/components/parameters/ReportTemplateId"
    get:
      tags: [Reports]
      operationId: getReportTemplate
      summary: Retrieve a report template
      x-cairn-phase: 2
      x-cairn-scopes: [reports:read]
      responses:
        "200":
          description: The template.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportTemplate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Reports]
      operationId: updateReportTemplate
      summary: Update a report template
      x-cairn-phase: 2
      x-cairn-scopes: [reports:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReportTemplateWrite" }
      responses:
        "200":
          description: The updated template.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportTemplate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Reports]
      operationId: deleteReportTemplate
      summary: Delete a report template
      description: Already-generated runs and their artefacts are retained.
      x-cairn-phase: 2
      x-cairn-scopes: [reports:write]
      responses:
        "204": { description: Template deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/report-templates/{reportTemplateId}/generate:
    parameters:
      - $ref: "#/components/parameters/ReportTemplateId"
    post:
      tags: [Reports]
      operationId: generateReport
      summary: Generate a report now
      description: Queues a one-off run over the supplied period and returns the run to poll.
      x-cairn-phase: 2
      x-cairn-scopes: [reports:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReportGenerateRequest" }
      responses:
        "202":
          description: Run queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportRun" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/report-schedules:
    get:
      tags: [Reports]
      operationId: listReportSchedules
      summary: List report schedules
      x-cairn-phase: 2
      x-cairn-scopes: [reports:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
      responses:
        "200":
          description: A page of report schedules.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ReportSchedule" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Reports]
      operationId: createReportSchedule
      summary: Create a report schedule
      description: |
        The delivery half of the differentiator — fifty branded client reports on the
        first of the month, delivered by email, Slack, webhook, or S3 drop, with nobody
        touching anything.
      x-cairn-phase: 2
      x-cairn-scopes: [reports:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReportScheduleWrite" }
      responses:
        "201":
          description: Schedule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportSchedule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /api/v1/report-schedules/{reportScheduleId}:
    parameters:
      - name: reportScheduleId
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Reports]
      operationId: getReportSchedule
      summary: Retrieve a report schedule
      x-cairn-phase: 2
      x-cairn-scopes: [reports:read]
      responses:
        "200":
          description: The schedule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportSchedule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Reports]
      operationId: updateReportSchedule
      summary: Update a report schedule
      x-cairn-phase: 2
      x-cairn-scopes: [reports:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReportScheduleWrite" }
      responses:
        "200":
          description: The updated schedule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportSchedule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Reports]
      operationId: deleteReportSchedule
      summary: Delete a report schedule
      x-cairn-phase: 2
      x-cairn-scopes: [reports:write]
      responses:
        "204": { description: Schedule deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/report-runs:
    get:
      tags: [Reports]
      operationId: listReportRuns
      summary: List report runs
      x-cairn-phase: 2
      x-cairn-scopes: [reports:read]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CollectionLimit"
        - name: report_template_id
          in: query
          schema: { type: string, format: uuid }
        - name: state
          in: query
          schema:
            type: array
            items: { type: string, enum: [queued, running, succeeded, failed] }
          style: form
          explode: true
      responses:
        "200":
          description: A page of runs.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ReportRun" }
                  pagination: { $ref: "#/components/schemas/CursorPagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/report-runs/{reportRunId}:
    parameters:
      - $ref: "#/components/parameters/ReportRunId"
    get:
      tags: [Reports]
      operationId: getReportRun
      summary: Retrieve a report run
      x-cairn-phase: 2
      x-cairn-scopes: [reports:read]
      responses:
        "200":
          description: The run and its artefacts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportRun" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/report-runs/{reportRunId}/download:
    parameters:
      - $ref: "#/components/parameters/ReportRunId"
    get:
      tags: [Reports]
      operationId: downloadReportArtifact
      summary: Download a generated report
      x-cairn-phase: 2
      x-cairn-scopes: [reports:read]
      parameters:
        - name: format
          in: query
          required: true
          schema: { $ref: "#/components/schemas/ReportFormat" }
      responses:
        "200":
          description: The rendered report.
          content:
            application/pdf:
              schema: { type: string, format: binary }
            text/html:
              schema: { type: string }
            text/csv:
              schema: { type: string }
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

components:

  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: cairn_<key>
      description: |
        A scoped API key: `Authorization: Bearer cairn_<key>`. Keys carry an explicit
        scope list, an optional expiry, and a last-used timestamp, and can be revoked
        at any time. Every operation names the scopes it requires in `x-cairn-scopes`.
    SessionCookie:
      type: apiKey
      in: cookie
      name: cairn_session
      description: |
        The browser session established by `POST /api/v1/auth/login`. The cookie is
        `HttpOnly`, `SameSite=Lax`, and `Secure` when served over HTTPS.
        State-changing requests authenticated this way must also carry a matching
        `X-Cairn-CSRF-Token` header.

  parameters:
    CollectionLimit:
      name: limit
      in: query
      description: |
        Maximum items to return. The server caps this; requesting more is clamped,
        not rejected.
      schema: { type: integer, minimum: 1, maximum: 200, default: 25 }
    Search:
      name: q
      in: query
      description: Free-text search across the resource's human-readable fields.
      schema: { type: string, maxLength: 200 }
    Cursor:
      name: cursor
      in: query
      description: |
        Opaque cursor from a previous response's `pagination.next_cursor`. Cursors are
        not decodable, not sortable, and not guaranteed stable across releases.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      description: Maximum items to return for cursor-paginated collections.
      schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
    From:
      name: from
      in: query
      description: Start of the time range, inclusive. RFC 3339. Defaults to 24 hours before `to`.
      schema: { type: string, format: date-time }
    To:
      name: to
      in: query
      description: End of the time range, exclusive. RFC 3339. Defaults to now.
      schema: { type: string, format: date-time }
    MonitorId:
      name: monitorId
      in: path
      required: true
      schema: { type: string, format: uuid }
    GroupId:
      name: groupId
      in: path
      required: true
      schema: { type: string, format: uuid }
    TagId:
      name: tagId
      in: path
      required: true
      schema: { type: string, format: uuid }
    ChannelId:
      name: channelId
      in: path
      required: true
      schema: { type: string, format: uuid }
    MaintenanceWindowId:
      name: maintenanceWindowId
      in: path
      required: true
      schema: { type: string, format: uuid }
    IncidentId:
      name: incidentId
      in: path
      required: true
      schema: { type: string, format: uuid }
    StatusPageId:
      name: statusPageId
      in: path
      required: true
      schema: { type: string, format: uuid }
    StatusPageSlug:
      name: slug
      in: path
      required: true
      description: The status page's URL slug.
      schema: { type: string, pattern: "^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$" }
    WebhookId:
      name: webhookId
      in: path
      required: true
      schema: { type: string, format: uuid }
    ApiKeyId:
      name: apiKeyId
      in: path
      required: true
      schema: { type: string, format: uuid }
    UserId:
      name: userId
      in: path
      required: true
      schema: { type: string, format: uuid }
    TeamId:
      name: teamId
      in: path
      required: true
      schema: { type: string, format: uuid }
    ScheduleId:
      name: scheduleId
      in: path
      required: true
      schema: { type: string, format: uuid }
    ReportTemplateId:
      name: reportTemplateId
      in: path
      required: true
      schema: { type: string, format: uuid }
    ReportRunId:
      name: reportRunId
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    BadRequest:
      description: The request was malformed — unparseable parameters, an invalid range, a bad cursor.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: No credentials were supplied, or they were not valid.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Forbidden:
      description: |
        The caller is authenticated but lacks the required scope or role. The problem
        document names the missing scope, so an operator can fix the key rather than guess.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: No such resource.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Conflict:
      description: The request conflicts with the current state — a duplicate slug, a referenced resource still in use.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    PayloadTooLarge:
      description: The upload exceeded the configured limit.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ValidationFailed:
      description: |
        The request was well-formed but semantically invalid. `errors` names each
        offending field by JSON pointer.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ValidationProblem" }
    RateLimited:
      description: Too many requests.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:

    # ------------------------------------------------------------------------
    # Errors, pagination, and shared primitives
    # ------------------------------------------------------------------------

    Problem:
      type: object
      description: An error, per RFC 9457.
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: |
            A stable URI identifying the error kind. Clients branch on this, never on
            `title` or `detail`, both of which are prose and may be translated.
          examples: ["https://uptimecairn.dev/errors/monitor-not-found"]
        title:
          type: string
          description: A short, human-readable summary of the error kind.
        status:
          type: integer
          description: The HTTP status code, repeated for clients that only read the body.
        detail:
          type: string
          description: A human-readable explanation specific to this occurrence.
        instance:
          type: string
          format: uri-reference
          description: The request path that produced the error.
        request_id:
          type: string
          description: Correlates this response with the server log entry.

    ValidationProblem:
      allOf:
        - $ref: "#/components/schemas/Problem"
        - type: object
          properties:
            errors:
              type: array
              description: One entry per invalid field.
              items:
                type: object
                required: [pointer, code, message]
                properties:
                  pointer:
                    type: string
                    description: RFC 6901 JSON pointer to the offending field.
                    examples: ["/config/interval_seconds"]
                  code:
                    type: string
                    description: Stable machine-readable reason.
                    examples: ["below_minimum"]
                  message:
                    type: string
                    description: Human-readable explanation.

    CursorPagination:
      type: object
      description: |
        The only pagination model in this API, per ADR-004: every list view is a
        cursor-paginated query keyed on `(updated_at, id)`, applied uniformly
        regardless of how few rows an install has. Iterate until `next_cursor` is null.

        There is deliberately **no total count**. Computing one costs a full scan of
        the filtered set on every page fetch, which is the cost cursor pagination
        exists to avoid. A count for the current filter comes from
        `GET /api/v1/monitors/membership` instead, which returns it alongside the
        change signal the client is already polling.
      required: [next_cursor, has_more]
      properties:
        next_cursor:
          type: [string, "null"]
          description: Pass as `cursor` to fetch the next page. Null when the collection is exhausted.
        has_more: { type: boolean }

    BulkResult:
      type: object
      description: The outcome of a bulk operation, reported per identifier.
      required: [succeeded, failed]
      properties:
        succeeded:
          type: array
          items: { type: string, format: uuid }
        failed:
          type: array
          items:
            type: object
            required: [id, code, message]
            properties:
              id: { type: string, format: uuid }
              code: { type: string }
              message: { type: string }

    HealthStatus:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [ok, degraded, unavailable] }
        checks:
          type: array
          description: Individual subsystem checks. Present on readiness, omitted on liveness.
          items:
            type: object
            required: [name, status]
            properties:
              name:
                type: string
                examples: ["database", "migrations", "scheduler"]
              status: { type: string, enum: [ok, degraded, unavailable] }
              detail: { type: string }

    SystemInfo:
      type: object
      required: [version, mode]
      properties:
        version:
          type: string
          description: Semantic version of the running build.
          examples: ["0.1.0"]
        commit: { type: string }
        built_at: { type: string, format: date-time }
        mode:
          type: string
          enum: [solo, control_plane, probe]
          description: |
            Deployment shape. Solo is the single binary with an embedded probe and
            SQLite; the others are the scaled-mode split from ADR-001.
        storage_engine: { type: string, enum: [sqlite, postgres_timescale] }
        api_version: { type: string, examples: ["v1"] }
        monitor_types:
          type: array
          description: Monitor types this build supports, so clients need not hardcode the list.
          items: { $ref: "#/components/schemas/MonitorType" }
        notification_channel_types:
          type: array
          items: { $ref: "#/components/schemas/NotificationChannelType" }
        capabilities:
          type: object
          description: |
            Feature flags reflecting what this build and configuration can do — the
            mechanism progressive disclosure runs on. The dashboard hides surfaces the
            instance does not have rather than showing dead controls.
          additionalProperties: { type: boolean }

    Overview:
      type: object
      description: Aggregate dashboard counts.
      required: [monitors, generated_at]
      properties:
        monitors:
          type: object
          required: [total, up, down, pending, paused, maintenance]
          properties:
            total: { type: integer }
            up: { type: integer }
            down: { type: integer }
            pending: { type: integer }
            paused: { type: integer }
            maintenance: { type: integer }
        active_incidents: { type: integer }
        active_maintenance_windows: { type: integer }
        certificates_expiring_soon:
          type: integer
          description: Monitors whose observed certificate falls inside its configured threshold.
        domains_expiring_soon: { type: integer }
        generated_at: { type: string, format: date-time }

    # ------------------------------------------------------------------------
    # Setup, sessions, and API keys
    # ------------------------------------------------------------------------

    SetupRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        name: { type: string, maxLength: 200 }
        password:
          type: string
          minLength: 12
          writeOnly: true
          description: Hashed with argon2id. Minimum length is enforced server-side and may be raised by settings.
        instance_name: { type: string, maxLength: 200 }
        timezone:
          type: string
          description: IANA timezone name used for schedules, reports, and display.
          examples: ["Australia/Sydney"]

    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string, writeOnly: true }
        totp_code:
          type: string
          pattern: "^[0-9]{6}$"
          writeOnly: true
          description: Required when the account has TOTP enabled.
        recovery_code:
          type: string
          writeOnly: true
          description: A single-use recovery code, accepted in place of `totp_code`.

    Session:
      type: object
      description: The authenticated principal.
      required: [principal_type]
      properties:
        principal_type: { type: string, enum: [user, api_key] }
        user: { $ref: "#/components/schemas/User" }
        api_key_id: { type: [string, "null"], format: uuid }
        scopes:
          type: array
          description: Effective scopes. A user session holds the scopes its role grants.
          items: { $ref: "#/components/schemas/ApiKeyScope" }
        csrf_token:
          type: [string, "null"]
          description: Present for cookie sessions; echo it in `X-Cairn-CSRF-Token` on writes.
        expires_at: { type: [string, "null"], format: date-time }

    TotpEnrolment:
      type: object
      required: [secret, provisioning_uri]
      properties:
        secret:
          type: string
          description: Base32 shared secret. Shown once, during enrolment.
        provisioning_uri:
          type: string
          description: otpauth:// URI for QR rendering.

    TotpDisableRequest:
      type: object
      required: [password]
      properties:
        password: { type: string, writeOnly: true }
        totp_code: { type: string, pattern: "^[0-9]{6}$", writeOnly: true }
        recovery_code: { type: string, writeOnly: true }

    ApiKeyScope:
      type: string
      description: |
        A permission grant. Scopes are `<resource>:<read|write>`; `write` implies `read`
        on the same resource. Clients must tolerate scopes added in later releases.
      enum:
        - monitors:read
        - monitors:write
        - heartbeats:read
        - groups:read
        - groups:write
        - tags:read
        - tags:write
        - notifications:read
        - notifications:write
        - maintenance:read
        - maintenance:write
        - incidents:read
        - incidents:write
        - status_pages:read
        - status_pages:write
        - webhooks:read
        - webhooks:write
        - imports:write
        - settings:read
        - settings:write
        - users:read
        - users:write
        - api_keys:read
        - api_keys:write
        - metrics:read
        - teams:read
        - teams:write
        - schedules:read
        - schedules:write
        - reports:read
        - reports:write

    ApiKey:
      type: object
      required: [id, name, prefix, scopes, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, maxLength: 200 }
        prefix:
          type: string
          description: The key's non-secret leading characters, so a key can be identified in a list.
          examples: ["cairn_7Kq2"]
        scopes:
          type: array
          items: { $ref: "#/components/schemas/ApiKeyScope" }
        expires_at: { type: [string, "null"], format: date-time }
        last_used_at:
          type: [string, "null"]
          format: date-time
          description: Null until first use. The signal for finding keys safe to revoke.
        created_at: { type: string, format: date-time }
        created_by: { type: [string, "null"], format: uuid }

    ApiKeyCreate:
      type: object
      required: [name, scopes]
      properties:
        name: { type: string, maxLength: 200 }
        scopes:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/ApiKeyScope" }
        expires_at:
          type: [string, "null"]
          format: date-time
          description: Null means no expiry.

    ApiKeyUpdate:
      type: object
      properties:
        name: { type: string, maxLength: 200 }
        scopes:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/ApiKeyScope" }
        expires_at: { type: [string, "null"], format: date-time }

    ApiKeyCreated:
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          required: [key]
          properties:
            key:
              type: string
              description: |
                The plaintext key. Returned in this response only — the server stores a
                hash and cannot show it again.
              examples: ["cairn_7Kq2xM4vPn8sTbYw1eR6uJ3aZfLd0gHc"]

    # ------------------------------------------------------------------------
    # Monitors
    #
    # A monitor is `MonitorCommon` plus a `type` discriminator and a `config`
    # object whose shape that type determines. Server-managed fields are marked
    # readOnly and are always present on reads; the `required` lists describe
    # what a client must send when writing.
    # ------------------------------------------------------------------------

    MonitorType:
      type: string
      description: |
        The check a monitor performs. Immutable after creation — changing it would make
        the monitor's own history incomparable with itself.
      enum: [http, tcp, icmp, dns, tls_expiry, domain_expiry, push, docker, grpc]

    MonitorStatus:
      type: string
      description: |
        `pending` covers both a monitor that has never reported and one that is retrying
        below its failure threshold — it has not yet earned a verdict either way.
      enum: [up, down, pending, paused, maintenance]

    MonitorCommon:
      type: object
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string, minLength: 1, maxLength: 200 }
        description:
          type: [string, "null"]
          maxLength: 5000
          description: Free-text notes. Shown on monitor detail and available to templates.
        enabled:
          type: boolean
          default: true
          description: A disabled monitor is not scheduled and raises no alerts. Equivalent to paused.
        interval_seconds:
          type: integer
          minimum: 20
          maximum: 86400
          default: 60
          description: Seconds between checks. The 20-second floor is a product commitment.
        timeout_seconds:
          type: integer
          minimum: 1
          maximum: 300
          default: 30
          description: Per-check timeout. Must be less than `interval_seconds`.
        retries:
          type: integer
          minimum: 0
          maximum: 20
          default: 0
          description: |
            Consecutive failures required before the monitor is declared down. Zero means
            the first failure counts. While retrying, status is `pending`, not `down`.
        retry_interval_seconds:
          type: [integer, "null"]
          minimum: 20
          maximum: 86400
          description: Interval used while retrying. Null uses `interval_seconds`.
        resend_after:
          type: integer
          minimum: 0
          default: 0
          description: |
            Re-notify after this many consecutive failed checks while still down.
            Zero disables resending, so an ongoing outage alerts once.
        upside_down:
          type: boolean
          default: false
          description: Invert the verdict — a failing check means up. For checks that should not succeed.
        group_id: { type: [string, "null"], format: uuid }
        tag_ids:
          type: array
          items: { type: string, format: uuid }
        parent_monitor_id:
          type: [string, "null"]
          format: uuid
          description: |
            Dependency parent. While the parent is down, this monitor's alerts are
            suppressed and its heartbeats are annotated as suppressed — the router being
            down should page once, not forty times. Cycles are rejected.
        probe_id:
          type: [string, "null"]
          format: uuid
          description: |
            Pin this monitor to one named probe. Null means unpinned: any probe may
            run it, which is what every type except `docker` wants.

            `docker` is the type that needs this. Every other check answers the same
            question from anywhere with the right egress — an `http` monitor checked
            from two continents is two opinions about one target — while "is this
            container running" is a question about one host's daemon, and there is
            no second opinion to be had. The pin is *placement* rather than checking,
            so it is a field on the monitor rather than a key inside `config`; the
            next type that needs it wants no second mechanism.

            On a `docker` monitor the server fills this in when the install has
            exactly one probe, which is every solo install, and refuses the write
            with a `/probe_id` error when it has more than one. Guessing which host
            was meant produces a monitor reporting a container missing that was never
            meant to be there.
        notification_channel_ids:
          type: array
          description: Channels alerted on state change. Empty means no alerts for this monitor.
          items: { type: string, format: uuid }
        notify_on_recovery:
          type: boolean
          default: true
          description: Whether recovery sends a notification as well as the failure.
        status:
          allOf:
            - $ref: "#/components/schemas/MonitorStatus"
          readOnly: true
        last_check_at: { type: [string, "null"], format: date-time, readOnly: true }
        next_check_at: { type: [string, "null"], format: date-time, readOnly: true }
        last_heartbeat:
          allOf:
            - $ref: "#/components/schemas/Heartbeat"
          readOnly: true
          description: Present when requested via `include=last_heartbeat`.
        heartbeats:
          type: array
          items: { $ref: "#/components/schemas/Heartbeat" }
          readOnly: true
          description: |
            Present when requested via `include=heartbeats`. Most recent first,
            at most `heartbeats_limit` entries. A monitor that has never been
            checked carries an empty array rather than being absent, which is
            the difference between "no history" and "not asked for".
        uptime:
          allOf:
            - $ref: "#/components/schemas/UptimeSummary"
          readOnly: true
          description: Present when requested via `include=uptime`.
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    MonitorLiveUpdate:
      type: object
      description: |
        One monitor's change, as a live subscriber sees it. A diff, not a monitor:
        the client already holds the row, and re-sending its configuration on every
        heartbeat is the full-state broadcast this design exists to avoid, arriving
        one row at a time instead of all at once.
      required: [monitor_id, status, at, important, state_version]
      properties:
        monitor_id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/MonitorStatus" }
        at: { type: string, format: date-time }
        response_time_ms:
          type: [number, "null"]
          description: Null when nothing was measured, which is every failure that never reached the target.
        important:
          type: boolean
          description: |
            A transition. The same flag the stored heartbeat carries, so the row a
            client draws attention to and the row the history marks cannot disagree.
        message:
          type: string
          description: Only on failures and transitions. An up-to-up check has nothing to say.
        state_version:
          type: integer
          format: int64
          description: |
            The monitor's version after this change, so a client can discard an
            update it has already applied — which happens on the seam between a
            page refetch and the stream that was running during it.

    MonitorSummary:
      type: object
      description: |
        The global header counts. Computed server-side and pushed to every stream
        regardless of scope, because a client summing its own subscription would
        report "3 down" for a filtered view holding three of the down monitors.
      required: [counts, at]
      properties:
        counts:
          type: object
          additionalProperties: { type: integer }
          description: Monitor count per status, keyed by `MonitorStatus`.
        at: { type: string, format: date-time }

    Probe:
      type: object
      description: |
        One check executor. Solo mode has exactly one, embedded and seeded by the
        first migration — heartbeats reference a probe from the first commit, because
        nullable-and-backfill-later is the retrofit ADR-001 exists to avoid.
      required: [id, name, mode, enabled, created_at]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string }
        region:
          type: [string, "null"]
          description: |
            Advisory, and never how a host-local monitor is placed: a region can hold
            more than one host, and `docker` needs the host.
        mode:
          type: string
          enum: [embedded, remote]
          description: |
            `embedded` is the probe compiled into this binary, whose row *is* its
            identity and which therefore carries no credential. `remote` enrols.
        version:
          type: [string, "null"]
          description: Agent version last reported. Null for a probe that has never connected.
        last_seen_at: { type: [string, "null"], format: date-time }
        enabled: { type: boolean }
        created_at: { type: string, format: date-time, readOnly: true }

    Monitor:
      description: A configured check. The `type` field selects the shape of `config`.
      oneOf:
        - $ref: "#/components/schemas/HttpMonitor"
        - $ref: "#/components/schemas/TcpMonitor"
        - $ref: "#/components/schemas/IcmpMonitor"
        - $ref: "#/components/schemas/DnsMonitor"
        - $ref: "#/components/schemas/TlsExpiryMonitor"
        - $ref: "#/components/schemas/DomainExpiryMonitor"
        - $ref: "#/components/schemas/PushMonitor"
        - $ref: "#/components/schemas/DockerMonitor"
        - $ref: "#/components/schemas/GrpcMonitor"
      discriminator:
        propertyName: type
        mapping:
          http: "#/components/schemas/HttpMonitor"
          tcp: "#/components/schemas/TcpMonitor"
          icmp: "#/components/schemas/IcmpMonitor"
          dns: "#/components/schemas/DnsMonitor"
          tls_expiry: "#/components/schemas/TlsExpiryMonitor"
          domain_expiry: "#/components/schemas/DomainExpiryMonitor"
          push: "#/components/schemas/PushMonitor"
          docker: "#/components/schemas/DockerMonitor"
          grpc: "#/components/schemas/GrpcMonitor"

    MonitorWrite:
      description: Request body for creating a monitor. Same shape as `Monitor`; readOnly fields are ignored.
      $ref: "#/components/schemas/Monitor"

    MonitorUpdate:
      description: |
        Partial update. Every field is optional; `type` may not be changed. `config` is
        merged shallowly against the monitor's existing configuration, so a caller can
        change one setting without restating the rest.
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 5000 }
        enabled: { type: boolean }
        interval_seconds: { type: integer, minimum: 20, maximum: 86400 }
        timeout_seconds: { type: integer, minimum: 1, maximum: 300 }
        retries: { type: integer, minimum: 0, maximum: 20 }
        retry_interval_seconds: { type: [integer, "null"], minimum: 20, maximum: 86400 }
        resend_after: { type: integer, minimum: 0 }
        upside_down: { type: boolean }
        group_id: { type: [string, "null"], format: uuid }
        tag_ids:
          type: array
          items: { type: string, format: uuid }
        parent_monitor_id: { type: [string, "null"], format: uuid }
        probe_id:
          type: [string, "null"]
          format: uuid
          description: |
            Absent leaves the pin as it is; null unpins; a value repins. Unpinning a
            `docker` monitor on an install with more than one probe is refused.
        notification_channel_ids:
          type: array
          items: { type: string, format: uuid }
        notify_on_recovery: { type: boolean }
        config:
          description: Partial configuration, validated against the monitor's existing type.
          oneOf:
            - $ref: "#/components/schemas/HttpConfig"
            - $ref: "#/components/schemas/TcpConfig"
            - $ref: "#/components/schemas/IcmpConfig"
            - $ref: "#/components/schemas/DnsConfig"
            - $ref: "#/components/schemas/TlsExpiryConfig"
            - $ref: "#/components/schemas/DomainExpiryConfig"
            - $ref: "#/components/schemas/PushConfig"
            - $ref: "#/components/schemas/DockerConfig"
            - $ref: "#/components/schemas/GrpcConfig"

    MonitorBulkRequest:
      type: object
      required: [monitor_ids, operation]
      properties:
        monitor_ids:
          type: array
          minItems: 1
          maxItems: 1000
          items: { type: string, format: uuid }
        operation:
          type: string
          enum: [enable, disable, delete, add_tags, remove_tags, set_group, set_notification_channels]
        tag_ids:
          type: array
          description: Required for `add_tags` and `remove_tags`.
          items: { type: string, format: uuid }
        group_id:
          type: [string, "null"]
          format: uuid
          description: Required for `set_group`. Null removes the monitors from their group.
        notification_channel_ids:
          type: array
          description: Required for `set_notification_channels`. Replaces the existing assignment.
          items: { type: string, format: uuid }

    HttpMonitor:
      allOf:
        - $ref: "#/components/schemas/MonitorCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: http }
            config: { $ref: "#/components/schemas/HttpConfig" }

    TcpMonitor:
      allOf:
        - $ref: "#/components/schemas/MonitorCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: tcp }
            config: { $ref: "#/components/schemas/TcpConfig" }

    IcmpMonitor:
      allOf:
        - $ref: "#/components/schemas/MonitorCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: icmp }
            config: { $ref: "#/components/schemas/IcmpConfig" }

    DnsMonitor:
      allOf:
        - $ref: "#/components/schemas/MonitorCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: dns }
            config: { $ref: "#/components/schemas/DnsConfig" }

    TlsExpiryMonitor:
      allOf:
        - $ref: "#/components/schemas/MonitorCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: tls_expiry }
            config: { $ref: "#/components/schemas/TlsExpiryConfig" }

    DomainExpiryMonitor:
      allOf:
        - $ref: "#/components/schemas/MonitorCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: domain_expiry }
            config: { $ref: "#/components/schemas/DomainExpiryConfig" }

    PushMonitor:
      allOf:
        - $ref: "#/components/schemas/MonitorCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: push }
            config: { $ref: "#/components/schemas/PushConfig" }

    DockerMonitor:
      allOf:
        - $ref: "#/components/schemas/MonitorCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: docker }
            config: { $ref: "#/components/schemas/DockerConfig" }

    GrpcMonitor:
      allOf:
        - $ref: "#/components/schemas/MonitorCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: grpc }
            config: { $ref: "#/components/schemas/GrpcConfig" }

    # --- Per-type configuration ---------------------------------------------

    HttpConfig:
      type: object
      description: |
        An HTTP/HTTPS check. Assertions are evaluated in order — status code, then
        keyword, then JSON path, then response time — and the first failure is what the
        heartbeat message reports, so an operator sees the actual cause rather than a
        generic failure.
      required: [url]
      properties:
        url: { type: string, format: uri }
        method:
          type: string
          enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS]
          default: GET
        headers:
          type: object
          description: Request headers. Values are stored encrypted and redacted on read.
          additionalProperties: { type: string }
        body: { type: [string, "null"] }
        body_encoding:
          type: string
          enum: [json, form, text, xml]
          default: json
          description: Sets the default Content-Type when one is not given in `headers`.
        accepted_status_codes:
          type: array
          description: Status codes or inclusive ranges treated as success.
          default: ["200-299"]
          items: { type: string, pattern: "^[1-5][0-9]{2}(-[1-5][0-9]{2})?$" }
        keyword:
          type: [object, "null"]
          description: Assert on the response body.
          required: [value, mode]
          properties:
            value: { type: string, minLength: 1 }
            mode: { type: string, enum: [contains, not_contains, regex, not_regex] }
            case_sensitive: { type: boolean, default: false }
        json_path:
          type: [object, "null"]
          description: Assert on a value extracted from a JSON response body.
          required: [path, operator]
          properties:
            path:
              type: string
              description: JSONPath expression.
              examples: ["$.status"]
            operator: { type: string, enum: [eq, ne, contains, gt, gte, lt, lte, exists, not_exists] }
            expected: { type: [string, "null"], description: Compared as a string; omitted for existence operators. }
        response_time_threshold_ms:
          type: [integer, "null"]
          minimum: 1
          description: Fail the check if the response takes longer than this, even on a 200.
        follow_redirects: { type: boolean, default: true }
        max_redirects: { type: integer, minimum: 0, maximum: 20, default: 10 }
        verify_tls:
          type: boolean
          default: true
          description: Disabling this is offered for internal targets with private CAs, and is surfaced as a warning in the UI.
        auth:
          type: [object, "null"]
          description: Credentials are stored encrypted and redacted on read.
          required: [type]
          properties:
            type: { type: string, enum: [none, basic, bearer] }
            username: { type: [string, "null"] }
            password: { type: [string, "null"], writeOnly: true }
            token: { type: [string, "null"], writeOnly: true }
        ip_family:
          type: string
          enum: [auto, ipv4, ipv6]
          default: auto

    TcpConfig:
      type: object
      description: A TCP connect check. Success is a completed handshake within the timeout.
      required: [hostname, port]
      properties:
        hostname: { type: string, minLength: 1 }
        port: { type: integer, minimum: 1, maximum: 65535 }
        ip_family: { type: string, enum: [auto, ipv4, ipv6], default: auto }

    IcmpConfig:
      type: object
      description: |
        An ICMP ping check.

        Raw sockets are unavailable in many container runtimes. When that is detected the
        monitor fails with an explicit, explanatory message rather than breaking silently,
        and — if `fallback_to_tcp` is set — automatically checks the named TCP port
        instead, recording on each heartbeat that it did so.
      required: [hostname]
      properties:
        hostname: { type: string, minLength: 1 }
        packet_size: { type: integer, minimum: 8, maximum: 65507, default: 56 }
        packet_count: { type: integer, minimum: 1, maximum: 10, default: 1 }
        fallback_to_tcp:
          type: boolean
          default: false
          description: Fall back to a TCP connect check when raw ICMP sockets are unavailable.
        fallback_tcp_port:
          type: [integer, "null"]
          minimum: 1
          maximum: 65535
          description: Required when `fallback_to_tcp` is true.
        ip_family: { type: string, enum: [auto, ipv4, ipv6], default: auto }

    DnsConfig:
      type: object
      description: A DNS record check against a chosen resolver.
      required: [hostname, record_type]
      properties:
        hostname: { type: string, minLength: 1 }
        record_type:
          type: string
          enum: [A, AAAA, CNAME, MX, NS, TXT, SRV, CAA, SOA, PTR]
        resolver:
          type: [string, "null"]
          description: Resolver address. Null uses the system resolver.
          examples: ["1.1.1.1"]
        resolver_port: { type: integer, minimum: 1, maximum: 65535, default: 53 }
        expected_values:
          type: array
          description: Expected record values. Empty means the check asserts only that the record resolves.
          items: { type: string }
        match_mode:
          type: string
          enum: [any, all, exact]
          default: any
          description: |
            `any` passes if one expected value is present, `all` if every one is,
            `exact` if the answer set matches exactly with nothing extra.

    TlsExpiryConfig:
      type: object
      description: |
        A TLS certificate check. Fails when the certificate is within
        `days_remaining_threshold` of expiry, or when chain validation fails and
        `verify_chain` is set. Observed certificate detail is available from
        `/monitors/{id}/certificate`.
      required: [hostname]
      properties:
        hostname: { type: string, minLength: 1 }
        port: { type: integer, minimum: 1, maximum: 65535, default: 443 }
        server_name:
          type: [string, "null"]
          description: SNI server name, when it differs from `hostname`.
        days_remaining_threshold: { type: integer, minimum: 0, maximum: 3650, default: 14 }
        verify_chain: { type: boolean, default: true }

    DomainExpiryConfig:
      type: object
      description: |
        A domain registration expiry check via RDAP, falling back to WHOIS where RDAP is
        unavailable. Checked no more often than once a day regardless of the monitor's
        interval, because registries rate-limit and the data changes yearly.
      required: [domain]
      properties:
        domain: { type: string, minLength: 1 }
        days_remaining_threshold: { type: integer, minimum: 0, maximum: 3650, default: 30 }
        source: { type: string, enum: [auto, rdap, whois], default: auto }

    PushConfig:
      type: object
      description: |
        A dead-man's switch. The monitor is up while something calls
        `/api/v1/push/{push_token}` at least once per `expected_interval_seconds`, and
        goes down once that interval plus `grace_period_seconds` elapses with no call.
      properties:
        push_token:
          type: string
          readOnly: true
          description: |
            Issued on creation. Treat as a secret — anyone holding it can report this
            monitor healthy. Rotate by recreating the monitor.
        push_url:
          type: string
          format: uri
          readOnly: true
          description: The full URL to call, assembled from the instance base URL.
        expected_interval_seconds:
          type: integer
          minimum: 20
          maximum: 2592000
          default: 60
        grace_period_seconds:
          type: integer
          minimum: 0
          maximum: 86400
          default: 30
          description: Slack allowed on top of the expected interval before declaring down.

    DockerConfig:
      type: object
      description: |
        A container check via the Docker API. Requires the daemon socket to be reachable
        from wherever the probe runs, which in solo mode means mounting it into the container.
      required: [container]
      properties:
        container:
          type: string
          description: Container name or ID.
        docker_host:
          type: string
          default: "unix:///var/run/docker.sock"
          description: Socket path or TCP endpoint of the Docker daemon.
        require_healthy:
          type: boolean
          default: false
          description: |
            When true, a running container with a failing healthcheck counts as down.
            When false, running is sufficient.
        tls:
          type: [object, "null"]
          description: Client TLS material for a remote daemon. Stored encrypted, redacted on read.
          properties:
            ca_cert: { type: [string, "null"], writeOnly: true }
            client_cert: { type: [string, "null"], writeOnly: true }
            client_key: { type: [string, "null"], writeOnly: true }
            verify: { type: boolean, default: true }

    GrpcConfig:
      type: object
      description: A gRPC health check, per the standard `grpc.health.v1.Health/Check` protocol.
      required: [address]
      properties:
        address:
          type: string
          description: Host and port of the gRPC server.
          examples: ["api.example.com:443"]
        service_name:
          type: [string, "null"]
          description: Service to query. Null checks overall server health.
        use_tls: { type: boolean, default: true }
        verify_tls: { type: boolean, default: true }
        accepted_statuses:
          type: array
          description: Health statuses treated as success.
          default: [SERVING]
          items: { type: string, enum: [SERVING, NOT_SERVING, UNKNOWN, SERVICE_UNKNOWN] }
        metadata:
          type: object
          description: Request metadata. Stored encrypted, redacted on read.
          additionalProperties: { type: string }

    # ------------------------------------------------------------------------
    # Heartbeats and history
    # ------------------------------------------------------------------------

    HeartbeatStatus:
      type: string
      description: |
        `unknown` and `skipped` are statements about the probe, not the target, and are
        excluded from every uptime ratio exactly as an absent bucket is: `unknown` means
        the probe could not perform the check, `skipped` that it never started because
        the probe shed it under load. Collapsing either into `down` would report a probe
        outage as a total customer outage — see
        [ADR-005](../adr/005-probe-architecture.md) decision 16.
      enum: [up, down, pending, maintenance, unknown, skipped]

    Heartbeat:
      type: object
      description: One check result.
      required: [monitor_id, time, status]
      properties:
        monitor_id: { type: string, format: uuid }
        time: { type: string, format: date-time }
        status: { $ref: "#/components/schemas/HeartbeatStatus" }
        response_time_ms:
          type: [number, "null"]
          description: Null when the check did not complete far enough to time anything.
        message:
          type: [string, "null"]
          description: Human-readable outcome — the assertion that failed, or the transport error.
        code:
          type: [string, "null"]
          description: Protocol-level result, such as an HTTP status or a gRPC health status.
        attempt:
          type: integer
          minimum: 1
          description: Which retry produced this result. 1 for the first attempt.
        important:
          type: boolean
          description: |
            True when this heartbeat changed the monitor's state. The events, as opposed
            to the ticks — this is what `important_only` filters to and what drives alerts.
        suppressed:
          type: boolean
          default: false
          description: |
            True when notifications were withheld — a maintenance window was active, or a
            dependency parent was already down. The check still ran and still recorded a result.
        suppression_reason:
          type: [string, "null"]
          enum: [maintenance, dependency, null]
        probe_id:
          type: [string, "null"]
          description: |
            Which probe executed the check. Null in solo mode, where the probe is
            in-process and unnamed. Populated once multi-region probes ship in Phase 4.

    HistoryResolution:
      type: string
      description: |
        Rollup tier, per the tiered pipeline in ADR-002. `auto` lets the server choose the
        coarsest tier that still yields a useful number of buckets for the range.
      enum: [auto, raw, 1m, 5m, 1h, 1d]

    HistoryResponse:
      type: object
      required: [monitor_id, resolution, from, to, data]
      properties:
        monitor_id: { type: string, format: uuid }
        resolution:
          allOf:
            - $ref: "#/components/schemas/HistoryResolution"
          description: The tier actually used, which may be coarser than requested.
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        data:
          type: array
          items: { $ref: "#/components/schemas/HistoryBucket" }

    HistoryBucket:
      type: object
      required: [bucket_start, up_count, down_count]
      properties:
        bucket_start: { type: string, format: date-time }
        up_count: { type: integer, minimum: 0 }
        down_count: { type: integer, minimum: 0 }
        maintenance_count: { type: integer, minimum: 0 }
        pending_count: { type: integer, minimum: 0 }
        uptime_ratio:
          type: [number, "null"]
          minimum: 0
          maximum: 1
          description: Null for a bucket with no checks — a gap, which is not the same as downtime.
        response_time_avg_ms: { type: [number, "null"] }
        response_time_min_ms: { type: [number, "null"] }
        response_time_max_ms: { type: [number, "null"] }
        response_time_p95_ms: { type: [number, "null"] }

    UptimeSummary:
      type: object
      description: Uptime and latency statistics per named window.
      required: [windows]
      properties:
        maintenance_handling:
          type: string
          enum: [exclude, count_as_up, count_as_down]
          description: How maintenance time was treated. Reported so an SLA figure carries its own method.
        windows:
          type: object
          description: Keyed by window name — `24h`, `30d`, and so on.
          additionalProperties:
            type: object
            required: [uptime_ratio]
            properties:
              uptime_ratio: { type: [number, "null"], minimum: 0, maximum: 1 }
              total_checks: { type: integer, minimum: 0 }
              down_checks: { type: integer, minimum: 0 }
              downtime_seconds: { type: integer, minimum: 0 }
              maintenance_seconds: { type: integer, minimum: 0 }
              response_time_avg_ms: { type: [number, "null"] }
              response_time_p95_ms: { type: [number, "null"] }
              incident_count: { type: integer, minimum: 0 }

    CertificateInfo:
      type: object
      description: The TLS certificate observed on the most recent check.
      required: [valid_to, days_remaining]
      properties:
        subject: { type: string }
        issuer: { type: string }
        serial_number: { type: string }
        valid_from: { type: string, format: date-time }
        valid_to: { type: string, format: date-time }
        days_remaining: { type: integer }
        fingerprint_sha256: { type: string }
        subject_alternative_names:
          type: array
          items: { type: string }
        chain_valid: { type: boolean }
        chain_error: { type: [string, "null"] }
        observed_at: { type: string, format: date-time }

    PushHeartbeat:
      type: object
      description: Optional body for a push heartbeat.
      properties:
        status: { type: string, enum: [up, down], default: up }
        message: { type: string, maxLength: 1024 }
        response_time_ms: { type: number, minimum: 0 }

    # ------------------------------------------------------------------------
    # Live updates (ADR-004)
    # ------------------------------------------------------------------------

    MembershipSignal:
      type: object
      description: |
        A cheap answer to "has this filtered view changed?" — returned by
        `GET /api/v1/monitors/membership`.

        `version` covers content: it changes when any monitor matching the filter
        changes status or configuration. `count` covers membership: it changes when a
        monitor enters or leaves the filter. Both are needed — a monitor leaving a
        `status=down` filter as another enters it keeps the count identical while the
        view is now wrong.

        Neither value is meaningful in isolation or comparable across different
        filters. Clients store the pair per view and compare it to the previous poll.
      required: [version, count, generated_at]
      properties:
        version:
          type: integer
          format: int64
          description: |
            Monotonic within a filter. Derived server-side; clients must treat it as
            opaque and only ever test it for inequality against their previous value.
        count:
          type: integer
          minimum: 0
          description: |
            Monitors currently matching the filter. This is where a list UI gets its
            result count, since cursor pagination deliberately carries no total.
        generated_at: { type: string, format: date-time }

    MonitorStatusDiff:
      type: object
      description: |
        The payload pushed to a client subscribed to a monitor currently on its screen.

        **Not returned by any REST endpoint.** It is documented here because it is part
        of the API contract and a client has to be written against it. ADR-004 puts it
        on a NATS subject shaped `updates.{org_id}.{monitor_id}.status` in scaled mode
        and an in-process bus in solo mode; both present this identical shape, so a
        frontend never knows which mode it is talking to.

        It is a diff, not a state blob: only what changed is sent, and only for
        monitors the client has explicitly subscribed to. A client renders a page of
        monitors, subscribes to exactly those IDs, and unsubscribes when they leave the
        viewport — which is what makes push volume bounded by viewport size rather than
        by total monitor count.
      required: [monitor_id, status, at]
      properties:
        monitor_id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/MonitorStatus" }
        previous_status: { $ref: "#/components/schemas/MonitorStatus" }
        at: { type: string, format: date-time }
        response_time_ms: { type: [number, "null"] }
        message: { type: [string, "null"] }
        suppressed: { type: boolean }
        heartbeat:
          allOf:
            - $ref: "#/components/schemas/Heartbeat"
          description: The heartbeat that caused the change, present on state transitions.

    OverviewDiff:
      type: object
      description: |
        The global "X up, Y down" summary, pushed on its own channel independently of
        per-monitor subscriptions.

        Deliberately **not** computed by having the client sum whatever it happens to be
        subscribed to — that would silently couple a global number to viewport state, so
        the header would disagree with itself as the user paginated. Server-computed,
        decoupled from monitor count and viewport size alike.
      required: [monitors, generated_at]
      properties:
        monitors:
          type: object
          required: [total, up, down, pending, paused, maintenance]
          properties:
            total: { type: integer }
            up: { type: integer }
            down: { type: integer }
            pending: { type: integer }
            paused: { type: integer }
            maintenance: { type: integer }
        active_incidents: { type: integer }
        active_maintenance_windows: { type: integer }
        generated_at: { type: string, format: date-time }

    # ------------------------------------------------------------------------
    # Groups and tags
    # ------------------------------------------------------------------------

    Group:
      type: object
      required: [id, name]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 2000 }
        parent_group_id:
          type: [string, "null"]
          format: uuid
          description: Groups may nest one level deep in Phase 1. Cycles are rejected.
        monitor_count: { type: integer, minimum: 0, readOnly: true }
        status:
          allOf:
            - $ref: "#/components/schemas/MonitorStatus"
          readOnly: true
          description: Worst status among the group's monitors — down beats pending beats up.
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    GroupWrite:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 2000 }
        parent_group_id: { type: [string, "null"], format: uuid }

    Tag:
      type: object
      required: [id, name]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string, minLength: 1, maxLength: 100 }
        slug:
          type: string
          readOnly: true
          description: URL-safe form derived from the name, unique across the instance.
        color:
          type: string
          pattern: "^#[0-9a-fA-F]{6}$"
          default: "#6b7280"
          description: Hex colour used in the UI and on status pages.
        description: { type: [string, "null"], maxLength: 2000 }
        monitor_count: { type: integer, minimum: 0, readOnly: true }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    TagWrite:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        description: { type: [string, "null"], maxLength: 2000 }

    # ------------------------------------------------------------------------
    # Notification channels
    # ------------------------------------------------------------------------

    NotificationChannelType:
      type: string
      description: |
        Native channels, plus `apprise` as a meta-provider covering roughly ninety more.
        Clients must tolerate values added in later releases.
      enum:
        - email
        - webhook
        - slack
        - discord
        - telegram
        - matrix
        - gotify
        - ntfy
        - msteams
        - pagerduty
        - opsgenie
        - twilio
        - apprise

    EventType:
      type: string
      description: Every state change the system emits, to webhooks and to notification channels alike.
      enum:
        - monitor.up
        - monitor.down
        - monitor.pending
        - monitor.created
        - monitor.updated
        - monitor.deleted
        - monitor.paused
        - monitor.resumed
        - monitor.certificate_expiring
        - monitor.domain_expiring
        - incident.opened
        - incident.updated
        - incident.resolved
        - maintenance.started
        - maintenance.ended
        - report.generated

    NotificationChannelCommon:
      type: object
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string, minLength: 1, maxLength: 200 }
        enabled: { type: boolean, default: true }
        is_default:
          type: boolean
          default: false
          description: Default channels are attached to newly created monitors automatically.
        events:
          type: array
          description: Events this channel receives. Empty means all monitor state changes.
          items: { $ref: "#/components/schemas/EventType" }
        last_used_at: { type: [string, "null"], format: date-time, readOnly: true }
        last_error:
          type: [string, "null"]
          readOnly: true
          description: The most recent delivery failure, so a silently broken channel is visible.
        monitor_count: { type: integer, minimum: 0, readOnly: true }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    NotificationChannel:
      description: An alert destination. `type` selects the shape of `config`.
      oneOf:
        - $ref: "#/components/schemas/EmailChannel"
        - $ref: "#/components/schemas/WebhookChannel"
        - $ref: "#/components/schemas/SlackChannel"
        - $ref: "#/components/schemas/DiscordChannel"
        - $ref: "#/components/schemas/TelegramChannel"
        - $ref: "#/components/schemas/MatrixChannel"
        - $ref: "#/components/schemas/GotifyChannel"
        - $ref: "#/components/schemas/NtfyChannel"
        - $ref: "#/components/schemas/MsTeamsChannel"
        - $ref: "#/components/schemas/PagerDutyChannel"
        - $ref: "#/components/schemas/OpsgenieChannel"
        - $ref: "#/components/schemas/TwilioChannel"
        - $ref: "#/components/schemas/AppriseChannel"
      discriminator:
        propertyName: type
        mapping:
          email: "#/components/schemas/EmailChannel"
          webhook: "#/components/schemas/WebhookChannel"
          slack: "#/components/schemas/SlackChannel"
          discord: "#/components/schemas/DiscordChannel"
          telegram: "#/components/schemas/TelegramChannel"
          matrix: "#/components/schemas/MatrixChannel"
          gotify: "#/components/schemas/GotifyChannel"
          ntfy: "#/components/schemas/NtfyChannel"
          msteams: "#/components/schemas/MsTeamsChannel"
          pagerduty: "#/components/schemas/PagerDutyChannel"
          opsgenie: "#/components/schemas/OpsgenieChannel"
          twilio: "#/components/schemas/TwilioChannel"
          apprise: "#/components/schemas/AppriseChannel"

    NotificationChannelWrite:
      description: Request body for creating a channel. Same shape as `NotificationChannel`.
      $ref: "#/components/schemas/NotificationChannel"

    NotificationChannelUpdate:
      type: object
      description: |
        Partial update. `type` may not change. Redacted secrets may be omitted to leave
        them as they are; supplying a value replaces the stored secret.
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        enabled: { type: boolean }
        is_default: { type: boolean }
        events:
          type: array
          items: { $ref: "#/components/schemas/EventType" }
        config:
          type: object
          description: Partial configuration, validated against the channel's existing type.
          additionalProperties: true

    EmailChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: email }
            config:
              type: object
              required: [to]
              properties:
                to:
                  type: array
                  minItems: 1
                  items: { type: string, format: email }
                cc: { type: array, items: { type: string, format: email } }
                use_instance_smtp:
                  type: boolean
                  default: true
                  description: Use the instance-wide SMTP settings rather than the per-channel ones below.
                smtp_host: { type: [string, "null"] }
                smtp_port: { type: [integer, "null"], minimum: 1, maximum: 65535 }
                smtp_username: { type: [string, "null"] }
                smtp_password: { type: [string, "null"], writeOnly: true }
                smtp_encryption: { type: string, enum: [none, starttls, tls], default: starttls }
                from_address: { type: [string, "null"], format: email }
                from_name: { type: [string, "null"] }

    WebhookChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: webhook }
            config:
              type: object
              description: |
                A generic webhook with full payload templating. Method, headers, and body
                are all user-defined, with variable interpolation — the difference between
                integrating with an arbitrary endpoint and not being able to.
              required: [url]
              properties:
                url: { type: string, format: uri }
                method: { type: string, enum: [POST, PUT, PATCH, GET], default: POST }
                headers:
                  type: object
                  additionalProperties: { type: string }
                  description: Header values support the same interpolation as the body.
                body_template:
                  type: [string, "null"]
                  description: |
                    Payload template. Null sends the default JSON event envelope.
                    Preview the rendered result with `/notification-channels/preview`
                    before saving.
                  examples: ["{\"text\": \"{{monitor.name}} is {{status}}\"}"]
                content_type: { type: string, default: "application/json" }
                verify_tls: { type: boolean, default: true }
                timeout_seconds: { type: integer, minimum: 1, maximum: 60, default: 10 }

    SlackChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: slack }
            config:
              type: object
              required: [webhook_url]
              properties:
                webhook_url: { type: string, format: uri, writeOnly: true }
                channel: { type: [string, "null"], description: "Override the incoming webhook's default channel." }
                username: { type: [string, "null"] }
                icon_emoji: { type: [string, "null"] }
                message_template: { type: [string, "null"] }

    DiscordChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: discord }
            config:
              type: object
              required: [webhook_url]
              properties:
                webhook_url: { type: string, format: uri, writeOnly: true }
                username: { type: [string, "null"] }
                avatar_url: { type: [string, "null"], format: uri }
                message_template: { type: [string, "null"] }

    TelegramChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: telegram }
            config:
              type: object
              required: [bot_token, chat_id]
              properties:
                bot_token: { type: string, writeOnly: true }
                chat_id: { type: string }
                message_thread_id: { type: [string, "null"] }
                parse_mode: { type: string, enum: [none, Markdown, MarkdownV2, HTML], default: none }
                disable_notification: { type: boolean, default: false }
                message_template: { type: [string, "null"] }

    MatrixChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: matrix }
            config:
              type: object
              required: [homeserver_url, room_id, access_token]
              properties:
                homeserver_url: { type: string, format: uri }
                room_id: { type: string }
                access_token: { type: string, writeOnly: true }
                message_template: { type: [string, "null"] }

    GotifyChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: gotify }
            config:
              type: object
              required: [server_url, application_token]
              properties:
                server_url: { type: string, format: uri }
                application_token: { type: string, writeOnly: true }
                priority: { type: integer, minimum: 0, maximum: 10, default: 5 }
                message_template: { type: [string, "null"] }

    NtfyChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: ntfy }
            config:
              type: object
              required: [topic]
              properties:
                server_url: { type: string, format: uri, default: "https://ntfy.sh" }
                topic: { type: string }
                priority: { type: integer, minimum: 1, maximum: 5, default: 3 }
                tags: { type: array, items: { type: string } }
                auth_type: { type: string, enum: [none, basic, token], default: none }
                username: { type: [string, "null"] }
                password: { type: [string, "null"], writeOnly: true }
                token: { type: [string, "null"], writeOnly: true }
                message_template: { type: [string, "null"] }

    MsTeamsChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: msteams }
            config:
              type: object
              required: [webhook_url]
              properties:
                webhook_url: { type: string, format: uri, writeOnly: true }
                message_template: { type: [string, "null"] }

    PagerDutyChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: pagerduty }
            config:
              type: object
              description: |
                Events API v2. A monitor going down triggers an incident keyed by monitor
                ID; its recovery resolves the same incident rather than opening a second one.
              required: [integration_key]
              properties:
                integration_key: { type: string, writeOnly: true }
                severity: { type: string, enum: [critical, error, warning, info], default: error }
                auto_resolve: { type: boolean, default: true }
                region: { type: string, enum: [us, eu], default: us }

    OpsgenieChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: opsgenie }
            config:
              type: object
              required: [api_key]
              properties:
                api_key: { type: string, writeOnly: true }
                region: { type: string, enum: [us, eu], default: us }
                priority: { type: string, enum: [P1, P2, P3, P4, P5], default: P3 }
                auto_close: { type: boolean, default: true }
                responders:
                  type: array
                  items:
                    type: object
                    required: [type, value]
                    properties:
                      type: { type: string, enum: [team, user, escalation, schedule] }
                      value: { type: string }

    TwilioChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: twilio }
            config:
              type: object
              required: [account_sid, auth_token, from_number, to_numbers]
              properties:
                account_sid: { type: string }
                auth_token: { type: string, writeOnly: true }
                from_number: { type: string }
                to_numbers:
                  type: array
                  minItems: 1
                  items: { type: string }
                message_template:
                  type: [string, "null"]
                  description: SMS is length-limited and billed per segment; the rendered message is truncated at 1,600 characters.

    AppriseChannel:
      allOf:
        - $ref: "#/components/schemas/NotificationChannelCommon"
        - type: object
          required: [name, type, config]
          properties:
            type: { type: string, const: apprise }
            config:
              type: object
              description: |
                The meta-provider. One Apprise URL reaches any of roughly ninety services
                Cairn does not implement natively. Requires Apprise to be available to the
                instance; `capabilities` on `/system/info` reports whether it is.
              required: [urls]
              properties:
                urls:
                  type: array
                  minItems: 1
                  writeOnly: true
                  description: Apprise notification URLs. Credentials are embedded in them, so they are treated as secrets.
                  items: { type: string }
                title_template: { type: [string, "null"] }
                body_template: { type: [string, "null"] }

    NotificationTestResult:
      type: object
      required: [delivered]
      properties:
        delivered: { type: boolean }
        status_code:
          type: [integer, "null"]
          description: The provider's HTTP status, where the transport has one.
        error:
          type: [string, "null"]
          description: The provider's error, passed through rather than summarised — the operator needs the real message.
        duration_ms: { type: number }
        rendered_payload:
          type: [string, "null"]
          description: What was actually sent, for channels that render a template.

    TemplateVariable:
      type: object
      required: [name, type, description]
      properties:
        name:
          type: string
          examples: ["monitor.name"]
        type: { type: string, enum: [string, number, boolean, timestamp, array, object] }
        description: { type: string }
        example: { description: A sample value of the documented type. }

    TemplatePreviewRequest:
      type: object
      required: [template]
      properties:
        template:
          type: string
          description: The template body to render.
        headers:
          type: object
          description: Optional header templates to render alongside the body.
          additionalProperties: { type: string }
        event:
          allOf:
            - $ref: "#/components/schemas/EventType"
          description: Which event's sample context to render against. Defaults to `monitor.down`.
        monitor_id:
          type: [string, "null"]
          format: uuid
          description: |
            Render against this monitor's real current data instead of the synthetic
            sample, so the preview shows the user their own values.
        context:
          type: [object, "null"]
          description: Explicit context, overriding the sample. Intended for testing.
          additionalProperties: true

    TemplatePreviewResult:
      type: object
      description: |
        A template that fails to render returns 200 with `ok: false` and the error — a
        broken template is a user mistake to be shown inline, not a server fault.
      required: [ok]
      properties:
        ok: { type: boolean }
        rendered_body: { type: [string, "null"] }
        rendered_headers:
          type: [object, "null"]
          additionalProperties: { type: string }
        error:
          type: [object, "null"]
          required: [message]
          properties:
            message: { type: string }
            line: { type: [integer, "null"] }
            column: { type: [integer, "null"] }
        context_used:
          type: object
          description: The context the template was rendered against, so the user can see what was available.
          additionalProperties: true

    # ------------------------------------------------------------------------
    # Maintenance windows
    # ------------------------------------------------------------------------

    MaintenanceState:
      type: string
      enum: [scheduled, active, ended, cancelled]

    MaintenanceStrategy:
      type: string
      description: |
        How the window recurs. `single` is a one-off; `cron` is the escape hatch for
        schedules the named strategies cannot express.
      enum: [single, recurring_daily, recurring_weekly, recurring_monthly, cron]

    MaintenanceWindow:
      type: object
      required: [id, title, strategy, targets]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        title: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 5000 }
        strategy: { $ref: "#/components/schemas/MaintenanceStrategy" }
        state:
          allOf:
            - $ref: "#/components/schemas/MaintenanceState"
          readOnly: true
        timezone:
          type: string
          description: IANA timezone the schedule is evaluated in. Recurrence follows local time across DST.
          examples: ["Australia/Sydney"]
        starts_at:
          type: string
          format: date-time
          description: First occurrence. For recurring strategies, the anchor the recurrence is computed from.
        ends_at:
          type: [string, "null"]
          format: date-time
          description: End of a `single` window. Recurring windows use `duration_minutes` instead.
        duration_minutes:
          type: [integer, "null"]
          minimum: 1
          description: Length of each occurrence, for recurring strategies.
        recurrence:
          type: [object, "null"]
          properties:
            weekdays:
              type: array
              description: 0 is Sunday. Used by `recurring_weekly`.
              items: { type: integer, minimum: 0, maximum: 6 }
            days_of_month:
              type: array
              description: Used by `recurring_monthly`. A day past the end of a short month is skipped, not clamped.
              items: { type: integer, minimum: 1, maximum: 31 }
            cron:
              type: [string, "null"]
              description: Five-field cron expression, used by the `cron` strategy.
            until:
              type: [string, "null"]
              format: date-time
              description: Stop recurring after this instant. Null recurs indefinitely.
        targets:
          type: object
          description: |
            What the window covers. A monitor matched by any of these is under maintenance.
            Targeting by tag is what makes a window survive monitors being added later.
          properties:
            monitor_ids: { type: array, items: { type: string, format: uuid } }
            group_ids: { type: array, items: { type: string, format: uuid } }
            tag_ids: { type: array, items: { type: string, format: uuid } }
        suppress_notifications: { type: boolean, default: true }
        show_on_status_pages:
          type: boolean
          default: true
          description: Display the window as scheduled maintenance on the pages listed below.
        status_page_ids:
          type: array
          items: { type: string, format: uuid }
        next_occurrence_at: { type: [string, "null"], format: date-time, readOnly: true }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    MaintenanceWindowWrite:
      description: Request body for creating or replacing a maintenance window.
      $ref: "#/components/schemas/MaintenanceWindow"

    # ------------------------------------------------------------------------
    # Incidents
    # ------------------------------------------------------------------------

    IncidentState:
      type: string
      description: The public lifecycle a status page displays.
      enum: [investigating, identified, monitoring, resolved]

    IncidentImpact:
      type: string
      enum: [none, minor, major, critical]

    Incident:
      type: object
      required: [id, title, state, impact, started_at]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        title: { type: string, minLength: 1, maxLength: 300 }
        state: { $ref: "#/components/schemas/IncidentState" }
        impact: { $ref: "#/components/schemas/IncidentImpact" }
        started_at: { type: string, format: date-time }
        resolved_at: { type: [string, "null"], format: date-time, readOnly: true }
        monitor_ids:
          type: array
          items: { type: string, format: uuid }
        status_page_ids:
          type: array
          description: Pages this incident is published on. Empty keeps it internal.
          items: { type: string, format: uuid }
        auto_opened:
          type: boolean
          readOnly: true
          default: false
          description: True when opened automatically from a failing check. Phase 3 onward.
        acknowledged_at: { type: [string, "null"], format: date-time, readOnly: true }
        acknowledged_by: { type: [string, "null"], format: uuid, readOnly: true }
        assigned_to: { type: [string, "null"], format: uuid }
        updates:
          type: array
          readOnly: true
          description: The timeline, oldest first.
          items: { $ref: "#/components/schemas/IncidentTimelineEntry" }
        metrics:
          type: [object, "null"]
          readOnly: true
          description: |
            Response-time metrics derived from the timeline. Populated as the relevant
            timestamps exist; MTTA requires acknowledgement, which arrives in Phase 3.
          properties:
            time_to_detect_seconds: { type: [integer, "null"] }
            time_to_acknowledge_seconds: { type: [integer, "null"] }
            time_to_resolve_seconds: { type: [integer, "null"] }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    IncidentWrite:
      type: object
      required: [title, impact]
      properties:
        title: { type: string, minLength: 1, maxLength: 300 }
        state:
          allOf:
            - $ref: "#/components/schemas/IncidentState"
          default: investigating
        impact: { $ref: "#/components/schemas/IncidentImpact" }
        started_at:
          type: string
          format: date-time
          description: Defaults to now. Set it in the past when recording an incident after the fact.
        monitor_ids: { type: array, items: { type: string, format: uuid } }
        status_page_ids: { type: array, items: { type: string, format: uuid } }
        body:
          type: [string, "null"]
          description: Opening timeline entry. Optional, but an incident with no first update says nothing.
        notify_subscribers: { type: boolean, default: true }

    IncidentUpdateRequest:
      type: object
      description: Metadata-only update. State changes go through the timeline.
      properties:
        title: { type: string, minLength: 1, maxLength: 300 }
        impact: { $ref: "#/components/schemas/IncidentImpact" }
        started_at: { type: string, format: date-time }
        monitor_ids: { type: array, items: { type: string, format: uuid } }
        status_page_ids: { type: array, items: { type: string, format: uuid } }
        assigned_to: { type: [string, "null"], format: uuid }

    IncidentTimelineEntry:
      type: object
      required: [id, body, created_at]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        state:
          allOf:
            - $ref: "#/components/schemas/IncidentState"
          description: The state this entry moved the incident to, if any.
        body: { type: string, minLength: 1, maxLength: 20000 }
        author_id: { type: [string, "null"], format: uuid, readOnly: true }
        notified_subscribers: { type: boolean, readOnly: true }
        created_at: { type: string, format: date-time, readOnly: true }

    IncidentTimelineEntryWrite:
      type: object
      required: [body]
      properties:
        state: { $ref: "#/components/schemas/IncidentState" }
        body: { type: string, minLength: 1, maxLength: 20000 }
        notify_subscribers: { type: boolean, default: true }

    # ------------------------------------------------------------------------
    # Status pages
    # ------------------------------------------------------------------------

    StatusPage:
      type: object
      required: [id, slug, title]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        slug:
          type: string
          pattern: "^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$"
          description: URL path segment. Unique across the instance.
        title: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 5000 }
        published:
          type: boolean
          default: false
          description: An unpublished page is invisible on the public path, so it can be built before launch.
        custom_domain:
          type: [string, "null"]
          description: |
            Hostname this page is served on. Certificate issuance and DNS are the
            operator's, handled at the reverse proxy — the docs carry Caddy, nginx, and
            Traefik recipes.
        visibility:
          type: string
          enum: [public, password]
          default: public
        password:
          type: [string, "null"]
          writeOnly: true
          description: Required when `visibility` is `password`. Never returned.
        theme: { type: string, enum: [light, dark, auto], default: auto }
        logo_url: { type: [string, "null"], format: uri }
        favicon_url: { type: [string, "null"], format: uri }
        primary_color: { type: [string, "null"], pattern: "^#[0-9a-fA-F]{6}$" }
        footer_text: { type: [string, "null"], maxLength: 2000 }
        custom_css:
          type: [string, "null"]
          maxLength: 50000
          description: Injected into the public page. Sanitised, and never able to introduce script.
        timezone: { type: [string, "null"], description: "Display timezone. Null uses the instance default." }
        show_uptime_percentage: { type: boolean, default: true }
        show_response_time_chart: { type: boolean, default: false }
        uptime_bar_days: { type: integer, minimum: 7, maximum: 365, default: 90 }
        show_powered_by:
          type: boolean
          default: true
          description: Attribution link. Removable — nothing in the open source build is paywalled.
        subscriptions_enabled: { type: boolean, default: false }
        google_analytics_id: { type: [string, "null"] }
        sections:
          type: array
          description: |
            Ordered groupings of monitors as visitors see them. A monitor may appear in
            only one section per page.
          items:
            type: object
            required: [name, monitor_ids]
            properties:
              name: { type: string, minLength: 1, maxLength: 200 }
              description: { type: [string, "null"], maxLength: 2000 }
              monitor_ids:
                type: array
                items: { type: string, format: uuid }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    StatusPageWrite:
      description: Request body for creating or replacing a status page.
      $ref: "#/components/schemas/StatusPage"

    PublicStatusPage:
      type: object
      description: |
        The visitor-facing view. Contains only what a status page displays — no monitor
        configuration, no internal identifiers beyond what rendering needs, and nothing
        about monitors the page does not list.
      required: [slug, title, overall_status, sections, generated_at]
      properties:
        slug: { type: string }
        title: { type: string }
        description: { type: [string, "null"] }
        theme: { type: string, enum: [light, dark, auto] }
        logo_url: { type: [string, "null"], format: uri }
        favicon_url: { type: [string, "null"], format: uri }
        primary_color: { type: [string, "null"] }
        footer_text: { type: [string, "null"] }
        custom_css: { type: [string, "null"] }
        show_powered_by: { type: boolean }
        subscriptions_enabled: { type: boolean }
        overall_status:
          type: string
          enum: [operational, degraded, partial_outage, major_outage, maintenance]
        sections:
          type: array
          items:
            type: object
            required: [name, monitors]
            properties:
              name: { type: string }
              description: { type: [string, "null"] }
              monitors:
                type: array
                items: { $ref: "#/components/schemas/PublicMonitorSummary" }
        active_incidents:
          type: array
          items: { $ref: "#/components/schemas/PublicIncident" }
        recent_incidents:
          type: array
          description: Recently resolved incidents, within the page's display window.
          items: { $ref: "#/components/schemas/PublicIncident" }
        scheduled_maintenance:
          type: array
          items:
            type: object
            required: [title, starts_at]
            properties:
              title: { type: string }
              description: { type: [string, "null"] }
              starts_at: { type: string, format: date-time }
              ends_at: { type: [string, "null"], format: date-time }
              affected_monitor_ids: { type: array, items: { type: string, format: uuid } }
        generated_at: { type: string, format: date-time }

    PublicMonitorSummary:
      type: object
      required: [id, name, status]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: [string, "null"] }
        status: { $ref: "#/components/schemas/MonitorStatus" }
        uptime_percentage: { type: [number, "null"], minimum: 0, maximum: 100 }
        uptime_bar:
          type: array
          description: |
            One entry per day over the page's configured window, oldest first, for the
            stacked-stones uptime bar. Days with no data carry a null ratio rather than
            being reported as downtime.
          items:
            type: object
            required: [date]
            properties:
              date: { type: string, format: date }
              uptime_ratio: { type: [number, "null"], minimum: 0, maximum: 1 }
              status: { type: [string, "null"], enum: [up, down, degraded, maintenance, null] }
        response_time_ms: { type: [number, "null"] }

    PublicIncident:
      type: object
      required: [id, title, state, impact, started_at, updates]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        state: { $ref: "#/components/schemas/IncidentState" }
        impact: { $ref: "#/components/schemas/IncidentImpact" }
        started_at: { type: string, format: date-time }
        resolved_at: { type: [string, "null"], format: date-time }
        affected_monitor_ids: { type: array, items: { type: string, format: uuid } }
        updates:
          type: array
          items:
            type: object
            required: [body, created_at]
            properties:
              state: { $ref: "#/components/schemas/IncidentState" }
              body: { type: string }
              created_at: { type: string, format: date-time }

    Subscriber:
      type: object
      required: [id, channel, target, confirmed, created_at]
      properties:
        id: { type: string, format: uuid }
        channel: { type: string, enum: [email, webhook] }
        target:
          type: string
          description: The email address or webhook URL, partially masked on read.
        confirmed: { type: boolean }
        confirmed_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    SubscribeRequest:
      type: object
      required: [channel, target]
      properties:
        channel: { type: string, enum: [email, webhook] }
        target: { type: string }

    # ------------------------------------------------------------------------
    # Outbound webhooks
    # ------------------------------------------------------------------------

    Webhook:
      type: object
      required: [id, url, events]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string, maxLength: 200 }
        url: { type: string, format: uri }
        events:
          type: array
          minItems: 1
          description: Events to deliver. Clients must ignore event types they do not recognise.
          items: { $ref: "#/components/schemas/EventType" }
        enabled: { type: boolean, default: true }
        headers:
          type: object
          description: Additional request headers. Values are stored encrypted and redacted on read.
          additionalProperties: { type: string }
        verify_tls: { type: boolean, default: true }
        secret_prefix:
          type: string
          readOnly: true
          description: Leading characters of the signing secret, so it can be identified without being exposed.
        last_delivery_at: { type: [string, "null"], format: date-time, readOnly: true }
        last_delivery_outcome:
          type: [string, "null"]
          enum: [succeeded, failed, null]
          readOnly: true
        consecutive_failures:
          type: integer
          readOnly: true
          description: |
            A subscription is disabled automatically after a sustained run of failures,
            and the operator is notified — a dead endpoint should not be retried forever.
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    WebhookWrite:
      type: object
      required: [url, events]
      properties:
        name: { type: string, maxLength: 200 }
        url: { type: string, format: uri }
        events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/EventType" }
        enabled: { type: boolean, default: true }
        headers:
          type: object
          additionalProperties: { type: string }
        verify_tls: { type: boolean, default: true }

    WebhookCreated:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: |
                HMAC-SHA256 signing secret. Returned once, on creation. Verify each
                delivery by recomputing the signature over the raw request body.

    WebhookDelivery:
      type: object
      required: [id, event, outcome, attempt, created_at]
      properties:
        id: { type: string, format: uuid }
        event: { $ref: "#/components/schemas/EventType" }
        outcome: { type: string, enum: [succeeded, failed, pending] }
        attempt:
          type: integer
          minimum: 1
          description: Retries use exponential backoff; each attempt is logged separately.
        request_body: { type: [string, "null"] }
        response_status: { type: [integer, "null"] }
        response_body:
          type: [string, "null"]
          description: Truncated to a bounded length so a chatty endpoint cannot fill the log.
        error: { type: [string, "null"] }
        duration_ms: { type: [number, "null"] }
        created_at: { type: string, format: date-time }
        next_retry_at: { type: [string, "null"], format: date-time }

    EventEnvelope:
      type: object
      description: |
        The default payload delivered to outbound webhooks and rendered into notification
        templates. Not returned by any endpoint — documented here because it is part of
        the contract, and a receiver has to be able to write against it.
      required: [id, type, occurred_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: Unique per event. Stable across retries, so receivers can deduplicate.
        type: { $ref: "#/components/schemas/EventType" }
        occurred_at: { type: string, format: date-time }
        instance:
          type: object
          properties:
            name: { type: string }
            base_url: { type: string, format: uri }
            version: { type: string }
        data:
          type: object
          description: |
            The event subject. Shape follows the event type — a `monitor.*` event carries
            `monitor` and `heartbeat`; an `incident.*` event carries `incident`.
          properties:
            monitor: { $ref: "#/components/schemas/Monitor" }
            heartbeat: { $ref: "#/components/schemas/Heartbeat" }
            previous_status: { $ref: "#/components/schemas/MonitorStatus" }
            incident: { $ref: "#/components/schemas/Incident" }
            maintenance_window: { $ref: "#/components/schemas/MaintenanceWindow" }
            report_run: { $ref: "#/components/schemas/ReportRun" }

    # ------------------------------------------------------------------------
    # Import
    # ------------------------------------------------------------------------

    KumaImportRequest:
      type: object
      required: [files]
      properties:
        files:
          type: array
          minItems: 1
          description: |
            One or more Uptime Kuma SQLite databases. Several files performs the
            multi-instance merge.
          items: { type: string, format: binary }
        options:
          type: object
          properties:
            dry_run:
              type: boolean
              default: false
              description: Produce the full report without writing anything.
            conflict_strategy:
              type: string
              enum: [skip, rename, replace]
              default: rename
              description: |
                What to do when an imported entity collides with an existing one by name.
                `rename` suffixes the incoming entity, which is the only option that
                cannot lose data.
            name_prefix:
              type: [string, "null"]
              description: |
                Prefix applied to imported monitor names. The practical way to keep several
                merged Kuma instances distinguishable afterwards.
            import_monitors: { type: boolean, default: true }
            import_tags: { type: boolean, default: true }
            import_notifications: { type: boolean, default: true }
            import_status_pages: { type: boolean, default: true }
            import_history:
              type: boolean
              default: false
              description: Import historical heartbeats as well as configuration. Slower, and larger.
            enable_after_import:
              type: boolean
              default: false
              description: |
                Leave imported monitors paused by default, so a migrating user reviews
                them before five thousand checks start firing at once.

    ImportJob:
      type: object
      required: [id, state, created_at]
      properties:
        id: { type: string, format: uuid }
        state: { type: string, enum: [queued, running, succeeded, partial, failed] }
        dry_run: { type: boolean }
        sources:
          type: array
          description: One entry per uploaded database.
          items:
            type: object
            properties:
              filename: { type: string }
              kuma_version: { type: [string, "null"] }
              detected_entities:
                type: object
                additionalProperties: { type: integer }
        summary:
          type: object
          description: Counts by entity type and outcome.
          additionalProperties:
            type: object
            properties:
              imported: { type: integer }
              renamed: { type: integer }
              skipped: { type: integer }
              failed: { type: integer }
        entries:
          type: array
          description: |
            Per-entity outcomes. Every source entity appears exactly once — the guarantee
            that nothing was silently dropped.
          items:
            type: object
            required: [source_file, entity_type, source_name, result]
            properties:
              source_file: { type: string }
              entity_type: { type: string, enum: [monitor, tag, notification, status_page, heartbeat_range] }
              source_id: { type: [string, "null"] }
              source_name: { type: string }
              result: { type: string, enum: [imported, renamed, skipped, failed, unsupported] }
              target_id: { type: [string, "null"], format: uuid }
              detail:
                type: [string, "null"]
                description: |
                  Why, in plain language — an unsupported monitor type, a notification
                  provider with no Cairn equivalent, a name collision and what was done about it.
        error: { type: [string, "null"] }
        started_at: { type: [string, "null"], format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    # ------------------------------------------------------------------------
    # Settings and users
    # ------------------------------------------------------------------------

    Settings:
      type: object
      description: Instance-wide configuration. Every section is optional on update.
      properties:
        general:
          type: object
          properties:
            instance_name: { type: string, maxLength: 200 }
            base_url:
              type: string
              format: uri
              description: Public URL of this install. Used to build push URLs, status page links, and report links.
            timezone: { type: string }
            locale: { type: string, examples: ["en"] }
        appearance:
          type: object
          properties:
            theme: { type: string, enum: [light, dark, auto] }
            primary_color: { type: [string, "null"], pattern: "^#[0-9a-fA-F]{6}$" }
        retention:
          type: object
          description: |
            Retention per rollup tier, in days, matching the pipeline in ADR-002. Coarser
            tiers must be retained at least as long as finer ones; zero means keep indefinitely.
          properties:
            raw_days: { type: integer, minimum: 1, default: 7 }
            rollup_1m_days: { type: integer, minimum: 0, default: 30 }
            rollup_5m_days: { type: integer, minimum: 0, default: 90 }
            rollup_1h_days: { type: integer, minimum: 0, default: 365 }
            rollup_1d_days: { type: integer, minimum: 0, default: 0 }
            webhook_delivery_days: { type: integer, minimum: 1, default: 30 }
        smtp:
          type: object
          description: Instance-wide SMTP, used by email channels that do not carry their own.
          properties:
            host: { type: [string, "null"] }
            port: { type: [integer, "null"], minimum: 1, maximum: 65535 }
            username: { type: [string, "null"] }
            password: { type: [string, "null"], writeOnly: true }
            encryption: { type: string, enum: [none, starttls, tls] }
            from_address: { type: [string, "null"], format: email }
            from_name: { type: [string, "null"] }
        monitoring:
          type: object
          properties:
            default_interval_seconds: { type: integer, minimum: 20, default: 60 }
            default_timeout_seconds: { type: integer, minimum: 1, default: 30 }
            default_retries: { type: integer, minimum: 0, default: 0 }
            default_notification_channel_ids:
              type: array
              items: { type: string, format: uuid }
            max_concurrent_checks:
              type: [integer, "null"]
              minimum: 1
              description: Scheduler concurrency ceiling. Null lets the scheduler size itself.
        security:
          type: object
          properties:
            session_timeout_minutes: { type: integer, minimum: 5, default: 43200 }
            login_rate_limit_per_minute: { type: integer, minimum: 1, default: 10 }
            api_rate_limit_per_minute: { type: [integer, "null"], minimum: 1 }
            require_totp:
              type: boolean
              default: false
              description: Require TOTP for every account that can sign in.
            trusted_proxies:
              type: array
              description: CIDR ranges whose forwarded-for headers are honoured. Empty trusts none.
              items: { type: string }
        telemetry:
          type: object
          description: |
            Off by default and never enabled without an explicit action. Your data is yours.
          properties:
            enabled: { type: boolean, default: false }
            last_sent_at: { type: [string, "null"], format: date-time, readOnly: true }

    UserRole:
      type: string
      description: |
        Phase 1 installs have a single `owner`. The remaining roles are enforced from
        Phase 3; they are named here so the enum does not have to change later.
      enum: [owner, admin, editor, responder, viewer, billing]

    User:
      type: object
      required: [id, email, role]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        email: { type: string, format: email }
        name: { type: [string, "null"], maxLength: 200 }
        role: { $ref: "#/components/schemas/UserRole" }
        active: { type: boolean, default: true }
        totp_enabled: { type: boolean, readOnly: true }
        timezone: { type: [string, "null"] }
        locale: { type: [string, "null"] }
        last_login_at: { type: [string, "null"], format: date-time, readOnly: true }
        team_ids:
          type: array
          readOnly: true
          description: Populated from Phase 3.
          items: { type: string, format: uuid }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    UserWrite:
      type: object
      required: [email, role]
      properties:
        email: { type: string, format: email }
        name: { type: [string, "null"], maxLength: 200 }
        role: { $ref: "#/components/schemas/UserRole" }
        active: { type: boolean }
        password:
          type: [string, "null"]
          minLength: 12
          writeOnly: true
          description: Omit to send an invitation instead of setting a password directly.
        timezone: { type: [string, "null"] }
        locale: { type: [string, "null"] }

    CurrentUserUpdate:
      type: object
      properties:
        email: { type: string, format: email }
        name: { type: [string, "null"], maxLength: 200 }
        timezone: { type: [string, "null"] }
        locale: { type: [string, "null"] }
        current_password:
          type: string
          writeOnly: true
          description: Required when changing email or password.
        new_password:
          type: string
          minLength: 12
          writeOnly: true

    Team:
      type: object
      required: [id, name]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 2000 }
        members:
          type: array
          items:
            type: object
            required: [user_id, role]
            properties:
              user_id: { type: string, format: uuid }
              role: { $ref: "#/components/schemas/UserRole" }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    TeamWrite:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 2000 }
        members:
          type: array
          items:
            type: object
            required: [user_id, role]
            properties:
              user_id: { type: string, format: uuid }
              role: { $ref: "#/components/schemas/UserRole" }

    # ------------------------------------------------------------------------
    # On-call (Phase 3)
    # ------------------------------------------------------------------------

    OnCallSchedule:
      type: object
      required: [id, name, timezone, rotations]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 2000 }
        timezone: { type: string }
        rotations:
          type: array
          items:
            type: object
            required: [name, participants, length]
            properties:
              name: { type: string }
              participants:
                type: array
                minItems: 1
                description: User identifiers in rotation order.
                items: { type: string, format: uuid }
              length: { type: string, enum: [daily, weekly, biweekly, custom] }
              length_hours: { type: [integer, "null"], minimum: 1, description: "Required when length is custom." }
              handoff_time: { type: string, pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$" }
              starts_at: { type: string, format: date-time }
              restriction:
                type: [object, "null"]
                description: Limit the rotation to given hours and weekdays — business-hours-only routing.
                properties:
                  weekdays: { type: array, items: { type: integer, minimum: 0, maximum: 6 } }
                  start_time: { type: string, pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$" }
                  end_time: { type: string, pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$" }
        overrides:
          type: array
          description: One-off cover, which takes precedence over the computed rotation.
          items:
            type: object
            required: [user_id, starts_at, ends_at]
            properties:
              id: { type: string, format: uuid, readOnly: true }
              user_id: { type: string, format: uuid }
              starts_at: { type: string, format: date-time }
              ends_at: { type: string, format: date-time }
              reason: { type: [string, "null"] }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    OnCallScheduleWrite:
      description: Request body for creating or replacing an on-call schedule.
      $ref: "#/components/schemas/OnCallSchedule"

    OnCallShift:
      type: object
      required: [user_id, starts_at, ends_at]
      properties:
        user_id: { type: string, format: uuid }
        rotation_name: { type: [string, "null"] }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        is_override: { type: boolean, default: false }

    EscalationPolicy:
      type: object
      required: [id, name, steps]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 2000 }
        steps:
          type: array
          minItems: 1
          description: Evaluated in order. A step fires once the previous step's delay elapses unacknowledged.
          items:
            type: object
            required: [delay_minutes, targets]
            properties:
              delay_minutes: { type: integer, minimum: 0 }
              targets:
                type: array
                minItems: 1
                items:
                  type: object
                  required: [type, id]
                  properties:
                    type: { type: string, enum: [user, team, schedule, notification_channel] }
                    id: { type: string, format: uuid }
        repeat_count:
          type: integer
          minimum: 0
          default: 0
          description: How many times to repeat the whole policy while unacknowledged. Zero does not repeat.
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    EscalationPolicyWrite:
      description: Request body for creating or replacing an escalation policy.
      $ref: "#/components/schemas/EscalationPolicy"

    # ------------------------------------------------------------------------
    # Reports (Phase 2)
    # ------------------------------------------------------------------------

    ReportFormat:
      type: string
      enum: [pdf, html, csv, json]

    ReportTemplate:
      type: object
      required: [id, name, type, scope, formats]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 2000 }
        type:
          type: string
          enum: [uptime, sla, post_mortem, comparative, custom]
          description: |
            `sla` reports target versus actual with error budget consumed and remaining —
            what a contract review actually needs. `post_mortem` is drafted from an
            incident timeline.
        scope:
          type: object
          description: What the report covers. Sources combine as a union.
          properties:
            monitor_ids: { type: array, items: { type: string, format: uuid } }
            group_ids: { type: array, items: { type: string, format: uuid } }
            tag_ids: { type: array, items: { type: string, format: uuid } }
            incident_id:
              type: [string, "null"]
              format: uuid
              description: For `post_mortem` reports.
        period:
          type: string
          enum: [day, week, month, quarter, year, custom]
          default: month
        sla_target:
          type: [number, "null"]
          minimum: 0
          maximum: 100
          description: Target uptime percentage for `sla` reports.
        maintenance_handling:
          type: string
          enum: [exclude, count_as_up, count_as_down]
          default: exclude
          description: How maintenance time counts toward the reported figure. Stated in the output.
        branding:
          type: object
          description: |
            White-labelling — the client receives a report that looks like their agency
            made it. Ships in the AGPL build like everything else.
          properties:
            logo_url: { type: [string, "null"], format: uri }
            primary_color: { type: [string, "null"], pattern: "^#[0-9a-fA-F]{6}$" }
            company_name: { type: [string, "null"] }
            footer_text: { type: [string, "null"] }
            hide_powered_by: { type: boolean, default: false }
        sections:
          type: array
          description: Ordered content blocks. Omitting this uses the defaults for the report type.
          items:
            type: string
            enum: [summary, uptime_table, uptime_chart, response_time_chart, incident_log, sla_breakdown, error_budget, maintenance_log, certificate_expiry, comparison]
        formats:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/ReportFormat" }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    ReportTemplateWrite:
      description: Request body for creating or replacing a report template.
      $ref: "#/components/schemas/ReportTemplate"

    ReportGenerateRequest:
      type: object
      properties:
        period_start:
          type: [string, "null"]
          format: date-time
          description: Defaults to the most recently completed period for the template.
        period_end: { type: [string, "null"], format: date-time }
        formats:
          type: array
          description: Overrides the template's formats for this run.
          items: { $ref: "#/components/schemas/ReportFormat" }
        deliver:
          type: boolean
          default: false
          description: Also deliver through the template's schedules, rather than only generating.

    ReportSchedule:
      type: object
      required: [id, report_template_id, frequency, deliveries]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        report_template_id: { type: string, format: uuid }
        name: { type: string, maxLength: 200 }
        enabled: { type: boolean, default: true }
        frequency: { type: string, enum: [daily, weekly, monthly, quarterly, cron] }
        cron: { type: [string, "null"], description: "Five-field cron expression, when frequency is cron." }
        timezone: { type: string }
        send_at: { type: string, pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$", default: "09:00" }
        deliveries:
          type: array
          minItems: 1
          items:
            type: object
            required: [type]
            properties:
              type: { type: string, enum: [email, slack, webhook, s3] }
              recipients:
                type: array
                description: Email recipients.
                items: { type: string, format: email }
              notification_channel_id:
                type: [string, "null"]
                format: uuid
                description: Deliver through an existing channel rather than restating its configuration.
              url: { type: [string, "null"], format: uri }
              s3:
                type: [object, "null"]
                properties:
                  bucket: { type: string }
                  prefix: { type: [string, "null"] }
                  region: { type: [string, "null"] }
                  endpoint: { type: [string, "null"], format: uri }
                  access_key_id: { type: [string, "null"], writeOnly: true }
                  secret_access_key: { type: [string, "null"], writeOnly: true }
              formats:
                type: array
                items: { $ref: "#/components/schemas/ReportFormat" }
        last_run_at: { type: [string, "null"], format: date-time, readOnly: true }
        next_run_at: { type: [string, "null"], format: date-time, readOnly: true }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    ReportScheduleWrite:
      description: Request body for creating or replacing a report schedule.
      $ref: "#/components/schemas/ReportSchedule"

    ReportRun:
      type: object
      required: [id, report_template_id, state, period_start, period_end, created_at]
      properties:
        id: { type: string, format: uuid }
        report_template_id: { type: string, format: uuid }
        report_schedule_id: { type: [string, "null"], format: uuid }
        state: { type: string, enum: [queued, running, succeeded, failed] }
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        artifacts:
          type: array
          items:
            type: object
            required: [format]
            properties:
              format: { $ref: "#/components/schemas/ReportFormat" }
              size_bytes: { type: [integer, "null"] }
              download_url:
                type: [string, "null"]
                format: uri
                description: Authenticated download path for this artefact.
        share_url:
          type: [string, "null"]
          format: uri
          description: Public shareable link, when the run was generated with sharing enabled.
        share_expires_at: { type: [string, "null"], format: date-time }
        deliveries:
          type: array
          description: Delivery outcomes for this run.
          items:
            type: object
            properties:
              type: { type: string, enum: [email, slack, webhook, s3] }
              outcome: { type: string, enum: [succeeded, failed, skipped] }
              error: { type: [string, "null"] }
              delivered_at: { type: [string, "null"], format: date-time }
        error: { type: [string, "null"] }
        started_at: { type: [string, "null"], format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
