# Engineering Runtime Control Plane API — published contract.
#
# This file is the contract consumers integrate against. It is hand-maintained
# beside the handlers it describes (internal/api/), and
# scripts/check-route-inventory.sh fails when a registered /api route is missing
# from it — so "hand-maintained" cannot quietly become "out of date".
#
# Deliberately plain YAML: no anchors, no merge keys, no vendor extensions that
# a generator has to understand. Every OpenAPI 3.1 toolchain should read this
# without special handling. x-er-surface / x-er-auth are informational only and
# safe to ignore.
#
# Shared routes appear twice, once per prefix, because they really are two
# routes with two auth policies in front of one handler. Collapsing them would
# hide the only difference that matters.
#
# Served at /openapi.yaml (verbatim) and /openapi.json (parsed, with the
# requesting host prepended to `servers`).

openapi: 3.1.0

info:
  title: Engineering Runtime Control Plane API
  version: v1
  summary: Policies, audit, fleet, and catalog for Engineering Runtime.
  description: |
    One API serves three consumers: the `runtime` CLI (portal client), the demo
    Control Plane UI, and the enterprise app UI.

    Two prefixes expose the same handlers to different audiences.
    `/api/public` is always open and backs Community runtimes and the demo.
    `/api/v1` additionally carries login and organisation management, and
    enforces JWT / API-key auth when the deployment sets `REQUIRE_AUTH=true`.

    The runtime uses exactly three of these routes — `GET {prefix}/policies?active=true`,
    `POST {prefix}/audit`, `POST {prefix}/fleet/heartbeat`. Policy is evaluated
    locally against a cached file, so the runtime keeps working when this API is
    unreachable.
  contact:
    name: Engineering Runtime
    url: https://api.engineeringruntime.com/

servers:
  - url: https://api.engineeringruntime.com
    description: Production
  - url: http://localhost:8081
    description: Local development

