openapi: 3.1.0
info:
  title: Splitstream API
  version: 0.1.0
  summary: A/B testing and experimentation API with sticky bucketing, Bayesian credible intervals, and SRM detection.
  description: |
    Splitstream is the sibling to Pennant. Where Pennant decides *whether* a
    feature ships, Splitstream decides *which variant* a user sees and
    *whether the variant moved the metric*. Define experiments in a
    workspace, configure variants + audience + metrics, let SDKs call
    `/v1/assign` for sticky bucketing, and stream conversion events into
    `/v1/events`. The analysis worker computes Bayesian credible intervals
    every 4 hours; the results endpoint returns the latest snapshot.

    This spec is the source of truth. Laravel controllers conform to it; SDKs
    are generated from it. Run `npm run spectral` to validate locally.

    Calibrated decision-rule defaults (Phase 0):

    | Parameter | Default |
    |---|---|
    | posterior_threshold | 0.995 |
    | min_sample_per_variant | 20000 |
    | snapshot_cadence_minutes | 240 |

    Plan's draft defaults (0.95 / 1000 / 15min) delivered 66% empirical false-
    positive rate under continuous peeking. See `/docs/concepts/bayesian-inference`.
  contact:
    name: Philip Rehberger
    url: https://splitstream.philiprehberger.com
  license:
    name: MIT
    identifier: MIT

servers:
  - url: https://api.splitstream.philiprehberger.com
    description: Production
  - url: http://localhost:8000
    description: Local development

security:
  - ApiKeyAuth: []

tags:
  - name: Health
    description: Liveness check.
  - name: Workspaces
    description: Tenant boundary; one workspace per customer.
  - name: Environments
    description: Per-workspace environments (e.g. dev / staging / prod).
  - name: Experiments
    description: Experiment definitions and lifecycle.
  - name: Metrics
    description: Reusable metric definitions referenced by experiments.
  - name: Mutex Groups
    description: Mutually exclusive experiment cohorts — a unit assigned to one experiment in the group cannot be assigned to another.
  - name: Segments
    description: Reusable audience filters (shared expression-tree format with Pennant).
  - name: Assignment
    description: SDK-facing sticky bucketing.
  - name: Events
    description: Conversion and exposure event ingest.
  - name: Results
    description: Posterior + decision-rule snapshots per experiment.
  - name: API Keys
    description: Server and client keys for SDK authentication.
  - name: Audit
    description: Read-only mutation + peek-event history.

