openapi: 3.1.0
info:
  title: ConstellationOS API
  version: "1.0.0"
  description: |
    Contract-true OpenAPI document for the ConstellationOS tenant API.

    This document is hand-maintained against the implementation; the API does
    not serve an OpenAPI document publicly. Tenant identity derives from the
    validated API key, never from the hostname.

    Limits enforced by the API:
    - Request body cap: 1 MB (413 request_too_large). Body methods require a
      Content-Length header (411 length_required).
    - Telemetry batch cap: 1000 records per request (413 batch_too_large).
    - Predictions link_ids cap: 100 (400 too_many_link_ids).
    - Rate limits: per-IP 30 requests/min; per-tenant 60 requests/min with a
      burst of 10/s (429 rate_limited with Retry-After).
    - Auth lockout: 5 authentication failures within 300 s lock the client IP
      for 900 s (429 auth_lockout with Retry-After).
servers:
  - url: https://api.constellation.space
    description: Production
  - url: https://dev.api.constellation.space
    description: Development
  - url: https://sandbox.api.constellation.space
    description: Sandbox
security:
  - ApiKeyAuth: []
tags:
  - name: health
    description: Liveness and subsystem health. No authentication required.
  - name: telemetry
    description: Telemetry ingestion.
  - name: topology
    description: Latest-sample-per-entity projection over tenant telemetry.
  - name: predictions
    description: Model predictions, cached and live.