tags:
  - name: Policy
    description: Versioned policy documents. Runtimes fetch the active one and evaluate locally.
  - name: Audit
    description: Command decisions reported by runtimes. The runtime's local record is the system of record.
  - name: Catalog
    description: |
      Providers and capabilities, generated from the runtime repository at
      deploy time. Two views: the **contract** view on /metadata/* (no auth, no
      tenant, carries runtime provenance) and the **tenant** view on
      {prefix}/providers and {prefix}/capabilities (org-scoped, adds live status
      from the database).
  - name: Fleet
    description: Runtime presence. Status is derived from last contact, never scheduled.
  - name: Dashboard
    description: Aggregates for the UIs.
  - name: Authentication
    description: Login and identity. `/api/v1` only.
  - name: Management
    description: Organisations, users, API keys, settings. `/api/v1` only.
  - name: Service
    description: Health, version, and machine-readable metadata about this API.

paths:
  # ------------------------------------------------------------------------
  # Service — no prefix, no credentials, safe to poll.
  # ------------------------------------------------------------------------

  /:
    x-er-surface: portal
    get:
      tags: [Service]
      summary: API home — website for browsers, metadata for tools
      description: |
        Content-negotiated. `Accept: text/html` returns the API portal;
        anything else (including `*/*`) returns the same document as
        `GET /metadata`. HTML is served only when the caller asks for it more
        specifically than for JSON.
      operationId: getRoot
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Metadata document, or the portal home page for browsers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Metadata" }
            text/html:
              schema: { type: string }

  /health:
    x-er-surface: portal
    get:
      tags: [Service]
      summary: Liveness check
      description: Unprefixed health endpoint — what uptime monitors should poll.
      operationId: getHealth
      x-er-auth: none
      security: []
      responses:
        "200":
          description: The service is up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }

  /version:
    x-er-surface: portal
    get:
      tags: [Service]
      summary: Build and uptime of the running instance
      operationId: getVersion
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Build metadata.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VersionInfo" }

  /openapi.yaml:
    x-er-surface: portal
    get:
      tags: [Service]
      summary: This contract, canonical YAML
      operationId: getOpenAPIYAML
      x-er-auth: none
      security: []
      responses:
        "200":
          description: The OpenAPI document, byte for byte as this repository holds it.
          content:
            application/yaml:
              schema: { type: string }

  /openapi.json:
    x-er-surface: portal
    get:
      tags: [Service]
      summary: This contract as JSON, with the requesting host in `servers`
      operationId: getOpenAPIJSON
      x-er-auth: none
      security: []
      responses:
        "200":
          description: The same document, parsed to JSON. Drives the explorer at /swagger.
          content:
            application/json:
              schema: { type: object }

  /metadata:
    x-er-surface: portal
    get:
      tags: [Service]
      summary: Service metadata index
      description: Surfaces, contract links, catalog state, and version. Browsers are shown the reference page instead.
      operationId: getMetadata
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Metadata index.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Metadata" }

  /metadata/apis:
    x-er-surface: portal
    get:
      tags: [Service]
      summary: Every documented operation, flattened
      description: Derived from this contract, so it cannot describe a route the contract omits.
      operationId: getMetadataAPIs
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Operation index.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MetadataAPIs" }

  /metadata/versions:
    x-er-surface: portal
    get:
      tags: [Service]
      summary: Build, contract, and surface versions
      operationId: getMetadataVersions
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Version metadata.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MetadataVersions" }

  /metadata/catalog:
    x-er-surface: portal
    get:
      tags: [Catalog]
      summary: Provider/capability contract envelope
      description: |
        Contract version, the runtime commit the catalog was built from,
        generation time and counts — without the payload. The cheapest way to
        check whether a cached contract is current.

        `runtime.commit` is what makes this auditable: `generated_at` says when
        the catalog was built, not what from. When a catalog is generated
        without a runtime checkout, `runtime.known` is false and the commit is
        absent rather than guessed.
      operationId: getMetadataCatalog
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Envelope plus links to the inventories.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogContract" }

  /metadata/providers:
    x-er-surface: portal
    get:
      tags: [Catalog]
      summary: Provider inventory (contract view — no auth, no tenant)
      description: |
        What the runtime supports, read straight from the embedded catalog.

        Distinct from `{prefix}/providers`, which is the **tenant** view: that
        one merges live status, `last_used` and org scope from the database and
        needs an organisation. This one needs no credentials, touches no
        database, and gives every caller the same answer.
      operationId: getMetadataProviders
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Provider inventory with the contract envelope.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogProviders" }

  /metadata/capabilities:
    x-er-surface: portal
    get:
      tags: [Catalog]
      summary: Capability inventory (contract view — no auth, no tenant)
      description: |
        Capabilities the runtime can execute. `provider` and `q` filter exactly
        as they do on `{prefix}/capabilities` — two endpoints filtering the same
        data differently would be a trap.
      operationId: getMetadataCapabilities
      x-er-auth: none
      security: []
      parameters:
        - $ref: "#/components/parameters/CapabilityProvider"
        - $ref: "#/components/parameters/CapabilityQuery"
      responses:
        "200":
          description: Capability inventory with the contract envelope.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogCapabilities" }

  /metadata/authentication:
    x-er-surface: portal
    get:
      tags: [Service]
      summary: What this deployment enforces right now
      description: |
        Reports live process configuration, not intent. A deployment running
        with `REQUIRE_AUTH=false` says so here rather than advertising
        credentials it does not check.
      operationId: getMetadataAuthentication
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Authentication model as configured.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MetadataAuthentication" }

  # ------------------------------------------------------------------------
  # /api/public — always open. Community runtimes and the demo UI.
  # ------------------------------------------------------------------------

  /api/public/health:
    x-er-surface: public
    get:
      tags: [Service]
      summary: Health (public surface)
      operationId: getPublicHealth
      x-er-auth: none
      security: []
      responses:
        "200":
          description: The service is up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }

  /api/public/policies:
    x-er-surface: public
    get:
      tags: [Policy]
      summary: List policies, or fetch the active one
      description: |
        With `active=true` — or an `X-Runtime-Client` header — returns the single
        active policy document. Otherwise returns every version for the
        organisation plus the active version name.
      operationId: listPublicPolicies
      x-er-auth: none
      security: []
      parameters:
        - $ref: "#/components/parameters/PolicyActive"
        - $ref: "#/components/parameters/RuntimeClient"
      responses:
        "200":
          description: A policy list, or a single active policy.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PolicyList"
                  - $ref: "#/components/schemas/Policy"
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      tags: [Policy]
      summary: Publish a new policy version
      description: |
        Validates the YAML, deactivates the current active version, and stores
        this one as active — in a single transaction. Versions are immutable:
        re-publishing an existing version returns 409 so audit records stay
        resolvable.
      operationId: publishPublicPolicy
      x-er-auth: none
      security: []
      requestBody: { $ref: "#/components/requestBodies/PolicyPublish" }
      responses:
        "201":
          description: The published policy, now active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Policy" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/public/policies/{version}:
    x-er-surface: public
    get:
      tags: [Policy]
      summary: Fetch one policy version
      operationId: getPublicPolicyVersion
      x-er-auth: none
      security: []
      parameters:
        - $ref: "#/components/parameters/PolicyVersion"
      responses:
        "200":
          description: The requested version.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Policy" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/public/audit:
    x-er-surface: public
    get:
      tags: [Audit]
      summary: Query audit events
      operationId: listPublicAudit
      x-er-auth: none
      security: []
      parameters:
        - $ref: "#/components/parameters/AuditQ"
        - $ref: "#/components/parameters/AuditDecision"
        - $ref: "#/components/parameters/AuditProvider"
        - $ref: "#/components/parameters/AuditConsumer"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of events, newest first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditPage" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      tags: [Audit]
      summary: Ingest audit events
      description: |
        Accepts one event object, or `{"events":[…]}` for a batch. camelCase
        aliases from older runtime builds are accepted alongside the canonical
        fields. Answers 202 — the runtime's local record was already
        authoritative.
      operationId: ingestPublicAudit
      x-er-auth: none
      security: []
      requestBody: { $ref: "#/components/requestBodies/AuditIngest" }
      responses:
        "202":
          description: Stored. A single event echoes the record; a batch echoes `accepted` and the records.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/AuditEvent"
                  - $ref: "#/components/schemas/AuditBatchResult"
        "400": { $ref: "#/components/responses/BadRequest" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/public/providers:
    x-er-surface: public
    get:
      tags: [Catalog]
      summary: List providers with live status
      description: Catalog rows merged with status derived from recent audit activity.
      operationId: listPublicProviders
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Providers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProviderList" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/public/provider-names:
    x-er-surface: public
    get:
      tags: [Catalog]
      summary: Provider names for filter dropdowns
      description: Catalog provider keys unioned with providers observed in audit.
      operationId: listPublicProviderNames
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Sorted provider names.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProviderNames" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/public/capabilities:
    x-er-surface: public
    get:
      tags: [Catalog]
      summary: List capabilities
      operationId: listPublicCapabilities
      x-er-auth: none
      security: []
      parameters:
        - $ref: "#/components/parameters/CapabilityProvider"
        - $ref: "#/components/parameters/CapabilityQuery"
      responses:
        "200":
          description: Capabilities.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityList" }

  /api/public/dashboard:
    x-er-surface: public
    get:
      tags: [Dashboard]
      summary: Summary cards
      operationId: getPublicDashboard
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Aggregates over the last 24 hours plus catalog and fleet counts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dashboard" }

  /api/public/fleet:
    x-er-surface: public
    get:
      tags: [Fleet]
      summary: List known runtimes
      description: "Status is derived from `last_seen`: online under 15 minutes, idle under 24 hours, offline beyond."
      operationId: listPublicFleet
      x-er-auth: none
      security: []
      responses:
        "200":
          description: Runtimes, most recently seen first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FleetList" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/public/fleet/heartbeat:
    x-er-surface: public
    post:
      tags: [Fleet]
      summary: Report runtime presence
      description: Upserts on (organisation, name), so a runtime reporting often keeps one row.
      operationId: publicFleetHeartbeat
      x-er-auth: none
      security: []
      requestBody: { $ref: "#/components/requestBodies/Heartbeat" }
      responses:
        "200":
          description: Recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusOK" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/public/runtime/register:
    x-er-surface: public
    post:
      tags: [Fleet]
      summary: Register a runtime
      description: Optional first-contact call. Heartbeats alone are enough to appear in the fleet.
      operationId: publicRuntimeRegister
      x-er-auth: none
      security: []
      requestBody: { $ref: "#/components/requestBodies/RuntimeRegister" }
      responses:
        "200":
          description: An existing runtime was updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RegisterResult" }
        "201":
          description: A new runtime was registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RegisterResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/public/runtime/heartbeat:
    x-er-surface: public
    post:
      tags: [Fleet]
      summary: Report runtime presence (alias)
      description: Identical to `/api/public/fleet/heartbeat`. Kept for older runtime builds.
      operationId: publicRuntimeHeartbeat
      x-er-auth: none
      security: []
      requestBody: { $ref: "#/components/requestBodies/Heartbeat" }
      responses:
        "200":
          description: Recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusOK" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "500": { $ref: "#/components/responses/ServerError" }

  # ------------------------------------------------------------------------
  # /api/v1 — enterprise surface. Same handlers, credentials when enforced.
  # ------------------------------------------------------------------------

  /api/v1/health:
    x-er-surface: v1
    get:
      tags: [Service]
      summary: Health (enterprise surface)
      operationId: getV1Health
      x-er-auth: none
      security: []
      responses:
        "200":
          description: The service is up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }

  /api/v1/policies:
    x-er-surface: v1
    get:
      tags: [Policy]
      summary: List policies, or fetch the active one
      operationId: listPolicies
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      parameters:
        - $ref: "#/components/parameters/PolicyActive"
        - $ref: "#/components/parameters/RuntimeClient"
      responses:
        "200":
          description: A policy list, or a single active policy.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PolicyList"
                  - $ref: "#/components/schemas/Policy"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      tags: [Policy]
      summary: Publish a new policy version
      operationId: publishPolicy
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      requestBody: { $ref: "#/components/requestBodies/PolicyPublish" }
      responses:
        "201":
          description: The published policy, now active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Policy" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/policies/{version}:
    x-er-surface: v1
    get:
      tags: [Policy]
      summary: Fetch one policy version
      operationId: getPolicyVersion
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      parameters:
        - $ref: "#/components/parameters/PolicyVersion"
      responses:
        "200":
          description: The requested version.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Policy" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/audit:
    x-er-surface: v1
    get:
      tags: [Audit]
      summary: Query audit events
      operationId: listAudit
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      parameters:
        - $ref: "#/components/parameters/AuditQ"
        - $ref: "#/components/parameters/AuditDecision"
        - $ref: "#/components/parameters/AuditProvider"
        - $ref: "#/components/parameters/AuditConsumer"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of events, newest first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      tags: [Audit]
      summary: Ingest audit events
      operationId: ingestAudit
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      requestBody: { $ref: "#/components/requestBodies/AuditIngest" }
      responses:
        "202":
          description: Stored.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/AuditEvent"
                  - $ref: "#/components/schemas/AuditBatchResult"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/providers:
    x-er-surface: v1
    get:
      tags: [Catalog]
      summary: List providers with live status
      operationId: listProviders
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      responses:
        "200":
          description: Providers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProviderList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/provider-names:
    x-er-surface: v1
    get:
      tags: [Catalog]
      summary: Provider names for filter dropdowns
      operationId: listProviderNames
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      responses:
        "200":
          description: Sorted provider names.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProviderNames" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/capabilities:
    x-er-surface: v1
    get:
      tags: [Catalog]
      summary: List capabilities
      operationId: listCapabilities
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      parameters:
        - $ref: "#/components/parameters/CapabilityProvider"
        - $ref: "#/components/parameters/CapabilityQuery"
      responses:
        "200":
          description: Capabilities.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CapabilityList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/dashboard:
    x-er-surface: v1
    get:
      tags: [Dashboard]
      summary: Summary cards
      operationId: getDashboard
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      responses:
        "200":
          description: Aggregates over the last 24 hours plus catalog and fleet counts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dashboard" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/fleet:
    x-er-surface: v1
    get:
      tags: [Fleet]
      summary: List known runtimes
      operationId: listFleet
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      responses:
        "200":
          description: Runtimes, most recently seen first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FleetList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/fleet/heartbeat:
    x-er-surface: v1
    post:
      tags: [Fleet]
      summary: Report runtime presence
      operationId: fleetHeartbeat
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      requestBody: { $ref: "#/components/requestBodies/Heartbeat" }
      responses:
        "200":
          description: Recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusOK" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/runtime/register:
    x-er-surface: v1
    post:
      tags: [Fleet]
      summary: Register a runtime
      operationId: runtimeRegister
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      requestBody: { $ref: "#/components/requestBodies/RuntimeRegister" }
      responses:
        "200":
          description: An existing runtime was updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RegisterResult" }
        "201":
          description: A new runtime was registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RegisterResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/runtime/heartbeat:
    x-er-surface: v1
    post:
      tags: [Fleet]
      summary: Report runtime presence (alias)
      operationId: runtimeHeartbeat
      x-er-auth: JWT or X-API-Key when REQUIRE_AUTH=true
      requestBody: { $ref: "#/components/requestBodies/Heartbeat" }
      responses:
        "200":
          description: Recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusOK" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/auth/login:
    x-er-surface: v1
    post:
      tags: [Authentication]
      summary: Exchange credentials for a JWT
      operationId: login
      x-er-auth: none
      security: []
      requestBody: { $ref: "#/components/requestBodies/Login" }
      responses:
        "200":
          description: A token and the authenticated user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/auth/me:
    x-er-surface: v1
    get:
      tags: [Authentication]
      summary: The user behind the current token
      description: Accepts an optional bearer token; answers 401 when none is present or it is invalid.
      operationId: getMe
      x-er-auth: Bearer token
      responses:
        "200":
          description: The authenticated user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/organizations:
    x-er-surface: v1
    get:
      tags: [Management]
      summary: List organisations
      operationId: listOrganizations
      x-er-auth: JWT when REQUIRE_AUTH=true
      responses:
        "200":
          description: Organisations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/users:
    x-er-surface: v1
    get:
      tags: [Management]
      summary: List users in the caller's organisation
      operationId: listUsers
      x-er-auth: JWT when REQUIRE_AUTH=true
      responses:
        "200":
          description: Users.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/api-keys:
    x-er-surface: v1
    get:
      tags: [Management]
      summary: List runtime API keys
      description: Key hashes are never returned. `last_used_at` shows which keys are still in use.
      operationId: listAPIKeys
      x-er-auth: JWT when REQUIRE_AUTH=true
      responses:
        "200":
          description: API keys.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIKeyList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      tags: [Management]
      summary: Create a runtime API key
      description: The plaintext key is returned once and never again — only its hash is stored.
      operationId: createAPIKey
      x-er-auth: JWT when REQUIRE_AUTH=true
      requestBody: { $ref: "#/components/requestBodies/APIKeyCreate" }
      responses:
        "201":
          description: The created key, including its plaintext value.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIKeyCreated" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/api-keys/{id}:
    x-er-surface: v1
    delete:
      tags: [Management]
      summary: Revoke an API key
      description: Takes effect on the next request using that key.
      operationId: revokeAPIKey
      x-er-auth: JWT when REQUIRE_AUTH=true
      parameters:
        - $ref: "#/components/parameters/APIKeyID"
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RevokeResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/v1/settings:
    x-er-surface: v1
    get:
      tags: [Management]
      summary: Organisation settings and edition flags
      operationId: getSettings
      x-er-auth: JWT when REQUIRE_AUTH=true
      responses:
        "200":
          description: Settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }
        "401": { $ref: "#/components/responses/Unauthorized" }

# ==========================================================================

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Issued by `POST /api/v1/auth/login`. Enforced when `REQUIRE_AUTH=true`.
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Runtime key (`erk_…`) created by `POST /api/v1/api-keys`. Accepted on runtime contract routes.

  parameters:
    PolicyActive:
      name: active
      in: query
      required: false
      description: "`true` returns the single active policy instead of a list."
      schema: { type: string, enum: ["true"] }
      example: "true"

    RuntimeClient:
      name: X-Runtime-Client
      in: header
      required: false
      description: Runtime client identity. Present on any value, it has the same effect as `active=true`.
      schema: { type: string }
      example: engineering-runtime

    PolicyVersion:
      name: version
      in: path
      required: true
      description: Policy version name.
      schema: { type: string }
      example: v12

    AuditQ:
      name: q
      in: query
      required: false
      description: Case-insensitive substring across command, executor, resource, provider, and capability.
      schema: { type: string }

    AuditDecision:
      name: decision
      in: query
      required: false
      description: Filter by policy decision.
      schema: { type: string, enum: [ALLOWED, DENIED, allowed, denied] }

    AuditProvider:
      name: provider
      in: query
      required: false
      description: Filter by provider.
      schema: { type: string }
      example: github

    AuditConsumer:
      name: consumer
      in: query
      required: false
      description: Filter by who ran the command.
      schema: { type: string, enum: [human, ai, ci] }

    Limit:
      name: limit
      in: query
      required: false
      description: Page size. Clamped to 1000.
      schema: { type: integer, default: 100, minimum: 1, maximum: 1000 }

    Offset:
      name: offset
      in: query
      required: false
      description: Rows to skip.
      schema: { type: integer, default: 0, minimum: 0 }

    CapabilityProvider:
      name: provider
      in: query
      required: false
      description: Only capabilities belonging to this provider.
      schema: { type: string }
      example: github

    CapabilityQuery:
      name: q
      in: query
      required: false
      description: Substring across capability name, description, id, and provider.
      schema: { type: string }

    APIKeyID:
      name: id
      in: path
      required: true
      description: API key id.
      schema: { type: string }

  requestBodies:
    PolicyPublish:
      required: true
      content:
        application/json:
          schema: { $ref: "#/components/schemas/PolicyPublish" }
          example:
            version: v13
            name: enterprise-policy
            description: Deny production writes outside change windows
            updated_by: kishore
            yaml: "version: v13\nrules:\n  - id: deny-prod-write\n    effect: deny\n"

    AuditIngest:
      required: true
      content:
        application/json:
          schema: { $ref: "#/components/schemas/AuditIngest" }
          example:
            executor: kishore
            consumer: human
            command: github repo list
            provider: github
            policy_decision: ALLOWED
            status: success
            duration_ms: 84

    Heartbeat:
      required: true
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Heartbeat" }
          example:
            name: ci-runner-01
            version: 0.9.3
            policy_version: v12
            hostname: gh-runner-7
            os: linux

    RuntimeRegister:
      required: true
      content:
        application/json:
          schema: { $ref: "#/components/schemas/RuntimeRegister" }
          example:
            name: ci-runner-01
            version: 0.9.3
            hostname: gh-runner-7
            os: linux

    Login:
      required: true
      content:
        application/json:
          schema: { $ref: "#/components/schemas/LoginRequest" }
          example:
            email: YOUR_EMAIL
            password: YOUR_PASSWORD

    APIKeyCreate:
      required: true
      content:
        application/json:
          schema: { $ref: "#/components/schemas/APIKeyCreate" }
          example:
            name: ci-runner
            scopes: runtime

  responses:
    BadRequest:
      description: The request body or parameters were rejected.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Credentials are required, or the ones supplied were rejected.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such record.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: That policy version already exists — versions are immutable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServerError:
      description: Server or database failure.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      properties:
        error: { type: string, description: "Human-readable failure reason." }
      required: [error]

    StatusOK:
      type: object
      properties:
        status: { type: string, example: ok }

    Health:
      type: object
      properties:
        status: { type: string, example: ok }
        edition: { type: string, example: enterprise }

    VersionInfo:
      type: object
      properties:
        service: { type: string, example: engineering-runtime-app-api }
        version: { type: string, example: dev }
        commit: { type: string }
        build_time: { type: string }
        go: { type: string, example: go1.23.0 }
        started_at: { type: string, format: date-time }
        uptime_seconds: { type: integer }
        environment: { type: string, example: production }
        surfaces:
          type: array
          items: { type: string }

    Policy:
      type: object
      properties:
        version: { type: string, example: v12 }
        name: { type: string, example: enterprise-policy }
        active: { type: boolean }
        description: { type: string }
        yaml: { type: string, description: "The policy document the runtime caches and evaluates locally." }
        updated_at: { type: string, format: date-time }
        updated_by: { type: string }

    PolicyList:
      type: object
      properties:
        policies:
          type: array
          items: { $ref: "#/components/schemas/Policy" }
        active: { type: string, description: "Version name of the active policy." }

    PolicyPublish:
      type: object
      required: [yaml]
      properties:
        yaml: { type: string, description: "Policy document. Validated before storage." }
        version: { type: string, description: "Omit to generate one from the UTC timestamp." }
        name: { type: string, default: enterprise-policy }
        description: { type: string }
        updated_by: { type: string, description: "Defaults to the authenticated user, or `portal-api`." }

    AuditEvent:
      type: object
      properties:
        id: { type: string }
        organization_id: { type: string }
        timestamp: { type: string, format: date-time }
        executor: { type: string, example: kishore }
        consumer: { type: string, enum: [human, ai, ci], default: human }
        runtime_context: { type: string }
        command: { type: string, example: github repo list }
        provider: { type: string, example: github }
        capability: { type: string }
        resource: { type: string }
        transport: { type: string }
        policy: { type: string, description: "Policy version that decided this command." }
        policy_decision: { type: string, enum: [ALLOWED, DENIED] }
        status: { type: string, example: success }
        message: { type: string }
        reason: { type: string }
        duration_ms: { type: integer }
        runtime_version: { type: string }
        runtime_name: { type: string }
        trace_id: { type: string }
        ai: { type: boolean }
        ingested_at: { type: string, format: date-time }

    AuditIngest:
      description: |
        One event, or `{"events":[…]}`. camelCase aliases (`user`, `tool`,
        `repository`, `decision`, `runtimeVersion`, `runtimeName`,
        `policyVersion`, `traceId`, `durationMs`, `ai`) are accepted for
        compatibility with older runtime builds.
      oneOf:
        - $ref: "#/components/schemas/AuditEvent"
        - type: object
          required: [events]
          properties:
            events:
              type: array
              items: { $ref: "#/components/schemas/AuditEvent" }

    AuditBatchResult:
      type: object
      properties:
        accepted: { type: integer }
        events:
          type: array
          items: { $ref: "#/components/schemas/AuditEvent" }

    AuditPage:
      type: object
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/AuditEvent" }
        total: { type: integer, description: "Matching rows before paging." }
        returned: { type: integer }
        limit: { type: integer }
        offset: { type: integer }

    Provider:
      type: object
      properties:
        id: { type: string, example: github }
        name: { type: string, example: GitHub }
        status:
          type: string
          enum: [connected, offline, degraded]
          description: Derived per request from recent audit activity unless a stored row overrides it.
        capabilities: { type: integer }
        operations: { type: integer }
        auth: { type: string }
        last_used: { type: string, format: date-time }
        scope: { type: string }

    ProviderList:
      type: object
      properties:
        providers:
          type: array
          items: { $ref: "#/components/schemas/Provider" }
        catalog: { type: string, description: "Which catalog document answered — `embed`, `file:…`, or `url:…`." }
        generated_at: { type: string }

    ProviderNames:
      type: object
      properties:
        providers:
          type: array
          items: { type: string }

    Capability:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        provider: { type: string }
        description: { type: string }
        path: { type: string }

    CapabilityList:
      type: object
      properties:
        capabilities:
          type: array
          items: { $ref: "#/components/schemas/Capability" }
        total: { type: integer }
        catalog: { type: string }
        generated_at: { type: string }

    RuntimeInstance:
      type: object
      properties:
        name: { type: string, example: ci-runner-01 }
        status: { type: string, enum: [online, idle, offline] }
        version: { type: string }
        policy_version: { type: string }
        last_seen: { type: string, format: date-time }
        hostname: { type: string }
        os: { type: string }

    FleetList:
      type: object
      properties:
        runtimes:
          type: array
          items: { $ref: "#/components/schemas/RuntimeInstance" }

    Heartbeat:
      type: object
      required: [name]
      properties:
        name: { type: string, description: "Stable runtime name. Upsert key together with the organisation." }
        version: { type: string }
        policy_version: { type: string }
        hostname: { type: string }
        os: { type: string }

    RuntimeRegister:
      type: object
      required: [name]
      properties:
        name: { type: string }
        version: { type: string }
        hostname: { type: string }
        os: { type: string }

    RegisterResult:
      type: object
      properties:
        runtimeId: { type: string }
        name: { type: string }
        status: { type: string, example: registered }

    Dashboard:
      type: object
      properties:
        runtimes_online: { type: integer }
        runtimes_total: { type: integer }
        providers: { type: integer }
        capabilities: { type: integer }
        policy_version: { type: string }
        commands_today: { type: integer, description: "Commands in the last 24 hours." }
        allowed_today: { type: integer }
        denied_today: { type: integer }
        ai_operations: { type: integer }
        ai_commands_today: { type: integer, description: "Same value as `ai_operations`, under the field name the demo UI reads." }
        by_provider:
          type: object
          additionalProperties: { type: integer }
        recent_audit:
          type: array
          items: { $ref: "#/components/schemas/AuditEvent" }

    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string, format: password }

    LoginResult:
      type: object
      properties:
        token: { type: string, description: "JWT for `Authorization: Bearer`." }
        expires_at: { type: string, format: date-time }
        user: { $ref: "#/components/schemas/User" }

    User:
      type: object
      properties:
        id: { type: string }
        email: { type: string, format: email }
        name: { type: string }
        role: { type: string, enum: [admin, operator, viewer] }
        active: { type: boolean }
        organization_id: { type: string }

    UserList:
      type: object
      properties:
        users:
          type: array
          items: { $ref: "#/components/schemas/User" }

    Organization:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        slug: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    OrganizationList:
      type: object
      properties:
        organizations:
          type: array
          items: { $ref: "#/components/schemas/Organization" }

    APIKey:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        prefix: { type: string, description: "First characters of the key — enough to identify it, not to use it." }
        scopes: { type: string, default: runtime }
        revoked: { type: boolean }
        last_used_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    APIKeyList:
      type: object
      properties:
        api_keys:
          type: array
          items: { $ref: "#/components/schemas/APIKey" }

    APIKeyCreate:
      type: object
      required: [name]
      properties:
        name: { type: string }
        scopes: { type: string, default: runtime }

    APIKeyCreated:
      allOf:
        - $ref: "#/components/schemas/APIKey"
        - type: object
          properties:
            api_key: { type: string, description: "Plaintext key. Shown once — only the hash is stored." }
            warning: { type: string }

    RevokeResult:
      type: object
      properties:
        status: { type: string, example: revoked }
        id: { type: string }

    Settings:
      type: object
      properties:
        organization_id: { type: string }
        edition: { type: string, example: enterprise }
        auth:
          type: object
          additionalProperties: true
        settings:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              value: { type: string }

    Metadata:
      type: object
      description: What this host is, which surfaces it exposes, and where its contracts live.
      properties:
        service: { type: string }
        description: { type: string }
        base_url: { type: string }
        environment: { type: string }
        version: { $ref: "#/components/schemas/BuildInfo" }
        portal:
          type: object
          additionalProperties: { type: string }
        contracts:
          type: object
          additionalProperties: { type: string }
        metadata:
          type: object
          additionalProperties: { type: string }
        surfaces:
          type: array
          items: { $ref: "#/components/schemas/Surface" }
        catalog:
          type: object
          additionalProperties: true
        health: { type: string }

    BuildInfo:
      type: object
      properties:
        version: { type: string }
        commit: { type: string }
        build_time: { type: string }
        go: { type: string }
        started_at: { type: string, format: date-time }
        uptime_seconds: { type: integer }

    Surface:
      type: object
      properties:
        prefix: { type: string, example: /api/public }
        audience: { type: string }
        auth: { type: string, description: "Reported from live configuration, not from documentation." }
        stable: { type: boolean }

    MetadataAPIs:
      type: object
      properties:
        base_url: { type: string }
        total: { type: integer }
        by_surface:
          type: object
          additionalProperties: { type: integer }
        contract: { type: string }
        operations:
          type: array
          items:
            type: object
            properties:
              method: { type: string }
              path: { type: string }
              summary: { type: string }
              surface: { type: string, enum: [public, v1, portal] }
              auth: { type: string }
              tags:
                type: array
                items: { type: string }

    MetadataVersions:
      type: object
      properties:
        build: { $ref: "#/components/schemas/BuildInfo" }
        openapi:
          type: object
          properties:
            spec: { type: string, example: 3.1.0 }
            api_version: { type: string, example: v1 }
        api_surfaces:
          type: array
          items:
            type: object
            properties:
              prefix: { type: string }
              version: { type: string }
              status: { type: string }
        catalog:
          type: object
          additionalProperties: true
        environment: { type: string }

    CatalogEnvelope:
      type: object
      description: Repeated on every contract response, so one request is enough to know what you are holding.
      properties:
        contract_version:
          type: string
          example: "1.0"
          description: "Compatibility promise. Additive changes bump the minor; removals or renames bump the major. A catalog generated before versioning existed reports \"0\"."
        generated_at: { type: string, format: date-time }
        source: { type: string, description: "Which catalog document answered — `embed`, `file:…` or `url:…`." }
        runtime: { $ref: "#/components/schemas/CatalogProvenance" }
        counts:
          type: object
          properties:
            providers: { type: integer }
            capabilities: { type: integer }

    CatalogProvenance:
      type: object
      description: Which commit of engineering-runtime the catalog was built from. Absence is meaningful — see `known`.
      properties:
        repo: { type: string, example: engineering-runtime }
        commit: { type: string, description: "Full SHA the catalog was generated from." }
        short_commit: { type: string, example: 38eec00 }
        ref: { type: string, example: main }
        known:
          type: boolean
          description: "False when the catalog was generated without a runtime checkout. The commit is then absent rather than guessed — an unknown provenance is stated, never defaulted."
        note: { type: string, description: "Present only when `known` is false." }

    CatalogContract:
      allOf:
        - $ref: "#/components/schemas/CatalogEnvelope"
        - type: object
          properties:
            description: { type: string }
            contracts:
              type: object
              additionalProperties: { type: string }
            documentation: { type: string }
            tenant_view:
              type: object
              additionalProperties: { type: string }
              description: Where to get the org-scoped view instead, and why it differs.

    CatalogProviders:
      allOf:
        - $ref: "#/components/schemas/CatalogEnvelope"
        - type: object
          properties:
            total: { type: integer }
            providers:
              type: array
              items: { $ref: "#/components/schemas/CatalogProviderRow" }

    CatalogProviderRow:
      type: object
      description: A provider as the runtime defines it — no status, no tenant.
      properties:
        id: { type: string, example: github }
        name: { type: string, example: GitHub }
        auth: { type: string }
        operations: { type: integer }
        operation_names:
          type: array
          items: { type: string }
        capabilities: { type: integer, description: "How many capabilities target this provider." }

    CatalogCapabilities:
      allOf:
        - $ref: "#/components/schemas/CatalogEnvelope"
        - type: object
          properties:
            total: { type: integer, description: "Rows after filtering." }
            capabilities:
              type: array
              items: { $ref: "#/components/schemas/CatalogCapabilityRow" }

    CatalogCapabilityRow:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        provider: { type: string }
        description: { type: string }
        path: { type: string, description: "Source path within the runtime repository." }

    MetadataAuthentication:
      type: object
      properties:
        require_auth: { type: boolean, description: "Whether this process enforces credentials on `/api/v1`." }
        api_keys_accepted: { type: boolean }
        environment: { type: string }
        surfaces:
          type: array
          items: { $ref: "#/components/schemas/Surface" }
        schemes:
          type: array
          items:
            type: object
            additionalProperties: true
        runtime_client_header: { type: string }
        documentation: { type: string }

security:
  - bearerAuth: []
  - apiKeyAuth: []