paths:
  /v1/healthz:
    get:
      summary: Liveness check
      description: Returns 200 if the API is up. Does not require auth.
      operationId: healthz
      tags: [Health]
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }

  /v1/workspaces/current:
    get:
      summary: Get the workspace the current API key belongs to
      operationId: getCurrentWorkspace
      tags: [Workspaces]
      responses:
        "200":
          description: The workspace.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Workspace" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/environments:
    get:
      summary: List environments
      operationId: listEnvironments
      tags: [Environments]
      responses:
        "200":
          description: Page of environments.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvironmentList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      summary: Create an environment
      operationId: createEnvironment
      tags: [Environments]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EnvironmentInput" }
      responses:
        "201":
          description: Environment created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Environment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /v1/experiments:
    get:
      summary: List experiments
      operationId: listExperiments
      tags: [Experiments]
      parameters:
        - in: query
          name: status
          schema: { type: string, enum: [draft, running, stopped, archived] }
        - in: query
          name: cursor
          schema: { type: string }
      responses:
        "200":
          description: Page of experiments.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExperimentList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      summary: Create an experiment
      operationId: createExperiment
      tags: [Experiments]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExperimentInput" }
      responses:
        "201":
          description: Experiment created (status `draft`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Experiment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /v1/experiments/{id}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string }, description: "Experiment ULID" }
    get:
      summary: Get an experiment
      operationId: getExperiment
      tags: [Experiments]
      responses:
        "200":
          description: The experiment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Experiment" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      summary: Update an experiment
      operationId: updateExperiment
      tags: [Experiments]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExperimentInput" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Experiment" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      summary: Archive an experiment
      operationId: archiveExperiment
      tags: [Experiments]
      responses:
        "204": { description: Archived (no body). }

  /v1/experiments/{id}/start:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
    post:
      summary: Start an experiment (draft → running)
      operationId: startExperiment
      tags: [Experiments]
      responses:
        "200":
          description: Started.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Experiment" }
        "409":
          description: Experiment not in `draft` status.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /v1/experiments/{id}/stop:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
    post:
      summary: Stop an experiment
      operationId: stopExperiment
      tags: [Experiments]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StopExperimentInput" }
      responses:
        "200":
          description: Stopped.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Experiment" }

  /v1/experiments/{id}/results:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
    get:
      summary: Latest analysis snapshot
      description: |
        Returns the most recent snapshot computed by the analysis worker.
        Snapshots run every 4 hours by default; the production cadence is
        controlled by `snapshot_cadence_minutes` per experiment.

        Opening this endpoint via the admin UI logs a peek event; SDK access
        via API key does not (the SDK reads this for SRM monitoring, not for
        the decision).
      operationId: getResults
      tags: [Results]
      responses:
        "200":
          description: Latest snapshot.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalysisSnapshot" }
        "404":
          description: No snapshots yet.

  /v1/experiments/{id}/results/history:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
    get:
      summary: Snapshot history
      description: Time series of snapshots — for the "watch the credible interval tighten" chart.
      operationId: getResultsHistory
      tags: [Results]
      responses:
        "200":
          description: Snapshot time series.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalysisSnapshotHistory" }

  /v1/metrics:
    get:
      summary: List metric definitions
      operationId: listMetrics
      tags: [Metrics]
      responses:
        "200":
          description: Page of metrics.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MetricList" }
    post:
      summary: Create a metric definition
      operationId: createMetric
      tags: [Metrics]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MetricInput" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Metric" }

  /v1/mutex-groups:
    get:
      summary: List mutex groups
      operationId: listMutexGroups
      tags: [Mutex Groups]
      responses:
        "200":
          description: Page of mutex groups.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MutexGroupList" }
    post:
      summary: Create a mutex group
      operationId: createMutexGroup
      tags: [Mutex Groups]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MutexGroupInput" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MutexGroup" }

  /v1/assign:
    post:
      summary: Sticky bucket assignment for one experiment
      description: |
        Returns the variant assigned to `unit_id` for `experiment_key`.
        First call writes to the `assignments` table (sticky-forever); later
        calls read the row.

        Side effect: the first call logs an exposure event for the variant.
        Subsequent calls do not double-log.

        **Latency target:** 20ms p99 cache-hit, 50ms p99 cache-miss. Endpoint
        runs on Laravel Octane (RoadRunner) — Apache mod_proxy carves out the
        path to `127.0.0.1:8000`.

        **Holdout responses:** if the experiment is in a mutex group and
        `unit_id` is already claimed by another experiment in the group, the
        response is `{ variant: null, reason: "mutex_holdout" }`.
      operationId: assign
      tags: [Assignment]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssignInput" }
      responses:
        "200":
          description: Assignment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Assignment" }
        "404":
          description: Experiment not found or not in `running` status.

  /v1/assign/batch:
    post:
      summary: Sticky bucket assignment across many experiments in one round-trip
      description: |
        Returns a map of `experiment_key → assignment`. Max 50 experiments per
        request.
      operationId: assignBatch
      tags: [Assignment]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssignBatchInput" }
      responses:
        "200":
          description: Per-experiment assignments.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AssignBatchResult" }

  /v1/assignments/{unit_id}:
    parameters:
      - { in: path, name: unit_id, required: true, schema: { type: string }, description: "Bucketed unit identifier (1–256 bytes UTF-8)" }
    get:
      summary: All assignments for a unit
      description: SDK reconciliation endpoint — returns every assignment the unit currently has.
      operationId: getAssignmentsForUnit
      tags: [Assignment]
      responses:
        "200":
          description: All assignments.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AssignmentList" }

  /v1/assignments/{experiment}/{unit_id}:
    parameters:
      - { in: path, name: experiment, required: true, schema: { type: string } }
      - { in: path, name: unit_id, required: true, schema: { type: string } }
    delete:
      summary: Force re-bucket (atomic, audit-logged)
      description: |
        Deletes the assignment row **and** sets `superseded_at = now()` on
        every `exposure_events` row for the unit-experiment pair. The
        analysis worker filters tombstoned rows. Use sparingly — breaks the
        experiment's data for that unit.
      operationId: forceRebucket
      tags: [Assignment]
      responses:
        "204": { description: Rebucketed. }

  /v1/events:
    post:
      summary: Track a conversion or arbitrary metric event
      description: |
        Synchronous endpoint, asynchronous persistence. Body is validated,
        deduped via Redis SETNX on `client_event_id`, written to a Redis
        list, and `202 Accepted` returned. The ingest worker drains the list
        every second via `COPY ... FROM STDIN`.

        **Latency target:** 30ms p99 (Octane).

        **Late-event policy:** events with `occurred_at` 7–30 days old are
        accepted and counted into `late_event_count`; older than 30 days are
        rejected with `412 Precondition Failed`.
      operationId: trackEvent
      tags: [Events]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventInput" }
      responses:
        "202":
          description: Accepted (or idempotent replay).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventAck" }
        "412":
          description: Event too late (occurred_at > 30 days ago).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Backpressure — Redis buffer exceeded 100k entries.
          headers:
            Retry-After: { schema: { type: integer }, description: "Seconds to wait" }
        "503":
          description: Redis unreachable.

  /v1/events/batch:
    post:
      summary: Track up to 500 events in one round-trip
      operationId: trackEventBatch
      tags: [Events]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventBatchInput" }
      responses:
        "202":
          description: Accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventBatchAck" }

  /v1/exposures:
    post:
      summary: Log an exposure (for SDKs that decouple assignment from exposure)
      description: |
        Server-side and mobile SDKs call this when the variant is actually
        shown to the user. Browser SDKs typically don't need it — the
        `/v1/assign` first-call already logged exposure.
      operationId: logExposure
      tags: [Events]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExposureInput" }
      responses:
        "202":
          description: Accepted.

  /v1/api-keys:
    get:
      summary: List API keys
      operationId: listApiKeys
      tags: [API Keys]
      responses:
        "200":
          description: Page of keys (secrets redacted; only `last_four` returned).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyList" }
    post:
      summary: Issue an API key
      description: |
        Returns the full key value exactly once — the server stores
        `HMAC-SHA256(key, APP_KEY_PEPPER)` for fast lookup and an Argon2id
        hash as defense-in-depth.
      operationId: createApiKey
      tags: [API Keys]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApiKeyInput" }
      responses:
        "201":
          description: Key created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyWithSecret" }

  /v1/audit:
    get:
      summary: Audit log
      operationId: listAudit
      tags: [Audit]
      responses:
        "200":
          description: Audit entries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditList" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: "ss_{srv|clt}_{env}_{secret}"

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: Resource not found.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ValidationError:
      description: Body validation failed.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    Health:
      type: object
      properties:
        status: { type: string, const: "ok" }
        version: { type: string }
      required: [status]

    Problem:
      type: object
      description: RFC 7807 problem details.
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
      required: [type, title, status]

    Workspace:
      type: object
      properties:
        id: { type: string, description: ULID }
        name: { type: string }
        slug: { type: string }
        created_at: { type: string, format: date-time }
      required: [id, name, slug, created_at]

    Environment:
      type: object
      properties:
        id: { type: string }
        workspace_id: { type: string }
        name: { type: string }
        slug: { type: string }
        created_at: { type: string, format: date-time }
      required: [id, workspace_id, name, slug, created_at]
    EnvironmentInput:
      type: object
      properties:
        name: { type: string }
        slug: { type: string }
      required: [name, slug]
    EnvironmentList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Environment" }
        next_cursor: { type: string, nullable: true }
      required: [data]

    Experiment:
      type: object
      properties:
        id: { type: string }
        workspace_id: { type: string }
        environment_id: { type: string }
        key: { type: string }
        name: { type: string }
        hypothesis: { type: string }
        unit_type: { type: string, enum: [user, account, session, device, custom_attribute] }
        status: { type: string, enum: [draft, running, stopped, archived] }
        variants:
          type: array
          items: { $ref: "#/components/schemas/Variant" }
        audience: { $ref: "#/components/schemas/AudienceExpression" }
        decision_rule: { $ref: "#/components/schemas/DecisionRule" }
        primary_metric_id: { type: string, nullable: true }
        guardrail_metric_ids:
          type: array
          items: { type: string }
        mutex_group_id: { type: string, nullable: true }
        started_at: { type: string, format: date-time, nullable: true }
        stopped_at: { type: string, format: date-time, nullable: true }
        stop_reason: { type: string, enum: [won, lost, inconclusive, bug, business], nullable: true }
        created_at: { type: string, format: date-time }
      required: [id, workspace_id, environment_id, key, name, hypothesis, unit_type, status, variants, decision_rule, created_at]
    ExperimentInput:
      type: object
      properties:
        environment_id: { type: string }
        key: { type: string, pattern: "^[a-z0-9._-]{1,128}$" }
        name: { type: string }
        hypothesis: { type: string, minLength: 1 }
        unit_type: { type: string, enum: [user, account, session, device, custom_attribute] }
        variants:
          type: array
          minItems: 2
          items: { $ref: "#/components/schemas/VariantInput" }
        audience: { $ref: "#/components/schemas/AudienceExpression" }
        decision_rule: { $ref: "#/components/schemas/DecisionRule" }
        primary_metric_id: { type: string, nullable: true }
        guardrail_metric_ids:
          type: array
          items: { type: string }
        mutex_group_id: { type: string, nullable: true }
      required: [environment_id, key, name, hypothesis, unit_type, variants]
    ExperimentList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Experiment" }
        next_cursor: { type: string, nullable: true }
      required: [data]
    StopExperimentInput:
      type: object
      properties:
        reason: { type: string, enum: [won, lost, inconclusive, bug, business] }
        outcome_notes: { type: string }
      required: [reason]

    Variant:
      type: object
      properties:
        id: { type: string }
        key: { type: string }
        weight: { type: integer, minimum: 0, maximum: 100 }
        description: { type: string }
        is_control: { type: boolean }
      required: [id, key, weight, is_control]
    VariantInput:
      type: object
      properties:
        key: { type: string, pattern: "^[a-z0-9._-]{1,64}$" }
        weight: { type: integer, minimum: 0, maximum: 100 }
        description: { type: string }
        is_control: { type: boolean, default: false }
      required: [key, weight]

    AudienceExpression:
      type: object
      description: |
        JSON expression tree — reuses the Pennant rule format. Either
        `{operator, operands}` for compound, or `{attribute, op, value}` for
        leaf.
      nullable: true

    DecisionRule:
      type: object
      properties:
        method: { type: string, enum: [bayesian.posterior_threshold, bayesian.always_valid_evalue, frequentist.sequential_msprt], default: bayesian.posterior_threshold }
        posterior_threshold: { type: number, minimum: 0.5, maximum: 0.9999, default: 0.995 }
        minimum_detectable_effect: { type: number, default: 0.02 }
        min_sample_per_variant: { type: integer, minimum: 1, default: 20000 }
        max_duration_days: { type: integer, default: 28 }
        snapshot_cadence_minutes: { type: integer, default: 240 }

    Metric:
      type: object
      properties:
        id: { type: string }
        workspace_id: { type: string }
        key: { type: string }
        name: { type: string }
        event_key: { type: string }
        kind: { type: string, enum: [binary, count, revenue, duration] }
        aggregation: { type: string, enum: [sum, mean, count] }
        unit_dedup_window: { type: string, description: "ISO-8601 duration; default P7D" }
        property_path: { type: string, nullable: true, description: "Strict JSON-Path subset (dot-chain, bracketed string keys) — no recursive descent, no filters." }
      required: [id, key, name, event_key, kind]
    MetricInput:
      type: object
      properties:
        key: { type: string, pattern: "^[a-z0-9._-]{1,128}$" }
        name: { type: string }
        event_key: { type: string }
        kind: { type: string, enum: [binary, count, revenue, duration] }
        aggregation: { type: string, enum: [sum, mean, count], default: count }
        unit_dedup_window: { type: string, default: "P7D" }
        property_path: { type: string, nullable: true }
      required: [key, name, event_key, kind]
    MetricList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Metric" } }
        next_cursor: { type: string, nullable: true }
      required: [data]

    MutexGroup:
      type: object
      properties:
        id: { type: string }
        key: { type: string }
        name: { type: string }
        experiment_count: { type: integer }
      required: [id, key, name]
    MutexGroupInput:
      type: object
      properties:
        key: { type: string }
        name: { type: string }
      required: [key, name]
    MutexGroupList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/MutexGroup" } }
        next_cursor: { type: string, nullable: true }
      required: [data]

    AssignInput:
      type: object
      properties:
        experiment_key: { type: string }
        unit_id: { type: string, minLength: 1, maxLength: 256 }
        context:
          type: object
          additionalProperties: true
          description: "User attributes for audience targeting; ≤4KB JSON."
      required: [experiment_key, unit_id]
    Assignment:
      type: object
      properties:
        experiment_key: { type: string }
        unit_id: { type: string }
        variant: { type: string, nullable: true, description: "null when mutex_holdout" }
        assignment_id: { type: string }
        reason: { type: string, enum: [bucketed, forced, mutex_holdout, audience_excluded] }
        exposure_logged_at: { type: string, format: date-time, nullable: true }
      required: [experiment_key, unit_id, variant, reason]
    AssignBatchInput:
      type: object
      properties:
        experiments:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string }
        unit_id: { type: string }
        context:
          type: object
          additionalProperties: true
      required: [experiments, unit_id]
    AssignBatchResult:
      type: object
      properties:
        assignments:
          type: object
          additionalProperties: { $ref: "#/components/schemas/Assignment" }
      required: [assignments]
    AssignmentList:
      type: object
      properties:
        unit_id: { type: string }
        assignments:
          type: array
          items: { $ref: "#/components/schemas/Assignment" }
      required: [unit_id, assignments]

    EventInput:
      type: object
      properties:
        event_key: { type: string, pattern: "^[a-z0-9._-]{1,128}$" }
        unit_id: { type: string, minLength: 1, maxLength: 256 }
        properties:
          type: object
          additionalProperties: true
          description: "≤16KB JSON, max nesting depth 8."
        occurred_at: { type: string, format: date-time }
        client_event_id: { type: string, maxLength: 128 }
      required: [event_key, unit_id]
    EventAck:
      type: object
      properties:
        accepted: { type: boolean }
        idempotent_replay: { type: boolean, description: "true if client_event_id was already seen within 30d" }
      required: [accepted]
    EventBatchInput:
      type: object
      properties:
        events:
          type: array
          minItems: 1
          maxItems: 500
          items: { $ref: "#/components/schemas/EventInput" }
      required: [events]
    EventBatchAck:
      type: object
      properties:
        accepted_count: { type: integer }
        rejected:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              reason: { type: string }
      required: [accepted_count]
    ExposureInput:
      type: object
      properties:
        experiment_key: { type: string }
        unit_id: { type: string }
        variant: { type: string }
      required: [experiment_key, unit_id, variant]

    AnalysisSnapshot:
      type: object
      properties:
        id: { type: string }
        experiment_id: { type: string }
        computed_at: { type: string, format: date-time }
        per_variant:
          type: array
          items: { $ref: "#/components/schemas/VariantPosterior" }
        srm_chi_squared_p: { type: number, nullable: true }
        srm_warning: { type: boolean }
        decision_rule_satisfied: { type: boolean }
        peek_count_at_computation: { type: integer }
        late_event_count: { type: integer }
        weights_changed_since_start: { type: boolean }
      required: [id, experiment_id, computed_at, per_variant, decision_rule_satisfied]
    AnalysisSnapshotHistory:
      type: object
      properties:
        experiment_id: { type: string }
        snapshots:
          type: array
          items: { $ref: "#/components/schemas/AnalysisSnapshot" }
      required: [experiment_id, snapshots]
    VariantPosterior:
      type: object
      properties:
        variant_key: { type: string }
        is_control: { type: boolean }
        sample_size: { type: integer }
        conversions: { type: integer }
        observed_rate: { type: number }
        posterior:
          type: object
          properties:
            mean: { type: number }
            credible_interval_95:
              type: array
              minItems: 2
              maxItems: 2
              items: { type: number }
          required: [mean, credible_interval_95]
        prob_best: { type: number, description: "P(this variant is best)" }
        expected_loss_if_stop_now: { type: number, description: "Expected lift forgone if we stop now and this variant turns out to be inferior" }
      required: [variant_key, sample_size, conversions, posterior]

    ApiKey:
      type: object
      properties:
        id: { type: string }
        workspace_id: { type: string }
        environment_id: { type: string, nullable: true }
        name: { type: string }
        kind: { type: string, enum: [server, client] }
        prefix: { type: string, description: "e.g. ss_srv_live" }
        last_four: { type: string }
        last_used_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
      required: [id, kind, prefix, last_four, created_at]
    ApiKeyInput:
      type: object
      properties:
        name: { type: string }
        kind: { type: string, enum: [server, client] }
        environment_id: { type: string, nullable: true }
      required: [name, kind]
    ApiKeyWithSecret:
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          properties:
            key: { type: string, description: "Full key — shown once at creation only." }
          required: [key]
    ApiKeyList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/ApiKey" } }
        next_cursor: { type: string, nullable: true }
      required: [data]

    AuditEntry:
      type: object
      properties:
        id: { type: string }
        actor_id: { type: string, nullable: true }
        action: { type: string }
        target_type: { type: string }
        target_id: { type: string }
        payload: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
      required: [id, action, target_type, target_id, created_at]
    AuditList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/AuditEntry" } }
        next_cursor: { type: string, nullable: true }
      required: [data]