paths:
  /health:
    get:
      operationId: getHealth
      tags: [health]
      summary: Service liveness
      description: Returns 200 whenever the API is reachable. No authentication required.
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                type: object

  /health/{subsystem}:
    get:
      operationId: getSubsystemHealth
      tags: [health]
      summary: Subsystem health with real downstream pings
      description: >
        Probes the named subsystem against its real downstream dependencies.
        Returns 503 when the subsystem is degraded. No authentication required.
      security: []
      parameters:
        - name: subsystem
          in: path
          required: true
          schema:
            type: string
            enum: [telemetry, topology, predictions]
      responses:
        "200":
          description: Subsystem healthy.
          content:
            application/json:
              schema:
                type: object
        "503":
          description: Subsystem degraded.
          content:
            application/json:
              schema:
                type: object

  /telemetry:
    post:
      operationId: ingestTelemetry
      tags: [telemetry]
      summary: Ingest telemetry records
      description: >
        Accepts a bare JSON array of telemetry records. The tenant key is
        stamped server-side from the API key; records cannot target another
        tenant. There is no measurement allowlist on write. At most 1000
        records per request (413 batch_too_large beyond that) and 1 MB per
        body (413 request_too_large).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: "#/components/schemas/TelemetryRecord"
              maxItems: 1000
            examples:
              linkSample:
                summary: One link sample
                value:
                  - measurement: link
                    time: "2026-07-09T12:00:00Z"
                    tags:
                      entity_id: gs-madrid--sat-041
                    fields:
                      snr_db: 12.4
                      elevation_deg: 34.1
      responses:
        "202":
          description: Write accepted (possibly partially; check rejected_count).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TelemetryWriteAck"
        "400":
          description: Malformed Content-Length header (invalid_content_length).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          $ref: "#/components/responses/AuthFailed"
        "411":
          description: Missing Content-Length header (length_required).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "413":
          description: >
            Body over 1 MB (request_too_large, with max_bytes and
            received_bytes) or more than 1000 records (batch_too_large, with
            max and received).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                batchTooLarge:
                  value:
                    error: batch_too_large
                    max: 1000
                    received: 1500
                requestTooLarge:
                  value:
                    error: request_too_large
                    max_bytes: 1048576
                    received_bytes: 2097152
        "422":
          description: Record validation failed (for example empty measurement or invalid timestamp).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Unavailable"

  /topology:
    get:
      operationId: getTopology
      tags: [topology]
      summary: Current topology snapshot
      description: >
        Latest-sample-per-entity projection over tenant telemetry, optionally
        scoped to one measurement and a freshness window. Reads are capped at
        50000 rows; there is no cursor or offset pagination.
      parameters:
        - name: as_of_utc
          in: query
          required: false
          description: Snapshot instant. Defaults to now.
          schema:
            type: string
            format: date-time
        - name: measurement
          in: query
          required: false
          description: Restrict to one entity type. Unknown values return 400 invalid_input.
          schema:
            type: string
            enum: [link, satellite, ground_station, weather, telemetry]
        - name: freshness_seconds
          in: query
          required: false
          description: Only include entities observed within this window before as_of_utc.
          schema:
            type: number
            minimum: 1
            maximum: 604800
      responses:
        "200":
          description: Topology snapshot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Topology"
        "400":
          description: Invalid measurement value.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                invalidInput:
                  value:
                    error: invalid_input
                    field: entity_type
        "401":
          $ref: "#/components/responses/AuthFailed"
        "422":
          description: Query parameter validation failed (for example freshness_seconds out of range).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Unavailable"

  /predictions:
    get:
      operationId: getPredictions
      tags: [predictions]
      summary: Model predictions, cached or live
      description: >
        Cached reads return a PredictionSet. With live=true the request runs
        inference and returns an InferencePayload with full provenance. The
        snr family is the only model family shipped; its value fields are
        p50, p10, and p90. There is no POST /predictions.
      parameters:
        - name: model_family
          in: query
          required: false
          schema:
            type: string
            default: snr
        - name: link_ids
          in: query
          required: false
          description: Link ids to predict for. At most 100 (400 too_many_link_ids beyond that).
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
        - name: horizon_minutes
          in: query
          required: false
          schema:
            type: integer
        - name: as_of_utc
          in: query
          required: false
          description: Defaults to now.
          schema:
            type: string
            format: date-time
        - name: live
          in: query
          required: false
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: PredictionSet for cached reads; InferencePayload when live=true.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PredictionSet"
                  - $ref: "#/components/schemas/InferencePayload"
        "400":
          description: Too many link ids.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              examples:
                tooManyLinkIds:
                  value:
                    error: too_many_link_ids
                    max: 100
                    received: 250
        "401":
          $ref: "#/components/responses/AuthFailed"
        "422":
          description: Query parameter validation failed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Unavailable"

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: Tenant API key. Tenant identity derives from this key, never from the hostname.

  headers:
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer

  responses:
    AuthFailed:
      description: Missing or invalid API key (auth_failed), disabled tenant (tenant_disabled returns 403).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            authFailed:
              value:
                error: auth_failed
    RateLimited:
      description: >
        Rate limit exceeded (rate_limited) or authentication lockout
        (auth_lockout, after 5 auth failures within 300 s; lasts 900 s).
        Both carry retry_after_seconds and a Retry-After header.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            rateLimited:
              value:
                error: rate_limited
                retry_after_seconds: 12
            authLockout:
              value:
                error: auth_lockout
                retry_after_seconds: 900
    Unavailable:
      description: >
        Telemetry backend unavailable (telemetry_unavailable, with upstream
        and retry_after_seconds) or tenant registered but its data plane is
        unavailable (tenant_unavailable).
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            telemetryUnavailable:
              value:
                error: telemetry_unavailable
                upstream: influx
                retry_after_seconds: 5

  schemas:
    TelemetryRecord:
      type: object
      required: [measurement, time]
      properties:
        measurement:
          type: string
          minLength: 1
          description: Measurement name, e.g. link, satellite, ground_station, weather.
        time:
          type: string
          format: date-time
          description: Sample timestamp (ISO 8601).
        tags:
          type: object
          additionalProperties:
            type: string
          default: {}
          description: String dimensions identifying the entity.
        fields:
          type: object
          additionalProperties:
            oneOf:
              - type: number
              - type: string
          default: {}
          description: Sample values (numbers or strings).

    TelemetryWriteAck:
      type: object
      required: [write_id, accepted_count, rejected_count]
      properties:
        write_id:
          type: string
        accepted_count:
          type: integer
        rejected_count:
          type: integer
        rejected_indices:
          type: ["array", "null"]
          items:
            type: integer
          description: Zero-based indices of rejected records within this request body, or null.

    TopologyEntity:
      type: object
      required: [measurement, entity_id, observed_at, tags, fields]
      properties:
        measurement:
          type: string
        entity_id:
          type: string
        observed_at:
          type: string
          format: date-time
        tags:
          type: object
          additionalProperties:
            type: string
        fields:
          type: object
          additionalProperties:
            oneOf:
              - type: number
              - type: string

    Topology:
      type: object
      required: [as_of_utc, tenant_key, entities]
      properties:
        as_of_utc:
          type: string
          format: date-time
        tenant_key:
          type: string
        entities:
          type: array
          items:
            $ref: "#/components/schemas/TopologyEntity"

    Prediction:
      type: object
      required: [link_id, horizon_minutes, predicted_at, value]
      properties:
        link_id:
          type: string
        horizon_minutes:
          type: integer
        predicted_at:
          type: string
          format: date-time
        assignment_id:
          type: string
        model_version:
          type: string
        lease_id:
          type: string
        value:
          type: object
          additionalProperties:
            type: number
          description: Contract-driven value fields. SNR family returns p50, p10, p90.
        feature_view_id:
          type: string

    PredictionSet:
      type: object
      required: [model_family, captured_at, items]
      properties:
        model_family:
          type: string
        captured_at:
          type: string
          format: date-time
        items:
          type: array
          items:
            $ref: "#/components/schemas/Prediction"

    LivePrediction:
      type: object
      required: [schema_version, link_id, horizon_minutes, value]
      properties:
        schema_version:
          type: string
        link_id:
          type: string
        horizon_minutes:
          type: integer
        value:
          type: object
          additionalProperties:
            type: number
          description: Contract-driven value fields. SNR family returns p50, p10, p90.

    InferenceProvenance:
      type: object
      required: [served_from, model_version, feature_source]
      properties:
        served_from:
          type: string
          enum: [sagemaker, local, registry]
        model_version:
          type: string
        feature_source:
          type: string
          enum: [npz, influx]
        entity_id:
          type: string
        as_of_utc:
          type: string
          format: date-time
        feature_schema_version:
          type: string
        input_window_start:
          type: string
          format: date-time
        input_window_end:
          type: string
          format: date-time
        telemetry_points_used:
          type: integer
        fill_policy:
          type: string
        horizons_minutes:
          type: array
          items:
            type: integer

    InferencePayload:
      type: object
      required: [schema_version, tenant_key, served_at, predictions, provenance]
      properties:
        schema_version:
          type: string
          const: v1
        tenant_key:
          type: string
        served_at:
          type: string
          format: date-time
        predictions:
          type: array
          items:
            $ref: "#/components/schemas/LivePrediction"
        provenance:
          $ref: "#/components/schemas/InferenceProvenance"

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: >
            Machine-readable error code. Documented codes: auth_failed,
            auth_lockout, rate_limited, batch_too_large, request_too_large,
            length_required, invalid_content_length, invalid_input,
            too_many_link_ids, telemetry_unavailable, tenant_required,
            unknown_tenant, tenant_disabled, tenant_unavailable,
            operator_auth_unavailable.
        retry_after_seconds:
          type: number
          description: Present on 429 and some 503 responses; mirrors the Retry-After header.
        reason:
          type: string
          description: Optional human-readable detail on auth failures.
        field:
          type: string
          description: Offending field on invalid_input errors.
        max:
          type: integer
          description: Limit that was exceeded (batch_too_large, too_many_link_ids).
        received:
          type: integer
          description: Count actually received (batch_too_large, too_many_link_ids).
        max_bytes:
          type: integer
          description: Body size limit in bytes (request_too_large).
        received_bytes:
          type: integer
          description: Body size received in bytes (request_too_large).
        upstream:
          type: string
          description: Degraded upstream system (telemetry_unavailable).

    ValidationError:
      type: object
      properties:
        detail:
          type: array
          items:
            type: object
            properties:
              loc:
                type: array
                items:
                  oneOf:
                    - type: string
                    - type: integer
              msg:
                type: string
              type:
                type: string
