openapi: 3.0.3
info:
  title: Kirelta API
  version: "1.0"
  description: >
    Reliability monitoring for production AI/ML models. Every path, parameter, and response
    field in this document was checked against the running service, not written from memory —
    see kirelta.com/docs.html for the same API explained in prose with copy-paste examples.


    Scope is deliberate: this covers `/fit`, `/assess`, and reading the decision journal — the
    same boundary the official Python and JavaScript clients use. Endpoints that require a
    signed-in human (reviewing a decision, managing team members, billing) are session-based,
    not API-key based, and are intentionally out of scope for a machine-to-machine spec like
    this one.
  license:
    name: MIT
  contact:
    url: https://kirelta.com/contact.html
servers:
  - url: https://api.kirelta.com
    description: Production (unversioned; stable, same as /v1 below)
  - url: https://api.kirelta.com/v1
    description: Production, explicitly versioned

tags:
  - name: Engine
    description: Train a baseline, assess a batch.
  - name: Decisions
    description: Read the evidence-backed record of every verdict change.
  - name: Auth
    description: Get an account and an API key.

paths:
  /health:
    get:
      operationId: health
      summary: Liveness probe
      description: Public, unauthenticated. Used by Kirelta's own status page and by hosting-platform health checks.
      tags: [Engine]
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  service: { type: string, example: kirelta }
                  durable:
                    type: boolean
                    description: false if the service has no writable model directory mounted — trained models will not survive a restart.
                  durable_note:
                    type: string
                    nullable: true

  /signup:
    post:
      operationId: signup
      summary: Create an account and get an API key
      description: "Wrapped by both official SDKs as a static/class method — Kirelta.signup(...) returns a ready-to-use client plus this full response."
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 10, description: "10 characters minimum." }
                name: { type: string }
      responses:
        "200":
          description: Account created. The api_key is shown exactly once — Kirelta itself keeps only a hash of it.
          content:
            application/json:
              schema:
                type: object
                properties:
                  session: { type: string, description: "Bearer token for session-authenticated endpoints." }
                  tenant_id: { type: string }
                  api_key: { type: string, example: "kir_..." }
                  email: { type: string }
                  name: { type: string }
                  note: { type: string }
                  verification: { type: string, nullable: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/login:
    post:
      operationId: login
      summary: Exchange email + password for a session token
      description: >
        Not wrapped by the official SDKs — a session authenticates human-facing dashboard
        actions, not the fit/assess/read operations those clients cover. Call this directly if
        your use case genuinely needs a session (e.g. building your own dashboard integration).
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  session: { type: string }
        "401": { description: "Invalid email or password." }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/me:
    get:
      operationId: getMe
      summary: The signed-in account, its tenant, and its API keys
      description: "Session-authenticated only, like /auth/login — not wrapped by the official SDKs for the same reason."
      tags: [Auth]
      security: [{ sessionAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  account:
                    type: object
                    properties:
                      id: { type: string }
                      email: { type: string }
                      name: { type: string }
                      role: { type: string, enum: [owner, member, viewer] }
                  tenant: { type: object }
                  keys: { type: array, items: { type: object } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /fit:
    post:
      operationId: fit
      summary: Train a baseline for a model on known-healthy data
      description: >
        This is the only ground truth the engine will ever assume for this model. Calling it
        again with different data replaces the baseline; there is no versioning of past
        baselines.
      tags: [Engine]
      security: [{ apiKeyAuth: [] }]
      parameters:
        - name: model
          in: query
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rows]
              properties:
                rows:
                  type: array
                  items: { type: array, items: { type: number } }
                feature_names: { type: array, items: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: fitted }
                  model: { type: string }
                  n: { type: integer }
                  features: { type: integer }
                  persisted: { type: boolean }
                  variant: { type: string }
                  variant_confidence: { type: string, description: "How confident the engine is in its choice of baseline model shape." }
                  reason: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /assess:
    post:
      operationId: assess
      summary: Check a batch against the trained baseline
      tags: [Engine]
      security: [{ apiKeyAuth: [] }]
      parameters:
        - name: model
          in: query
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rows]
              properties:
                rows:
                  type: array
                  items: { type: array, items: { type: number } }
      responses:
        "200":
          description: >
            decision_id is present only when the verdict actually changed since the last
            assessment — its absence on an unchanged TRUSTED verdict is intentional, not a
            missing field.
          content:
            application/json:
              schema:
                type: object
                properties:
                  model: { type: string }
                  verdict: { type: string, enum: [TRUSTED, DEGRADED, UNTRUSTED] }
                  action: { type: string }
                  decision_id: { type: string, nullable: true }
                  n: { type: integer, description: "Rows in this batch." }
                  variant: { type: string }
                  martingale: { type: number, description: "Current anytime-valid evidence value." }
                  alarm_threshold: { type: number }
                  drift_alarmed: { type: boolean }
                  drift_started_at: { type: number, nullable: true, description: "Unix timestamp." }
                  flagged_rate: { type: number }
                  top_features: { type: array, items: { type: string }, description: "Which inputs moved most." }
                  feature_importance: { type: object, additionalProperties: { type: number } }
                  output_stability: { type: object, nullable: true, description: "Present only when you send the model's own predictions alongside inputs." }
                  temporal: { type: object, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { description: "No baseline exists for this model yet — call /fit first." }
        "429": { $ref: "#/components/responses/RateLimited" }

  /decisions:
    get:
      operationId: listDecisions
      summary: The decision journal, filtered and paginated server-side
      tags: [Decisions]
      security: [{ apiKeyAuth: [] }, { sessionAuth: [] }]
      parameters:
        - name: status
          in: query
          schema: { type: string }
        - name: model
          in: query
          schema: { type: string }
        - name: verdict
          in: query
          schema: { type: string, enum: [TRUSTED, DEGRADED, UNTRUSTED] }
        - name: q
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 25 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  decisions: { type: array, items: { $ref: "#/components/schemas/DecisionSummary" } }
                  total: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /decisions/{id}:
    get:
      operationId: getDecision
      summary: A single decision
      tags: [Decisions]
      security: [{ apiKeyAuth: [] }, { sessionAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  decision: { $ref: "#/components/schemas/Decision" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: "No decision with this id in your tenant." }

  /decisions/{id}/evidence:
    get:
      operationId: getEvidence
      summary: The sealed evidence behind a decision
      description: >
        Sealed at write time with a SHA-256 digest in `content_hash`, re-verified on every read
        — this is tamper-evident, not tamper-proof: a party with direct database write access
        could recompute a consistent-looking chain. Detecting that requires an anchor outside
        this database, which is a documented, honest gap, not an implied guarantee.
      tags: [Decisions]
      security: [{ apiKeyAuth: [] }, { sessionAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  evidence:
                    type: object
                    properties:
                      id: { type: string }
                      model: { type: string }
                      snapshot: { type: object }
                      engine_version: { type: string, nullable: true }
                      model_version: { type: string }
                      content_hash: { type: string }
                      integrity_ok: { type: boolean }
                      created: { type: number, description: "Unix timestamp." }
        "401": { $ref: "#/components/responses/Unauthorized" }

components:
  securitySchemes:
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: "From Account & keys. Starts with kir_."
    sessionAuth:
      type: http
      scheme: bearer
      description: "Obtained from /signup or /auth/login. Human-authenticated actions only."

  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    BadRequest:
      description: The request was rejected — see detail for why.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Too many requests. Backs off, does not fail silently.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
        detail: { type: string, nullable: true }
    DecisionSummary:
      description: Shape returned by GET /decisions (the list). The single-decision GET returns more — see Decision.
      type: object
      properties:
        id: { type: string }
        model: { type: string }
        verdict: { type: string, enum: [TRUSTED, DEGRADED, UNTRUSTED] }
        status: { type: string }
        reason: { type: string, nullable: true }
        recommendation: { type: string, nullable: true }
        trust_score: { type: number, nullable: true }
        evidence_id: { type: string }
        event_ts: { type: number, description: "Unix timestamp of the verdict change itself." }
        created: { type: number, description: "Unix timestamp." }
        updated: { type: number, description: "Unix timestamp." }
    Decision:
      description: Shape returned by GET /decisions/{id} — everything in DecisionSummary, plus the full evidence snapshot and review history embedded inline.
      allOf:
        - $ref: "#/components/schemas/DecisionSummary"
        - type: object
          properties:
            user_id: { type: string, nullable: true, description: "Who last transitioned this decision, if anyone." }
            evidence:
              type: object
              description: "The same object GET /decisions/{id}/evidence returns, embedded here so a single call gets both."
            history:
              type: array
              items:
                type: object
                properties:
                  from: { type: string, nullable: true }
                  to: { type: string }
                  actor: { type: string }
                  note: { type: string, nullable: true }
                  ts: { type: number }
