openapi: 3.1.0
info:
  title: Cohorly API
  version: "1.0.0"
  description: |
    Cohorly is a hosted, multi-tenant product-analytics API (Mixpanel-style).
    This document is the single source of truth for the HTTP surface served by
    `apps/server` - generated from the route handlers themselves, not from prose.
    Where this spec and `CLAUDE.md` / `docs/SAAS-CONTRACT.md` ever disagree, this
    spec (and the code behind it) wins.

    ### Analyses and the Query IR

    The five `POST /api/query/*` endpoints (`segmentation`, `funnel`, `retention`,
    `flows`, `sessions`) plus `POST /api/query/metric` are the stable "v0" wire
    contract every dashboard screen already speaks. `POST /api/query` and
    `POST /api/query/drilldown` run the same analyses through the newer Query IR
    pipeline (`docs/QUERY-IR.md`) and are the forward path; `trend` and `funnel`
    are executable today, `retention` and `flows` currently answer `501`. This
    spec treats the IR document (`query`) as a free-form JSON object - the full
    IR grammar is specified in `docs/QUERY-IR.md`, not restated here.

    ### Authentication

    Three credential kinds, all on `Authorization: Bearer <token>` except the
    project token:

    1. **Superadmin key** (`COHORLY_API_KEY`) - full access to `/api/*` and the
       only credential accepted on `/admin/*`.
    2. **Firebase ID token** - a verified end-user identity, resolved to a user
       + their first org membership. Valid on `/api/*` only, never `/admin/*`.
    3. **Project token** - ingestion only (`/track`, `/engage`, `/alias`), sent
       via the `X-Cohorly-Token` header (applies to the whole batch) or a
       per-item `token` field (wins over the header for that item).

    ### Errors

    Ingestion endpoints (`/track`, `/engage`, `/alias`) reply with the Mixpanel-
    style envelope `{ status: 0 | 1, ... }`; every other endpoint replies with a
    plain `{ error: string }` (optionally with an `errors` array for IR
    validation failures). `POST /api/query/*` rate-limit rejections are the one
    exception under `/api/*`: they reuse the ingestion `{ status: 0, error }`
    shape (see the `RateLimited` response).
  contact:
    name: Cohorly
externalDocs:
  description: Query IR spec, UX contract, and the SaaS tenancy contract
  url: https://github.com/cohorly/cohorly/tree/main/docs

servers:
  - url: http://localhost:4000
    description: Local development
  - url: https://cohorly-service.velloalabs.com
    description: Production

tags:
  - name: Health
    description: Unauthenticated liveness and status endpoints.
  - name: Ingestion
    description: Project-token endpoints SDKs call to send events, profile updates, and aliases.
  - name: Query
    description: Analysis endpoints - the Query IR pipeline and its v0 wrapper contracts (segmentation, funnel, retention, flows, sessions, metric, drilldown).
  - name: Data
    description: Raw event/user/property browsing and collection-health diagnostics.
  - name: Projects
    description: Project (app) administration within an org.
  - name: Reports
    description: Saved analyses.
  - name: Boards
    description: Dashboards composed of saved-report and text cards.
  - name: Cohorts
    description: Saved, named user segments and their live preview/membership.
  - name: Flags
    description: Feature flags - CRUD plus the project-token evaluation endpoint SDKs call.
  - name: Definitions
    description: Reusable derived events and calculated metrics, and querying them.
  - name: Lexicon
    description: Data dictionary / governance metadata over events and properties.
  - name: Annotations
    description: Time-anchored notes (releases, campaigns, incidents) overlaid on charts.
  - name: Privacy
    description: Subject access/export/delete requests (GDPR-style), owner-only.
  - name: Account
    description: The caller's own identity, org, membership, and usage.
  - name: Admin
    description: Backoffice endpoints - superadmin key only, Firebase tokens always rejected.

security: []

components:
  securitySchemes:
    ProjectToken:
      type: apiKey
      in: header
      name: X-Cohorly-Token
      description: |
        Project token identifying which project ingested data belongs to.
        Alternatively sent per-item as a `token` field in the request body
        (a per-event/op `token` wins over the header for that item); when both
        are absent the request is rejected. Because the token can arrive in
        the body instead of this header, operations that accept it declare
        `security: [{}, {ProjectToken: []}]` (header optional at the HTTP-auth
        layer; the route itself still requires a token from one of the two
        places, else `401 invalid token`).
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        Accepts EITHER the superadmin key (`COHORLY_API_KEY`) OR a Firebase ID
        token. Resolves to an auth context of `{ kind: "superadmin" }` or
        `{ kind: "user", userId, orgId, role }`. Required on every `/api/*`
        route. A missing/invalid credential is `401 { error: "unauthorized" }`.
    FlagSecret:
      type: http
      scheme: bearer
      description: |
        The project's flag secret (ADR-0011) - a revocable per-project
        credential, minted by `POST /api/projects/{id}/flag-secret`. It
        authorizes exactly one route, `GET /flags/local-evaluation`, and is
        never accepted anywhere else; the project token and `/api/*`
        credentials are never accepted there.
        `401 { status: 0, error: "invalid flag secret" }` otherwise.
    SuperadminAuth:
      type: http
      scheme: bearer
      description: |
        The superadmin key ONLY (`COHORLY_API_KEY`). Firebase ID tokens are
        always rejected here, even for an owner. Required on every `/admin/*`
        route. `401 { error: "unauthorized" }` otherwise.

  headers:
    RetryAfter:
      description: Seconds to wait before retrying (integer, >= 1; fixed at 3600 for the monthly quota case).
      schema:
        type: integer
        minimum: 1
      example: 10

  parameters:
    projectId:
      name: projectId
      in: query
      required: false
      schema:
        type: string
      description: |
        Target project id. For a superadmin credential, defaults to the
        legacy "default" project when omitted. For a user credential, the
        id must belong to the caller's org (else `403 forbidden`); when
        omitted, resolves to the org's oldest project. A user credential can
        never reach a project outside their org, regardless of this value.
      example: "1"

  responses:
    Unauthorized:
      description: Missing or invalid credential.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorMessage" }
          example: { error: "unauthorized" }
    Forbidden:
      description: Cross-org access, insufficient role, or the caller has no org yet.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorMessage" }
          example: { error: "forbidden" }
    NoOrganization:
      description: The caller (a Firebase-authenticated user) has not completed onboarding.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorMessage" }
          example: { error: "no organization" }
    NotFound:
      description: No such resource, or it belongs to a different org (cross-org ids 404, not 403).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorMessage" }
          example: { error: "not found" }
    BadRequest:
      description: Validation failure.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorMessage" }
          example: { error: "invalid date range" }
    QueryValidation:
      description: The Query IR document failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ValidationErrorEnvelope" }
          example:
            error: "invalid query"
            errors:
              - path: "analysis.kind"
                message: "unknown analysis kind \"trned\""
    PayloadTooLarge:
      description: Request body exceeded the 1 MB cap.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
          example: { status: 0, error: "payload too large" }
    InvalidToken:
      description: The project token (header or body/per-item field) did not resolve to a project.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
          example: { status: 0, error: "invalid token" }
    RateLimited:
      description: |
        Per-IP or per-project token-bucket limit exceeded. The whole batch is
        rejected atomically (nothing inserted); safe to retry unchanged after
        `Retry-After`.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
          example: { status: 0, error: "rate limited" }
    QuotaExceeded:
      description: The org's monthly event quota would be exceeded by this batch; the whole batch is rejected.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
          example: { status: 0, error: "monthly event quota exceeded" }

  schemas:
    # ---- Error envelopes -------------------------------------------------
    ErrorMessage:
      type: object
      required: [error]
      properties:
        error: { type: string }
        code: { type: string, description: "Stable machine-readable error code when the route cannot be resolved or an unexpected server error occurs." }
        message: { type: string, description: "Human-readable explanation of the failure." }
        resolution: { type: string, description: "Concrete next step an API client can take to recover." }

    ValidationErrorItem:
      type: object
      required: [path, message]
      properties:
        path: { type: string, description: "Dotted path into the Query IR document." }
        message: { type: string }
        code: { type: string, description: "Present for resolve-time errors (unknown reference, illegal position); absent for shape-validation errors." }

    ValidationErrorEnvelope:
      type: object
      required: [error, errors]
      properties:
        error: { type: string, example: "invalid query" }
        errors:
          type: array
          items: { $ref: "#/components/schemas/ValidationErrorItem" }

    IngestErrorEnvelope:
      type: object
      required: [status, error]
      properties:
        status: { type: integer, enum: [0] }
        error: { type: string }

    # ---- Ingestion ---------------------------------------------------------
    TrackedEventProperties:
      type: object
      required: [distinct_id]
      properties:
        distinct_id: { type: string }
        time: { type: integer, description: "Unix ms. Defaults to server receive time if omitted." }
        $insert_id: { type: string, description: "Client-generated dedup id." }
        token: { type: string, description: "Per-event project token; wins over the X-Cohorly-Token header for this event." }
      additionalProperties: true
      example:
        distinct_id: "user-42"
        time: 1753900800000
        $insert_id: "a1b2c3"
        $lib: "web"

    TrackedEvent:
      type: object
      required: [event, properties]
      properties:
        event: { type: string, example: "Signed Up" }
        properties: { $ref: "#/components/schemas/TrackedEventProperties" }

    TrackRequest:
      oneOf:
        - { $ref: "#/components/schemas/TrackedEvent" }
        - type: array
          maxItems: 500
          items: { $ref: "#/components/schemas/TrackedEvent" }
      description: "A single event, or a batch (max 500)."

    TrackResponse:
      type: object
      required: [status, inserted]
      properties:
        status: { type: integer, enum: [1] }
        inserted: { type: integer }
      example: { status: 1, inserted: 1 }

    EngageOp:
      type: object
      required: [distinct_id]
      properties:
        distinct_id: { type: string }
        $set: { type: object, additionalProperties: true }
        $set_once: { type: object, additionalProperties: true }
        $add: { type: object, additionalProperties: { type: number } }
        $unset: { type: array, items: { type: string } }
        $delete: { type: boolean, description: "Deletes the whole profile row." }
        token: { type: string }
      example:
        distinct_id: "user-42"
        $set: { plan: "pro", $name: "Ada Lovelace" }

    EngageRequest:
      oneOf:
        - { $ref: "#/components/schemas/EngageOp" }
        - type: array
          maxItems: 500
          items: { $ref: "#/components/schemas/EngageOp" }

    EngageResponse:
      type: object
      required: [status, applied]
      properties:
        status: { type: integer, enum: [1] }
        applied: { type: integer }
      example: { status: 1, applied: 1 }

    EngageRefusedResponse:
      type: object
      required: [status, error, applied, refused]
      description: |
        Returned with HTTP 200 (not 4xx) even though some ops were refused:
        SDK retry queues re-send an engage batch on any non-2xx, and a 4xx
        here would wedge the queue and replay non-idempotent `$add` ops.
      properties:
        status: { type: integer, enum: [0] }
        error: { type: string, example: "$delete/$unset require an owner or superadmin credential" }
        applied: { type: integer, description: "Count of ops that DID apply." }
        refused:
          type: array
          items:
            type: object
            required: [index, op]
            properties:
              index: { type: integer, description: "0-based index into the request batch." }
              op: { type: string, enum: ["$delete", "$unset"] }

    AliasRequest:
      type: object
      required: [alias, distinct_id]
      properties:
        alias: { type: string }
        distinct_id: { type: string }
        token: { type: string }
      example: { alias: "anon-9f2c", distinct_id: "user-42" }

    AliasResponse:
      type: object
      required: [status]
      properties:
        status: { type: integer, enum: [1] }
      example: { status: 1 }

    # ---- Shared query primitives -------------------------------------------
    PropertyFilter:
      type: object
      required: [key, op]
      properties:
        key: { type: string }
        op: { type: string, enum: [eq, neq, contains, set, not_set] }
        value: { type: string, description: "Required for eq/neq/contains; omitted for set/not_set." }
      example: { key: "plan", op: "eq", value: "pro" }

    Unit:
      type: string
      enum: [hour, day, week, month]

    EventRow:
      type: object
      properties:
        id: { type: integer }
        event: { type: string }
        distinct_id: { type: string }
        time: { type: integer }
        properties: { type: object, additionalProperties: true }
      example:
        id: 10245
        event: "Signed Up"
        distinct_id: "user-42"
        time: 1753900800000
        properties: { plan: "free", $city: "Lisbon" }

    EventsResult:
      type: object
      required: [events, total]
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/EventRow" }
        total: { type: integer, description: "Match count ignoring `limit`." }

    EventNameCount:
      type: object
      required: [name, count, recent_count, last_seen]
      properties:
        name: { type: string }
        count: { type: integer, description: "Lifetime volume." }
        recent_count: { type: integer, description: "Volume in the last 30 days." }
        last_seen: { type: integer, description: "Unix ms." }
      example: { name: "Signed Up", count: 8213, recent_count: 402, last_seen: 1753900800000 }

    UserProfile:
      type: object
      required: [distinct_id, properties, first_seen, last_seen, event_count]
      properties:
        distinct_id: { type: string }
        properties: { type: object, additionalProperties: true }
        first_seen: { type: integer, nullable: true }
        last_seen: { type: integer, nullable: true }
        event_count: { type: integer }

    UserDetail:
      allOf:
        - $ref: "#/components/schemas/UserProfile"
        - type: object
          properties:
            sessions:
              type: integer
              description: Sessions across this user's whole history (same inactivity rule as the sessions query).
            events:
              type: array
              items: { $ref: "#/components/schemas/EventRow" }

    StatsResult:
      type: object
      required: [events, users, profiles, event_names, events_today, sessions]
      properties:
        events: { type: integer }
        users: { type: integer, description: "Distinct distinct_ids that have sent events." }
        profiles: { type: integer }
        event_names: { type: integer, description: "Distinct event names." }
        events_today: { type: integer }
        sessions: { type: integer, description: "Sessions started in the last 7 days." }
      example: { events: 128340, users: 812, profiles: 790, event_names: 34, events_today: 1204, sessions: 96 }

    # ---- Query: v0 wrapper contracts ---------------------------------------
    SegmentationQuery:
      type: object
      required: [event, from, to, unit, type]
      properties:
        event: { type: string, description: "Event name, or the `$all_events` sentinel to match every event." }
        from: { type: string, format: date, example: "2026-07-01" }
        to: { type: string, format: date, example: "2026-07-31" }
        unit: { $ref: "#/components/schemas/Unit" }
        type: { type: string, enum: [total, unique] }
        breakdown: { type: string, description: "Property key to segment by." }
        filters:
          type: array
          maxItems: 20
          items: { $ref: "#/components/schemas/PropertyFilter" }
        projectId: { type: string }
      example:
        event: "Signed Up"
        from: "2026-07-01"
        to: "2026-07-31"
        unit: "day"
        type: "unique"

    SegmentationSeries:
      type: object
      properties:
        name: { type: string }
        data:
          type: array
          items:
            type: object
            properties:
              bucket: { type: string }
              value: { type: number }

    SegmentationResult:
      type: object
      required: [buckets, series]
      properties:
        buckets: { type: array, items: { type: string } }
        series:
          type: array
          items: { $ref: "#/components/schemas/SegmentationSeries" }
      example:
        buckets: ["2026-07-01", "2026-07-02"]
        series:
          - name: "Signed Up"
            data: [{ bucket: "2026-07-01", value: 12 }, { bucket: "2026-07-02", value: 18 }]

    FunnelQuery:
      type: object
      required: [steps, from, to, windowDays]
      properties:
        steps:
          type: array
          minItems: 2
          maxItems: 10
          items: { type: string }
        from: { type: string, format: date }
        to: { type: string, format: date }
        windowDays: { type: integer, minimum: 1, maximum: 366, default: 30, description: "Max elapsed days from step 0 for a user to still count as converting at a later step." }
        breakdown: { type: string }
        filters:
          type: array
          maxItems: 20
          items: { $ref: "#/components/schemas/PropertyFilter" }
        projectId: { type: string }
      example:
        steps: ["Viewed Pricing", "Signed Up", "Upgraded"]
        from: "2026-07-01"
        to: "2026-07-31"
        windowDays: 14

    FunnelStepResult:
      type: object
      properties:
        event: { type: string }
        count: { type: integer, description: "Unique users reaching this step." }
        conversionFromPrevious: { type: number, description: "0..1" }
        conversionFromStart: { type: number, description: "0..1" }
        droppedCount: { type: integer }
        dropoffRate: { type: number }
        convertedUserIds: { type: array, items: { type: string } }
        droppedUserIds: { type: array, items: { type: string } }
        segments:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              count: { type: integer }
              conversionFromStart: { type: number }
        meanSecondsToConvert: { type: number, nullable: true, description: "Null on step 0, and null when nobody reached this step." }
        medianSecondsToConvert: { type: number, nullable: true }
      example:
        event: "Signed Up"
        count: 340
        conversionFromPrevious: 0.62
        conversionFromStart: 0.62
        droppedCount: 208
        dropoffRate: 0.38
        convertedUserIds: ["user-42"]
        droppedUserIds: ["user-7"]
        segments: []
        meanSecondsToConvert: 145.2
        medianSecondsToConvert: 98

    FunnelResult:
      type: array
      items: { $ref: "#/components/schemas/FunnelStepResult" }

    RetentionQuery:
      type: object
      required: [bornEvent, returnEvent, from, to]
      properties:
        bornEvent: { type: string, description: "Event name, or `$all_events`." }
        returnEvent: { type: string, description: "Event name, or `$all_events`." }
        from: { type: string, format: date }
        to: { type: string, format: date }
        unit: { type: string, enum: [day, week], default: week }
        buckets: { type: integer, minimum: 1, maximum: 16, default: 8 }
        projectId: { type: string }
      example:
        bornEvent: "Signed Up"
        returnEvent: "$all_events"
        from: "2026-05-01"
        to: "2026-07-31"
        unit: "week"
        buckets: 8

    RetentionCohort:
      type: object
      properties:
        cohort: { type: string, description: "Bucket start date." }
        size: { type: integer }
        counts: { type: array, items: { type: integer }, description: "counts[0] = same-bucket return, counts[i] = bucket i." }
      example: { cohort: "2026-07-06", size: 120, counts: [120, 54, 40, 31] }

    RetentionResult:
      type: array
      items: { $ref: "#/components/schemas/RetentionCohort" }

    FlowsQuery:
      type: object
      required: [event, direction, from, to]
      properties:
        event: { type: string, description: "Anchor event name (concrete name required; no `$all_events`)." }
        direction: { type: string, enum: [after, before] }
        steps: { type: integer, minimum: 1, maximum: 5, default: 3 }
        from: { type: string, format: date }
        to: { type: string, format: date }
        limit: { type: integer, minimum: 1, maximum: 100, default: 20 }
        projectId: { type: string }
      example:
        event: "Signed Up"
        direction: "after"
        steps: 3
        from: "2026-07-01"
        to: "2026-07-31"
        limit: 20

    FlowPath:
      type: object
      properties:
        path: { type: array, items: { type: string }, description: "Ordered event names; the anchor sits at the head (after) or tail (before)." }
        count: { type: integer }

    FlowsResult:
      type: object
      required: [paths, total]
      properties:
        paths: { type: array, items: { $ref: "#/components/schemas/FlowPath" } }
        total: { type: integer, description: "Users with the anchor in range (the denominator)." }
      example:
        paths:
          - path: ["Signed Up", "Viewed Dashboard", "Upgraded"]
            count: 88
        total: 340

    SessionsQuery:
      type: object
      required: [from, to]
      properties:
        from: { type: string, format: date }
        to: { type: string, format: date }
        unit: { $ref: "#/components/schemas/Unit" }
        projectId: { type: string }
      example: { from: "2026-07-01", to: "2026-07-31", unit: "day" }

    SessionBucket:
      type: object
      properties:
        bucket: { type: string }
        sessions: { type: integer }
        avgDurationSec: { type: number }

    SessionsResult:
      type: object
      required: [series]
      properties:
        series: { type: array, items: { $ref: "#/components/schemas/SessionBucket" } }
      example:
        series:
          - { bucket: "2026-07-01", sessions: 40, avgDurationSec: 212.5 }

    MetricQuery:
      type: object
      required: [definitionId, from, to, unit]
      properties:
        definitionId: { type: integer }
        from: { type: string, format: date }
        to: { type: string, format: date }
        unit: { $ref: "#/components/schemas/Unit" }
        projectId: { type: string }
      example: { definitionId: 4, from: "2026-07-01", to: "2026-07-31", unit: "day" }

    # ---- Query: IR pipeline -------------------------------------------------
    QueryIR:
      type: object
      description: |
        A Query IR document (docs/QUERY-IR.md). Deliberately modeled as a
        free-form object here: the full grammar (analysis kinds, metrics,
        filters, comparisons, breakdowns) is specified in that document, not
        restated in this API contract. `compile(ir) -> SQL` is the one and
        only SQL producer for analyses (ADR-0002).

        `v` is the persisted-shape version, currently 2. Older versions are
        still accepted: every endpoint runs the upgrade chain first, so a
        client that predates a version bump gets its document upgraded
        (repaired where the bump made a pairing illegal) rather than a 400.
      additionalProperties: true
      # Unique users who signed up, by day, over the last 30 days. Kept a real
      # document rather than an illustrative one: `metrics` is top-level (the
      # analysis references metrics by id, which is what makes switching
      # analysis type a field swap), ids are the letters "A".."J", the date
      # range is a preset stored as a preset, and an aggregation is an object
      # with an `op`, not a string.
      example:
        v: 2
        dateRange: { kind: "preset", preset: "30d" }
        metrics:
          - { id: "A", kind: "event", event: "Signed Up", aggregation: { op: "unique" } }
        analysis: { kind: "trend", unit: "day", chart: "line" }

    QueryRequest:
      type: object
      required: [query]
      properties:
        projectId: { type: string }
        query: { $ref: "#/components/schemas/QueryIR" }

    QueryResult:
      type: object
      description: "Shape depends on `analysis.kind` (trend | funnel today; retention | flows answer 501 until their IR slice lands). See docs/QUERY-IR.md."
      additionalProperties: true

    DrilldownSelection:
      description: "Identifies the exact mark a chart drill-down was requested for."
      oneOf:
        - type: object
          required: [kind, metricId, bucket]
          properties:
            kind: { type: string, enum: [trend] }
            metricId: { type: string }
            range: { type: string, enum: [primary, comparison], default: primary }
            bucket:
              oneOf: [{ type: integer }, { type: string, enum: [total] }]
            segment: { type: string, description: "Required iff the query has a breakdown." }
        - type: object
          required: [kind, metricId]
          properties:
            kind: { type: string, enum: [trend_dropped] }
            metricId: { type: string }
            segment: { type: string }
        - type: object
          required: [kind, step, mode]
          properties:
            kind: { type: string, enum: [funnel] }
            step: { type: integer, description: "0-based index into analysis.steps." }
            mode: { type: string, enum: [reached, dropped] }
            range: { type: string, enum: [primary, comparison], default: primary }
            segment: { type: string }
      example: { kind: "trend", metricId: "m1", bucket: 3 }

    DrilldownPageRequest:
      type: object
      properties:
        limit: { type: integer, default: 50, maximum: 500 }
        offset: { type: integer, default: 0 }

    DrilldownRequest:
      type: object
      required: [query, selection]
      properties:
        projectId: { type: string }
        query: { $ref: "#/components/schemas/QueryIR" }
        selection: { $ref: "#/components/schemas/DrilldownSelection" }
        page: { $ref: "#/components/schemas/DrilldownPageRequest" }

    DrilldownPage:
      type: object
      required: [total, limit, offset, users]
      properties:
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
        users:
          type: array
          items:
            type: object
            properties:
              distinctId: { type: string }
      example: { total: 340, limit: 50, offset: 0, users: [{ distinctId: "user-42" }] }

    # ---- Projects ------------------------------------------------------------
    Project:
      type: object
      required: [id, name, token, created_at]
      properties:
        id: { type: integer }
        name: { type: string }
        token: { type: string, format: uuid }
        created_at: { type: integer }
      example: { id: 1, name: "Production", token: "1c9e2f2a-6a3e-4a54-9b7d-6a2a9d1a7b21", created_at: 1753900800000 }

    ProjectListItem:
      allOf:
        - $ref: "#/components/schemas/Project"
        - type: object
          properties:
            org_id: { type: integer, nullable: true }
            event_count: { type: integer }
            has_flag_secret:
              type: boolean
              description: "Whether a flag secret has been minted for this project. The secret value itself is returned only by POST /api/projects/{id}/flag-secret, once."


    # ---- Saved reports / boards -----------------------------------------------
    ReportType:
      type: string
      enum: [segmentation, funnel, retention, flows, metric]

    SavedReport:
      type: object
      required: [id, project_id, created_by, name, description, type, query, created_at, updated_at]
      properties:
        id: { type: integer }
        project_id: { type: integer }
        created_by: { type: integer, nullable: true }
        name: { type: string }
        description: { type: string, nullable: true, description: "The question the report answers; distinct from `name`." }
        type: { $ref: "#/components/schemas/ReportType" }
        query:
          type: object
          additionalProperties: true
          description: "Exact body sent to the matching POST /api/query/* endpoint, minus projectId. May be a v0 bag or a v1 Query IR document (any object carrying `v`)."
        created_at: { type: integer }
        updated_at: { type: integer }
        queryV1:
          type: object
          additionalProperties: true
          description: "GET only: the query upgraded to v1 IR (docs/QUERY-IR.md), when upgradable."
        queryWarnings:
          type: array
          items: { type: string }
          description: "GET only: non-fatal repairs applied during the v0 -> v1 upgrade."
      example:
        id: 12
        project_id: 1
        created_by: 3
        name: "Weekly signups"
        description: "Unique users who signed up this week"
        type: "segmentation"
        query: { event: "Signed Up", from: "2026-07-01", to: "2026-07-07", unit: "day", type: "unique" }
        created_at: 1753900800000
        updated_at: 1753900800000

    BoardCardHeight:
      type: string
      enum: [short, medium, tall]

    BoardCardSpan:
      type: integer
      enum: [4, 6, 8, 12]
      description: "Columns spanned on a 12-column grid (1/3, 1/2, 2/3, full)."

    BoardReportCard:
      type: object
      required: [id, board_id, position, span, height, card_type, report_id, text]
      properties:
        id: { type: integer }
        board_id: { type: integer }
        position: { type: integer }
        span: { $ref: "#/components/schemas/BoardCardSpan" }
        height: { $ref: "#/components/schemas/BoardCardHeight" }
        card_type: { type: string, enum: [report] }
        report_id: { type: integer }
        text: { type: "null" }
        report: { $ref: "#/components/schemas/SavedReport" }

    BoardTextCard:
      type: object
      required: [id, board_id, position, span, height, card_type, report_id, text]
      properties:
        id: { type: integer }
        board_id: { type: integer }
        position: { type: integer }
        span: { $ref: "#/components/schemas/BoardCardSpan" }
        height: { $ref: "#/components/schemas/BoardCardHeight" }
        card_type: { type: string, enum: [text] }
        report_id: { type: "null" }
        text: { type: string }

    BoardCardWithReport:
      oneOf:
        - $ref: "#/components/schemas/BoardReportCard"
        - $ref: "#/components/schemas/BoardTextCard"
      discriminator:
        propertyName: card_type
        mapping:
          report: "#/components/schemas/BoardReportCard"
          text: "#/components/schemas/BoardTextCard"

    DateRangeSpec:
      description: "docs/QUERY-IR.md date range spec, reused as a board-level override."
      oneOf:
        - type: object
          required: [kind, preset]
          properties:
            kind: { type: string, enum: [preset] }
            preset: { type: string, enum: [today, yesterday, "7d", "30d", "3m", "6m", "12m"] }
        - type: object
          required: [kind, from, to]
          properties:
            kind: { type: string, enum: [absolute] }
            from: { type: string, format: date }
            to: { type: string, format: date }

    FilterSpec:
      description: "docs/QUERY-IR.md filter spec, reused as a board-level filter."
      type: object
      additionalProperties: true

    Board:
      type: object
      required: [id, project_id, created_by, name, date_range, filters, created_at, updated_at]
      properties:
        id: { type: integer }
        project_id: { type: integer }
        created_by: { type: integer, nullable: true }
        name: { type: string }
        date_range:
          oneOf:
            - { $ref: "#/components/schemas/DateRangeSpec" }
            - type: "null"
        filters:
          type: array
          items: { $ref: "#/components/schemas/FilterSpec" }
        created_at: { type: integer }
        updated_at: { type: integer }
      example:
        id: 5
        project_id: 1
        created_by: 3
        name: "Starter Board"
        date_range: null
        filters: []
        created_at: 1753900800000
        updated_at: 1753900800000

    BoardWithCards:
      allOf:
        - $ref: "#/components/schemas/Board"
        - type: object
          required: [cards]
          properties:
            cards:
              type: array
              items: { $ref: "#/components/schemas/BoardCardWithReport" }

    BoardListItem:
      allOf:
        - $ref: "#/components/schemas/Board"
        - type: object
          required: [card_count, preview]
          properties:
            card_count: { type: integer }
            preview:
              type: array
              items:
                type: object
                properties:
                  card_type: { type: string, enum: [report, text] }
                  report_type:
                    oneOf:
                      - { $ref: "#/components/schemas/ReportType" }
                      - { type: "null" }
                  label: { type: string }

    BoardCardWrite:
      type: object
      description: "Write shape for PUT /api/boards/:id `cards`. Omitted sizing falls back to defaults (span 6, height medium)."
      properties:
        cardType: { type: string, enum: [report, text], default: report }
        reportId: { type: integer, description: "Required when cardType is report." }
        text: { type: string, description: "Required when cardType is text." }
        position: { type: integer }
        span: { $ref: "#/components/schemas/BoardCardSpan" }
        height: { $ref: "#/components/schemas/BoardCardHeight" }

    # ---- Cohorts ---------------------------------------------------------
    CohortPropertyCriterion:
      type: object
      required: [kind, filter]
      properties:
        kind: { type: string, enum: [property] }
        filter: { $ref: "#/components/schemas/PropertyFilter" }

    CohortBehaviorCriterion:
      type: object
      required: [kind, event, op, count, windowDays]
      properties:
        kind: { type: string, enum: [behavior] }
        event: { type: string, description: "Event name, or the `$all_events` sentinel." }
        op: { type: string, enum: [gte, lte, gt, lt, eq] }
        count: { type: integer, minimum: 0, maximum: 1000000 }
        windowDays: { type: integer, minimum: 0, maximum: 366 }

    CohortCriterion:
      oneOf:
        - { $ref: "#/components/schemas/CohortPropertyCriterion" }
        - { $ref: "#/components/schemas/CohortBehaviorCriterion" }
      discriminator:
        propertyName: kind
        mapping:
          property: "#/components/schemas/CohortPropertyCriterion"
          behavior: "#/components/schemas/CohortBehaviorCriterion"

    CohortDefinition:
      type: object
      required: [op, criteria]
      properties:
        op: { type: string, enum: [and, or] }
        criteria:
          type: array
          maxItems: 20
          items: { $ref: "#/components/schemas/CohortCriterion" }
      example:
        op: "and"
        criteria:
          - { kind: "behavior", event: "Signed Up", op: "gte", count: 1, windowDays: 30 }
          - { kind: "property", filter: { key: "plan", op: "eq", value: "pro" } }

    Cohort:
      type: object
      required: [id, project_id, created_by, name, description, definition, size, created_at, updated_at]
      properties:
        id: { type: integer }
        project_id: { type: integer }
        created_by: { type: integer, nullable: true }
        name: { type: string }
        description: { type: string, nullable: true }
        definition: { $ref: "#/components/schemas/CohortDefinition" }
        size: { type: integer, description: "Current member count, computed on read." }
        created_at: { type: integer }
        updated_at: { type: integer }
      example:
        id: 7
        project_id: 1
        created_by: 3
        name: "Power Users"
        description: null
        definition:
          op: "and"
          criteria: [{ kind: "behavior", event: "$all_events", op: "gte", count: 10, windowDays: 30 }]
        size: 214
        created_at: 1753900800000
        updated_at: 1753900800000

    CohortPreview:
      type: object
      required: [count, sample]
      properties:
        count: { type: integer }
        sample: { type: array, items: { type: string }, maxItems: 10 }
      example: { count: 214, sample: ["user-42", "user-7"] }

    CohortMembers:
      type: object
      required: [members]
      properties:
        members: { type: array, items: { type: string } }

    # ---- Feature flags -------------------------------------------------------
    FlagType:
      type: string
      enum: [release, experiment, operational, kill_switch, permission]
      description: "Purely descriptive; never changes evaluation. Default \"release\"."

    FlagVariant:
      type: object
      required: [key, rolloutPct]
      properties:
        key: { type: string, minLength: 1, maxLength: 200 }
        payload: { description: "Arbitrary JSON payload, optional." }
        rolloutPct: { type: integer, minimum: 0, maximum: 100, description: "Across a flag's variants, must sum to exactly 100 when the list is non-empty." }

    FlagRule:
      type: object
      required: [rolloutPct]
      properties:
        cohortId: { type: integer, description: "Cohort in the same project; the rule matches only cohort members." }
        distinctIds:
          type: array
          maxItems: 50
          items: { type: string, minLength: 1, maxLength: 255 }
          description: "Override - explicit allow-list of Distinct IDs (testing/QA tool). Non-empty requires rolloutPct 100."
        rolloutPct: { type: integer, minimum: 0, maximum: 100 }
        variant: { type: string, description: "Must name an existing variant key." }

    FeatureFlag:
      type: object
      required: [id, project_id, created_by, key, name, description, active, type, variants, rules, archived_at, created_at, updated_at]
      properties:
        id: { type: integer }
        project_id: { type: integer }
        created_by: { type: integer, nullable: true }
        key: { type: string, pattern: "^[a-z0-9][a-z0-9_-]{0,199}$" }
        name: { type: string }
        description: { type: string, nullable: true }
        active: { type: boolean }
        type: { $ref: "#/components/schemas/FlagType" }
        variants:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/FlagVariant" }
        rules:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/FlagRule" }
          description: "Ordered; first match wins."
        archived_at:
          type: integer
          nullable: true
          description: "Non-null = archived. An archived flag is absent from POST /flags/evaluate and excluded from GET /api/flags and GET /flags/local-evaluation by default."
        created_at: { type: integer }
        updated_at: { type: integer }
      example:
        id: 3
        project_id: 1
        created_by: 3
        key: "new-checkout"
        name: "New checkout"
        description: null
        active: true
        type: "release"
        variants: []
        rules: [{ rolloutPct: 100 }]
        archived_at: null
        created_at: 1753900800000
        updated_at: 1753900800000

    FlagEvent:
      type: object
      required: [id, project_id, flag_id, actor_user_id, action, before, after, created_at]
      description: "One row of a flag's audit trail (GET /api/flags/{id}/events), newest first."
      properties:
        id: { type: integer }
        project_id: { type: integer }
        flag_id: { type: integer }
        actor_user_id: { type: integer, nullable: true, description: "Null for the superadmin key." }
        action: { type: string, enum: [created, updated, activated, deactivated, archived, revived] }
        before: { allOf: [{ $ref: "#/components/schemas/FeatureFlag" }], nullable: true }
        after: { allOf: [{ $ref: "#/components/schemas/FeatureFlag" }], nullable: true }
        created_at: { type: integer }
      example:
        id: 12
        project_id: 1
        flag_id: 3
        actor_user_id: 7
        action: "activated"
        before: null
        after: null
        created_at: 1753900800000

    FlagDefinition:
      type: object
      required: [key, name, active, variants, rules, localEvaluable]
      description: |
        One flag as served by `GET /flags/local-evaluation`: what an SDK needs
        to evaluate in-process, and nothing else - no `description`, no
        `created_by`, no ids.
      properties:
        key: { type: string }
        name: { type: string }
        active: { type: boolean }
        variants:
          type: array
          items: { $ref: "#/components/schemas/FlagVariant" }
        rules:
          type: array
          items: { $ref: "#/components/schemas/FlagRule" }
        localEvaluable:
          type: boolean
          description: |
            False when any rule references a cohort. Cohort membership is a
            server-side query over events and profiles, so the SDK must fall
            back to `POST /flags/evaluate` for that flag rather than silently
            failing to match (ADR-0011).

    FlagDefinitionsResponse:
      type: object
      required: [flags]
      properties:
        flags:
          type: array
          items: { $ref: "#/components/schemas/FlagDefinition" }
      example:
        flags:
          - key: "new-checkout"
            name: "New checkout"
            active: true
            variants: []
            rules: [{ rolloutPct: 100 }]
            localEvaluable: true

    FlagSecretResponse:
      type: object
      required: [secret]
      properties:
        secret: { type: string, format: uuid }
      example: { secret: "0f1d5a9c-2b7e-4a10-9c3d-8b4e1f6a2c55" }

    FlagEvaluateRequest:
      type: object
      required: [distinct_id]
      properties:
        distinct_id: { type: string }
        flag_keys:
          type: array
          items: { type: string }
          description: "Filter; absent = all flags of the project."
        token: { type: string, description: "Project token (alternative to the X-Cohorly-Token header)." }

    FlagEvaluationResult:
      type: object
      required: [enabled, variant, payload, reason]
      properties:
        enabled: { type: boolean }
        variant: { type: string, nullable: true }
        payload: { nullable: true }
        reason: { type: string, description: "\"inactive\" | \"no_match\" | \"rule:<i>\" | \"archived\". Inactive flags report \"inactive\"; archived flags are absent from /flags/evaluate and report \"archived\" only from the Playground preview." }

    FlagEvaluateResponse:
      type: object
      required: [flags]
      properties:
        flags:
          type: object
          additionalProperties: { $ref: "#/components/schemas/FlagEvaluationResult" }
      example:
        flags:
          new-checkout: { enabled: true, variant: null, payload: null, reason: "rule:0" }

    FlagPreviewRequest:
      type: object
      required: [distinctId]
      properties:
        distinctId: { type: string, description: "The Playground/preview identity to evaluate this one flag for." }
      example: { distinctId: "user-42" }

    # ---- Reusable definitions ----------------------------------------------
    ReusableDefinitionKind:
      type: string
      enum: [derived_event, calculated_metric]

    DerivedEventRule:
      type: object
      required: [events]
      properties:
        events:
          type: array
          minItems: 1
          maxItems: 100
          items: { type: string }

    CalculatedMetricRule:
      type: object
      required: [leftEvent, rightEvent, leftType, rightType, operator]
      properties:
        leftEvent: { type: string }
        rightEvent: { type: string }
        leftType: { type: string, enum: [total, unique] }
        rightType: { type: string, enum: [total, unique] }
        operator: { type: string, enum: [sum, difference, ratio] }

    ReusableDefinition:
      type: object
      required: [id, project_id, kind, name, purpose, owner, interpretation, definition, version, impacted_reports, created_at, updated_at]
      properties:
        id: { type: integer }
        project_id: { type: integer }
        kind: { $ref: "#/components/schemas/ReusableDefinitionKind" }
        name: { type: string }
        purpose: { type: string }
        owner: { type: string }
        interpretation: { type: string }
        definition:
          oneOf:
            - { $ref: "#/components/schemas/DerivedEventRule" }
            - { $ref: "#/components/schemas/CalculatedMetricRule" }
        version: { type: integer }
        impacted_reports:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              type: { $ref: "#/components/schemas/ReportType" }
        created_at: { type: integer }
        updated_at: { type: integer }
      example:
        id: 4
        project_id: 1
        kind: "derived_event"
        name: "Any Purchase"
        purpose: "Union of every checkout event across platforms"
        owner: "growth"
        interpretation: "Counts as one event whenever a user completes any purchase flow"
        definition: { events: ["Web Checkout", "iOS Purchase", "Android Purchase"] }
        version: 1
        impacted_reports: []
        created_at: 1753900800000
        updated_at: 1753900800000

    # ---- Lexicon -------------------------------------------------------------
    LexiconEntityType:
      type: string
      enum: [event, event_property, profile_property]

    AdoptionStatus:
      type: string
      enum: [planned, instrumenting, live]

    ReviewStatus:
      type: string
      enum: [draft, approved]

    LexiconEntry:
      type: object
      required: [id, project_id, entity_type, name, display_name, description, tags, hidden, dropped, merged_into, verified, updated_at]
      properties:
        id: { type: integer }
        project_id: { type: integer }
        entity_type: { $ref: "#/components/schemas/LexiconEntityType" }
        name: { type: string, description: "Raw name in the data; immutable." }
        display_name: { type: string, nullable: true }
        description: { type: string, nullable: true }
        tags: { type: array, items: { type: string } }
        hidden: { type: boolean, description: "Excluded from pickers; still ingested." }
        dropped: { type: boolean, description: "New data rejected at ingest; history kept." }
        merged_into: { type: string, nullable: true, description: "Events only; query-time aliasing." }
        verified: { type: boolean }
        objective: { type: string, nullable: true }
        owner: { type: string, nullable: true }
        adoption_status: { $ref: "#/components/schemas/AdoptionStatus" }
        review_status: { $ref: "#/components/schemas/ReviewStatus" }
        updated_at: { type: integer }
      example:
        id: 9
        project_id: 1
        entity_type: "event"
        name: "signed_up"
        display_name: "Signed Up"
        description: "Fires once per account on completed signup."
        tags: ["core"]
        hidden: false
        dropped: false
        merged_into: null
        verified: true
        objective: "Track activation"
        owner: "growth"
        adoption_status: "live"
        review_status: "approved"
        updated_at: 1753900800000

    LexiconPatch:
      type: object
      description: "`dropped` and `mergedInto` require the org owner role (403 for a member); other fields any member may set."
      properties:
        displayName: { type: string, nullable: true }
        description: { type: string, nullable: true }
        tags: { type: array, items: { type: string } }
        hidden: { type: boolean }
        dropped: { type: boolean }
        mergedInto: { type: string, nullable: true }
        verified: { type: boolean }
        objective: { type: string, nullable: true }
        owner: { type: string, nullable: true }
        adoptionStatus: { $ref: "#/components/schemas/AdoptionStatus" }
        reviewStatus: { $ref: "#/components/schemas/ReviewStatus" }
      example: { displayName: "Signed Up", verified: true }

    LexiconUsage:
      type: object
      description: "Map keyed by raw event name (no merge aliasing)."
      additionalProperties:
        type: object
        properties:
          reports: { type: integer }
          cohorts: { type: integer }
      example:
        signed_up: { reports: 3, cohorts: 1 }

    RenameSuggestion:
      type: object
      required: [retired, retired_last_seen, retired_count, suggested, suggested_first_seen, suggested_count]
      properties:
        retired: { type: string }
        retired_last_seen: { type: integer }
        retired_count: { type: integer }
        suggested: { type: string }
        suggested_first_seen: { type: integer }
        suggested_count: { type: integer }
      example:
        retired: "Signed up"
        retired_last_seen: 1751500800000
        retired_count: 900
        suggested: "signed_up"
        suggested_first_seen: 1751600800000
        suggested_count: 120

    CollectionHealthDay:
      type: object
      properties:
        date: { type: string, format: date }
        volume: { type: integer }

    CollectionHealthEvent:
      type: object
      properties:
        name: { type: string }
        last_seen: { type: integer, nullable: true }
        current_volume: { type: integer }
        previous_volume: { type: integer }
        change_pct: { type: number, nullable: true }
        status: { type: string, enum: [healthy, delayed, missing, unplanned] }
        impact: { type: string }

    CollectionSource:
      type: object
      properties:
        source: { type: string }
        version: { type: string, nullable: true }
        volume: { type: integer }
        last_seen: { type: integer }
        status: { type: string, enum: [current, outdated, incompatible, unknown] }
        issues: { type: array, items: { type: string } }
        delayed_events: { type: array, items: { type: string } }
        unplanned_events: { type: array, items: { type: string } }
        guidance: { type: string }

    CollectionHealth:
      type: object
      required: [generated_at, latest_event_at, current_volume, previous_volume, continuity_pct, planned_events, history, events, sources, renames]
      properties:
        generated_at: { type: integer }
        latest_event_at: { type: integer, nullable: true }
        current_volume: { type: integer }
        previous_volume: { type: integer }
        continuity_pct: { type: number }
        planned_events: { type: integer, description: "0 means there is no approved plan; every \"unplanned\" finding is then vacuous." }
        history: { type: array, items: { $ref: "#/components/schemas/CollectionHealthDay" } }
        events: { type: array, items: { $ref: "#/components/schemas/CollectionHealthEvent" } }
        sources: { type: array, items: { $ref: "#/components/schemas/CollectionSource" } }
        renames: { type: array, items: { $ref: "#/components/schemas/RenameSuggestion" } }

    # ---- Annotations -------------------------------------------------------
    AnnotationCategory:
      type: string
      enum: [release, campaign, incident, other]

    Annotation:
      type: object
      required: [id, project_id, created_by, author_name, category, context, starts_on, ends_on, visible, created_at, updated_at]
      properties:
        id: { type: integer }
        project_id: { type: integer }
        created_by: { type: integer, nullable: true }
        author_name: { type: string }
        category: { $ref: "#/components/schemas/AnnotationCategory" }
        context: { type: string }
        starts_on: { type: string, format: date }
        ends_on: { type: string, format: date }
        visible: { type: boolean }
        created_at: { type: integer }
        updated_at: { type: integer }
      example:
        id: 3
        project_id: 1
        created_by: 3
        author_name: "Ada Lovelace"
        category: "release"
        context: "Shipped checkout redesign"
        starts_on: "2026-07-10"
        ends_on: "2026-07-10"
        visible: true
        created_at: 1753900800000
        updated_at: 1753900800000

    # ---- Privacy -------------------------------------------------------------
    PrivacySubject:
      type: object
      required: [requested_id, distinct_id, aliases, profile, event_count, first_seen, last_seen]
      properties:
        requested_id: { type: string }
        distinct_id: { type: string, description: "Resolved canonical id (aliases collapse to one identity)." }
        aliases: { type: array, items: { type: string } }
        profile: { type: object, additionalProperties: true }
        event_count: { type: integer }
        first_seen: { type: integer, nullable: true }
        last_seen: { type: integer, nullable: true }

    PrivacyExport:
      allOf:
        - $ref: "#/components/schemas/PrivacySubject"
        - type: object
          required: [events, exported_at]
          properties:
            events:
              type: array
              items:
                type: object
                properties:
                  event: { type: string }
                  time: { type: integer }
                  insert_id: { type: string }
                  properties: { type: object, additionalProperties: true }
            exported_at: { type: integer }

    PrivacyRequest:
      type: object
      required: [id, project_id, action, subject_ref, responsible, result, requested_at, completed_at]
      properties:
        id: { type: integer }
        project_id: { type: integer }
        action: { type: string, enum: [export, delete] }
        subject_ref: { type: string, description: "sha256(projectId:distinctId); no raw identity retained in the audit log." }
        responsible: { type: string }
        result: { type: object, additionalProperties: { type: integer } }
        requested_at: { type: integer }
        completed_at: { type: integer }

    PrivacyDeleteResult:
      type: object
      required: [ok, deleted]
      properties:
        ok: { type: boolean }
        deleted: { type: object, additionalProperties: { type: integer } }

    # ---- Account / org -------------------------------------------------------
    AppUser:
      type: object
      required: [id, firebase_uid, email, display_name, created_at]
      properties:
        id: { type: integer }
        firebase_uid: { type: string }
        email: { type: string, format: email }
        display_name: { type: string, nullable: true }
        created_at: { type: integer }

    OrgRole:
      type: string
      enum: [owner, member]

    Org:
      type: object
      required: [id, name, plan, quota_events_month, quota_projects, retention_months, created_at]
      properties:
        id: { type: integer }
        name: { type: string }
        plan: { type: string, example: "free" }
        quota_events_month: { type: integer, nullable: true, description: "NULL = plan default." }
        quota_projects: { type: integer, nullable: true }
        retention_months: { type: integer, nullable: true }
        created_at: { type: integer }

    OrgMember:
      type: object
      required: [user_id, email, display_name, role, created_at]
      properties:
        user_id: { type: integer }
        email: { type: string, format: email }
        display_name: { type: string, nullable: true }
        role: { $ref: "#/components/schemas/OrgRole" }
        created_at: { type: integer }

    PlanQuotas:
      type: object
      required: [eventsPerMonth, maxProjects, retentionMonths]
      properties:
        eventsPerMonth: { type: integer }
        maxProjects: { type: integer }
        retentionMonths: { type: integer }

    Usage:
      type: object
      required: [month, periodStartsOn, periodEndsOn, elapsedDays, periodDays, events, eventLimit, projectedEvents, projects, projectLimit, retentionMonths]
      properties:
        month: { type: string, example: "2026-07" }
        periodStartsOn: { type: string, format: date }
        periodEndsOn: { type: string, format: date }
        elapsedDays: { type: integer }
        periodDays: { type: integer }
        events: { type: integer }
        eventLimit: { type: integer }
        projectedEvents: { type: integer, description: "Linear projection of `events` to the end of the period." }
        projects: { type: integer }
        projectLimit: { type: integer }
        retentionMonths: { type: integer }

    Me:
      type: object
      required: [user, org, role, quotas, usage]
      properties:
        user: { oneOf: [{ $ref: "#/components/schemas/AppUser" }, { type: "null" }] }
        org: { oneOf: [{ $ref: "#/components/schemas/Org" }, { type: "null" }] }
        role: { oneOf: [{ $ref: "#/components/schemas/OrgRole" }, { type: "null" }] }
        quotas: { oneOf: [{ $ref: "#/components/schemas/PlanQuotas" }, { type: "null" }] }
        usage: { oneOf: [{ $ref: "#/components/schemas/Usage" }, { type: "null" }] }

    MeSuperadmin:
      type: object
      required: [user, superadmin]
      properties:
        user: { type: "null" }
        superadmin: { type: boolean, enum: [true] }

    # ---- Admin (backoffice) --------------------------------------------------
    Invite:
      type: object
      required: [id, code, note, max_uses, used_count, created_at, expires_at, revoked_at]
      properties:
        id: { type: integer }
        code: { type: string, description: "8-char base32-ish random code." }
        note: { type: string, nullable: true }
        max_uses: { type: integer }
        used_count: { type: integer }
        created_at: { type: integer }
        expires_at: { type: integer, nullable: true }
        revoked_at: { type: integer, nullable: true }
      example:
        id: 2
        code: "K3F9QZ2A"
        note: "Beta batch 1"
        max_uses: 5
        used_count: 1
        created_at: 1753900800000
        expires_at: null
        revoked_at: null

    AdminStats:
      type: object
      required: [orgs, users, projects, invites_active, events_this_month]
      properties:
        orgs: { type: integer }
        users: { type: integer }
        projects: { type: integer }
        invites_active: { type: integer }
        events_this_month: { type: integer }

    AdminUser:
      allOf:
        - $ref: "#/components/schemas/AppUser"
        - type: object
          properties:
            org_id: { type: integer, nullable: true }
            org_name: { type: string, nullable: true }
            role: { oneOf: [{ $ref: "#/components/schemas/OrgRole" }, { type: "null" }] }

    AdminOrg:
      allOf:
        - $ref: "#/components/schemas/Org"
        - type: object
          properties:
            member_count: { type: integer }
            project_count: { type: integer }
            events_this_month: { type: integer }

    AdminOrgDetail:
      type: object
      required: [org, members, projects, usage]
      properties:
        org: { $ref: "#/components/schemas/Org" }
        members: { type: array, items: { $ref: "#/components/schemas/OrgMember" } }
        projects:
          type: array
          items:
            allOf:
              - $ref: "#/components/schemas/Project"
              - type: object
                properties:
                  event_count: { type: integer }
        usage: { $ref: "#/components/schemas/Usage" }

    AdminOrgPatch:
      type: object
      description: "Any field may be omitted (left unchanged); an explicit `null` on a quota field clears the org-level override back to the plan default."
      properties:
        plan: { type: string }
        quota_events_month: { type: integer, nullable: true }
        quota_projects: { type: integer, nullable: true }
        retention_months: { type: integer, nullable: true }

    InviteCreate:
      type: object
      properties:
        note: { type: string, nullable: true }
        maxUses: { type: integer }
        expiresAt: { type: integer, nullable: true }
      example: { note: "Beta batch 1", maxUses: 5 }

    # ---- Health ------------------------------------------------------------
    RootStatus:
      type: object
      required: [name, ok]
      properties:
        name: { type: string, example: "cohorly-server" }
        ok: { type: boolean }

    HealthStatus:
      type: object
      required: [ok, db]
      properties:
        ok: { type: boolean }
        db: { type: boolean }
      example: { ok: true, db: true }

    HealthCheck:
      type: object
      required: [checked_at, ok, latency_ms]
      properties:
        checked_at: { type: string, format: date-time }
        ok: { type: boolean }
        latency_ms: { type: number }

    StatusComponentId:
      type: string
      enum: [api, db, ingestion, dashboard, docs, status]

    StatusLevel:
      type: string
      enum: [operational, degraded, down, maintenance, unknown]

    StatusComponent:
      type: object
      required: [id, name, group, status, latest, uptime_30d, uptime_90d]
      properties:
        id: { $ref: "#/components/schemas/StatusComponentId" }
        name: { type: string }
        group: { type: string, enum: ["Core Platform", "Web"] }
        status: { $ref: "#/components/schemas/StatusLevel" }
        latest: { oneOf: [{ $ref: "#/components/schemas/HealthCheck" }, { type: "null" }] }
        uptime_30d:
          oneOf: [{ type: number }, { type: "null" }]
          description: 0..100, null when there is no data at all.
        uptime_90d:
          oneOf: [{ type: number }, { type: "null" }]
          description: 0..100, null when there is no data at all.

    IncidentKind:
      type: string
      enum: [incident, maintenance]

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

    IncidentStatus:
      type: string
      description: |
        `investigating|identified|monitoring|resolved` for kind=incident;
        `scheduled|in_progress|completed` for kind=maintenance.
      enum: [investigating, identified, monitoring, resolved, scheduled, in_progress, completed]

    IncidentUpdate:
      type: object
      required: [id, status, body, created_at]
      properties:
        id: { type: integer }
        status: { $ref: "#/components/schemas/IncidentStatus" }
        body: { type: string }
        created_at: { type: string, format: date-time }

    Incident:
      type: object
      required:
        [id, kind, title, status, impact, components, started_at, resolved_at, scheduled_start, scheduled_end, updates]
      properties:
        id: { type: integer }
        kind: { $ref: "#/components/schemas/IncidentKind" }
        title: { type: string }
        status: { $ref: "#/components/schemas/IncidentStatus" }
        impact: { $ref: "#/components/schemas/IncidentImpact" }
        components: { type: array, items: { $ref: "#/components/schemas/StatusComponentId" } }
        started_at: { type: string, format: date-time }
        resolved_at: { oneOf: [{ type: string, format: date-time }, { type: "null" }] }
        scheduled_start: { oneOf: [{ type: string, format: date-time }, { type: "null" }] }
        scheduled_end: { oneOf: [{ type: string, format: date-time }, { type: "null" }] }
        updates:
          type: array
          description: Oldest first.
          items: { $ref: "#/components/schemas/IncidentUpdate" }

    IncidentCreate:
      type: object
      required: [title, status]
      properties:
        kind: { $ref: "#/components/schemas/IncidentKind" }
        title: { type: string }
        status: { $ref: "#/components/schemas/IncidentStatus" }
        impact: { $ref: "#/components/schemas/IncidentImpact" }
        components: { type: array, items: { $ref: "#/components/schemas/StatusComponentId" } }
        started_at: { type: string, format: date-time }
        scheduled_start: { type: string, format: date-time }
        scheduled_end: { type: string, format: date-time }
        body:
          type: string
          description: When given, becomes the first IncidentUpdate.
      example: { title: "Elevated ingestion latency", status: "investigating", impact: "minor", components: ["ingestion"] }

    IncidentPatch:
      type: object
      description: Any field may be omitted (left unchanged). `kind` is not patchable.
      properties:
        title: { type: string }
        status: { $ref: "#/components/schemas/IncidentStatus" }
        impact: { $ref: "#/components/schemas/IncidentImpact" }
        components: { type: array, items: { $ref: "#/components/schemas/StatusComponentId" } }
        started_at: { type: string, format: date-time }
        resolved_at: { oneOf: [{ type: string, format: date-time }, { type: "null" }] }
        scheduled_start: { oneOf: [{ type: string, format: date-time }, { type: "null" }] }
        scheduled_end: { oneOf: [{ type: string, format: date-time }, { type: "null" }] }

    IncidentUpdateCreate:
      type: object
      required: [status, body]
      properties:
        status: { $ref: "#/components/schemas/IncidentStatus" }
        body: { type: string }

    StatusResult:
      type: object
      required: [ok, db, uptime_seconds, latest, components, active_incidents, scheduled_maintenance]
      properties:
        ok:
          type: boolean
          description: API + DB only. Unchanged meaning - a Vercel blip must NOT flip it.
        db: { type: boolean }
        uptime_seconds: { type: integer }
        latest:
          oneOf: [{ $ref: "#/components/schemas/HealthCheck" }, { type: "null" }]
          description: The db check, unchanged.
        components: { type: array, items: { $ref: "#/components/schemas/StatusComponent" } }
        active_incidents: { type: array, items: { $ref: "#/components/schemas/Incident" } }
        scheduled_maintenance: { type: array, items: { $ref: "#/components/schemas/Incident" } }

    StatusHistoryDay:
      type: object
      properties:
        date: { type: string, format: date }
        total: { type: integer }
        ok_count: { type: integer }
        uptime_pct: { type: number }
        avg_latency_ms: { type: number }

    StatusHistoryAll:
      type: object
      required: [days, components]
      properties:
        days: { type: integer }
        components:
          type: object
          description: Partial map keyed by StatusComponentId; only components with data in the window are present.
          additionalProperties:
            type: array
            items: { $ref: "#/components/schemas/StatusHistoryDay" }

paths:
  # =========================================================================
  # Health (unauthed)
  # =========================================================================
  /:
    get:
      tags: [Health]
      summary: Service identity
      operationId: getRoot
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RootStatus" }
              example: { name: "cohorly-server", ok: true }

  /health:
    get:
      tags: [Health]
      summary: Liveness + DB reachability
      operationId: getHealth
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }

  /status:
    get:
      tags: [Health]
      summary: Public status page snapshot
      operationId: getStatus
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusResult" }

  /status/history:
    get:
      tags: [Health]
      summary: Public status page uptime history for one component
      operationId: getStatusHistory
      security: []
      description: |
        Unchanged shape from before per-component tracking existed: `component`
        defaults to `db`, which is exactly today's meaning after the v15
        migration backfills every pre-existing row to `component = 'db'`.
      parameters:
        - name: component
          in: query
          required: false
          schema: { $ref: "#/components/schemas/StatusComponentId" }
          description: Defaults to `db`. Unknown component -> 400.
        - name: days
          in: query
          required: false
          schema: { type: integer, default: 90, minimum: 1, maximum: 90 }
          description: Clamped to [1, 90].
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/StatusHistoryDay" }
        "400":
          description: Unknown component.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }

  /status/history/all:
    get:
      tags: [Health]
      summary: Public status page uptime history for every component
      operationId: getStatusHistoryAll
      security: []
      description: One call for the whole status page history view - see GET /status/history for the per-component equivalent.
      parameters:
        - name: days
          in: query
          required: false
          schema: { type: integer, default: 90, minimum: 1, maximum: 90 }
          description: Clamped to [1, 90].
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusHistoryAll" }

  /status/incidents:
    get:
      tags: [Health]
      summary: Public incident + maintenance feed
      operationId: getStatusIncidents
      security: []
      description: |
        Newest first. Includes resolved incidents/maintenance inside the
        window, plus every unresolved one regardless of age.
      parameters:
        - name: days
          in: query
          required: false
          schema: { type: integer, default: 90, minimum: 1, maximum: 365 }
          description: Clamped to [1, 365].
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [incidents]
                properties:
                  incidents: { type: array, items: { $ref: "#/components/schemas/Incident" } }

  /signup/validate-invite:
    post:
      tags: [Health, Account]
      summary: Check an invite code before signup
      operationId: validateInvite
      security: []
      description: Rate limited by the same per-IP limiter as ingestion (100 req / 10s default).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                code: { type: string }
            example: { code: "K3F9QZ2A" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
              example: { valid: true }
        "429": { $ref: "#/components/responses/RateLimited" }

  # =========================================================================
  # Ingestion
  # =========================================================================
  /track:
    post:
      tags: [Ingestion]
      summary: Send one or more events
      operationId: track
      security: [{}, { ProjectToken: [] }]
      description: |
        Batch max 500. Per event, `properties.token` (if present) overrides the
        `X-Cohorly-Token` header. The server enriches every event before
        insert with receive time, processing time, payload size, the ingest
        Host header, and geo from the client IP - client-sent values are
        never overridden. Rejects the whole batch atomically on rate limit or
        monthly quota.
      parameters:
        - name: X-Cohorly-Token
          in: header
          required: false
          schema: { type: string }
          description: Applies to every event in the batch lacking its own `properties.token`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TrackRequest" }
            example:
              event: "Signed Up"
              properties: { distinct_id: "user-42", time: 1753900800000 }
      responses:
        "200":
          description: Accepted (fully or, for /track, only ever fully - see 401 for partial-batch invalid-token rejection).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TrackResponse" }
        "400":
          description: Malformed JSON, or batch exceeds 500 events.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
              example: { status: 0, error: "invalid json" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429":
          description: Rate limited (per-IP or per-project) or monthly quota exceeded.
          headers:
            Retry-After: { $ref: "#/components/headers/RetryAfter" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
              examples:
                rateLimited: { value: { status: 0, error: "rate limited" } }
                quotaExceeded: { value: { status: 0, error: "monthly event quota exceeded" } }

  /engage:
    post:
      tags: [Ingestion]
      summary: Update, increment, or delete user profile properties
      operationId: engage
      security: [{}, { ProjectToken: [] }]
      description: |
        Batch max 500. `$set`/`$set_once` ops receive Mixpanel-parity geo +
        `$last_seen` defaults (client-sent keys always win). Ops carrying
        `$unset` or `$delete` additionally require an `Authorization: Bearer`
        credential valid for `/api/*` (superadmin key, or a Firebase token for
        an OWNER of the org owning the target project); such ops are refused
        INDIVIDUALLY when that credential is absent - the rest of the batch
        still applies, and the response stays HTTP 200 (see
        `EngageRefusedResponse`) so SDK retry queues never wedge.
      parameters:
        - name: X-Cohorly-Token
          in: header
          required: false
          schema: { type: string }
        - name: Authorization
          in: header
          required: false
          schema: { type: string }
          description: "`Bearer <token>`. Only consulted when the batch contains a `$unset`/`$delete` op."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EngageRequest" }
      responses:
        "200":
          description: Applied (fully or partially - see `EngageRefusedResponse`).
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/EngageResponse" }
                  - { $ref: "#/components/schemas/EngageRefusedResponse" }
              examples:
                applied: { value: { status: 1, applied: 1 } }
                partiallyRefused:
                  value:
                    status: 0
                    error: "$delete/$unset require an owner or superadmin credential"
                    applied: 0
                    refused: [{ index: 0, op: "$unset" }]
        "400":
          description: Malformed JSON, or batch exceeds 500 ops.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /alias:
    post:
      tags: [Ingestion]
      summary: Alias an anonymous id to an identified distinct_id
      operationId: alias
      security: [{}, { ProjectToken: [] }]
      parameters:
        - name: X-Cohorly-Token
          in: header
          required: false
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AliasRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AliasResponse" }
        "400":
          description: Malformed JSON, or missing alias/distinct_id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
              example: { status: 0, error: "alias and distinct_id required" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /flags/evaluate:
    post:
      tags: [Flags]
      summary: Evaluate a project's feature flags for one user
      operationId: evaluateFlags
      security: [{}, { ProjectToken: [] }]
      description: |
        Project-token authed (like `/track`); SDKs call this - they never
        evaluate flag rules locally. Bucketing is on the identity cluster's
        canonical id, so a device id and the user id it was linked to
        evaluate identically.
      parameters:
        - name: X-Cohorly-Token
          in: header
          required: false
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FlagEvaluateRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FlagEvaluateResponse" }
        "400":
          description: Malformed JSON, or missing distinct_id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
              example: { status: 0, error: "distinct_id required" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /flags/local-evaluation:
    get:
      tags: [Flags]
      summary: Fetch flag definitions for local evaluation
      operationId: localEvaluationFlags
      security: [{ FlagSecret: [] }]
      description: |
        Flag-secret authed (ADR-0011): SDKs poll this (default 30s) and
        evaluate in-process, eliminating per-call latency. The project token
        and `/api/*` credentials are rejected here - the payload exposes
        Override distinct_ids and the full rollout structure, and the project
        token ships inside client bundles.

        Local evaluation hashes the RAW distinct_id, not the identity
        cluster's canonical id (`POST /flags/evaluate` does the latter), so
        pass the identified user id. Flags with `localEvaluable: false` must
        be evaluated remotely.

        Rate limited by the same per-IP limiter and per-project flags bucket
        as `/flags/evaluate`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FlagDefinitionsResponse" }
        "401":
          description: Missing, malformed, unknown or revoked flag secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
              example: { status: 0, error: "invalid flag secret" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # =========================================================================
  # Query
  # =========================================================================
  /api/query:
    post:
      tags: [Query]
      summary: Execute a Query IR document
      operationId: runQuery
      security: [{ BearerAuth: [] }]
      description: |
        The forward-path analysis endpoint (docs/QUERY-IR.md "Execution").
        Subject to the `/api/query/*` analytics rate limit (default 30
        requests / 60s per authenticated user, or per source IP for the
        superadmin key). Only `trend` and `funnel` analysis kinds execute
        today; a structurally valid document of another kind answers `501`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QueryRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QueryResult" }
        "400": { $ref: "#/components/responses/QueryValidation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429":
          description: Analytics rate limit exceeded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
              example: { status: 0, error: "rate limited" }
        "501":
          description: The analysis kind is not executable via this endpoint yet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "not implemented" }
                  kind: { type: string }
                  message: { type: string }

  /api/query/drilldown:
    post:
      tags: [Query]
      summary: The bounded list of users behind one chart mark
      operationId: runQueryDrilldown
      security: [{ BearerAuth: [] }]
      description: |
        `query` is the exact IR document that produced the chart (filters,
        breakdown, and date range are inherited, never re-specified);
        `selection` names the metric/position/segment identifying the mark.
        Subject to the same `/api/query/*` analytics rate limit as
        `POST /api/query`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DrilldownRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DrilldownPage" }
        "400":
          description: Invalid query, selection, or page.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429":
          description: Analytics rate limit exceeded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
        "501":
          description: Drill-down is not available for this analysis kind yet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  kind: { type: string }
                  message: { type: string }

  /api/query/segmentation:
    post:
      tags: [Query]
      summary: Time-bucketed event trend (v0 wrapper over the IR pipeline)
      operationId: querySegmentation
      security: [{ BearerAuth: [] }]
      description: Same request and response bytes as the pre-IR v0 endpoint; numbers now come from `compile(ir)`. Dashboard-facing; scheduled for removal once the dashboard moves to `POST /api/query`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SegmentationQuery" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SegmentationResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429":
          description: Analytics rate limit exceeded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }

  /api/query/funnel:
    post:
      tags: [Query]
      summary: Step-by-step conversion funnel (legacy v0 endpoint)
      operationId: queryFunnel
      security: [{ BearerAuth: [] }]
      description: Legacy v0 endpoint; converts into a wrapper over `POST /api/query` once the funnel IR slice fully lands.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FunnelQuery" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FunnelResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429":
          description: Analytics rate limit exceeded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }

  /api/query/retention:
    post:
      tags: [Query]
      summary: Cohort return-rate over time (legacy v0 endpoint)
      operationId: queryRetention
      security: [{ BearerAuth: [] }]
      description: Legacy v0 endpoint; converts into a wrapper over `POST /api/query` once the retention IR slice lands.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RetentionQuery" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RetentionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429":
          description: Analytics rate limit exceeded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }

  /api/query/flows:
    post:
      tags: [Query]
      summary: Top user paths around an anchor event (legacy v0 endpoint)
      operationId: queryFlows
      security: [{ BearerAuth: [] }]
      description: Legacy v0 endpoint; converts into a wrapper over `POST /api/query` once the flows IR slice lands.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FlowsQuery" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FlowsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429":
          description: Analytics rate limit exceeded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }

  /api/query/sessions:
    post:
      tags: [Query]
      summary: Session counts and average duration over time
      operationId: querySessions
      security: [{ BearerAuth: [] }]
      description: Sessions are computed at query time (30 min inactivity timeout by default, `COHORLY_SESSION_TIMEOUT_MS`) and bucketed like segmentation.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SessionsQuery" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429":
          description: Analytics rate limit exceeded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }

  /api/query/metric:
    post:
      tags: [Query, Definitions]
      summary: Time series for a saved calculated-metric/derived-event definition
      operationId: queryMetric
      security: [{ BearerAuth: [] }]
      description: |
        Legacy v0 endpoint over a `ReusableDefinition`. Absorbed by the
        `definition` metric kind once the builder-on-IR slice lands, at which
        point callers move to `POST /api/query` with
        `{ kind: "definition", definitionId }`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MetricQuery" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SegmentationResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Definition not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "metric not found" }
        "429":
          description: Analytics rate limit exceeded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }

  # =========================================================================
  # Data
  # =========================================================================
  /api/events:
    get:
      tags: [Data]
      summary: Raw event stream
      operationId: listEvents
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 1000 }
        - name: event
          in: query
          schema: { type: string }
          description: Applies lexicon merge aliasing (matches the canonical event plus merged children).
        - name: distinct_id
          in: query
          schema: { type: string }
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
        - name: search
          in: query
          schema: { type: string }
          description: Case-insensitive substring match on the event name.
        - name: filters
          in: query
          schema: { type: string }
          description: "JSON-encoded PropertyFilter[]. Invalid JSON or shape -> 400 `invalid filters`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventsResult" }
        "400":
          description: Invalid filters JSON/shape, or invalid date range.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "invalid filters" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/events/names:
    get:
      tags: [Data]
      summary: Observed event names with lifetime and recent volume
      operationId: listEventNames
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: raw
          in: query
          schema: { type: string, enum: ["1"] }
          description: Bypass lexicon governance (merge collapsing, hidden exclusion) and return observed names untouched.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/EventNameCount" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/events/properties:
    get:
      tags: [Data]
      summary: Observed property keys for one event
      operationId: listEventProperties
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: event
          in: query
          required: true
          schema: { type: string }
        - name: raw
          in: query
          schema: { type: string, enum: ["1"] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { type: string } }
              example: ["plan", "$city", "$browser"]
        "400":
          description: Missing `event`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "event required" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/events/renames:
    get:
      tags: [Data]
      summary: Silent events paired with a live rename candidate
      operationId: listRenameSuggestions
      security: [{ BearerAuth: [] }]
      description: Events silent 48h+ whose name normalizes (case/whitespace/separators stripped) to a name still arriving. Normalization equality only, never fuzzy matching.
      parameters:
        - $ref: "#/components/parameters/projectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RenameSuggestion" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/collection-health:
    get:
      tags: [Data]
      summary: Collection freshness/continuity diagnostic
      operationId: getCollectionHealth
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollectionHealth" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/profiles/properties:
    get:
      tags: [Data]
      summary: Observed profile property names (sampled)
      operationId: listProfileProperties
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { type: string } }
              example: ["plan", "$city", "$name"]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/users:
    get:
      tags: [Data]
      summary: List user profiles
      operationId: listUsers
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 500 }
        - name: search
          in: query
          schema: { type: string }
          description: Substring match on distinct_id or the serialized properties.
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
          description: Rows to skip, for paging. Profiles are ordered by most recently updated first (ties by distinct_id), so a profile updated between two page requests can move to an earlier page. Non-numeric values fall back to the default; fractions are truncated.
        - name: withTotal
          in: query
          schema: { type: string, enum: ["1"] }
          description: When `1`, the body is `{ users, total }` instead of a bare array; `total` ignores `limit` and `offset`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                oneOf:
                  - type: array
                    items: { $ref: "#/components/schemas/UserProfile" }
                  - type: object
                    required: [users, total]
                    properties:
                      users:
                        type: array
                        items: { $ref: "#/components/schemas/UserProfile" }
                      total: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/users/{distinctId}:
    get:
      tags: [Data]
      summary: One user's profile plus their last 100 events
      operationId: getUser
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: distinctId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/stats:
    get:
      tags: [Data]
      summary: Project-level counters
      operationId: getStats
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatsResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # =========================================================================
  # Projects
  # =========================================================================
  /api/projects:
    get:
      tags: [Projects]
      summary: List projects
      operationId: listProjects
      security: [{ BearerAuth: [] }]
      description: A superadmin credential sees every project across every org; a user credential sees only their own org's projects.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ProjectListItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NoOrganization" }
    post:
      tags: [Projects]
      summary: Create a project
      operationId: createProject
      security: [{ BearerAuth: [] }]
      description: Creates in the caller's org (superadmin creates in the bootstrap org). Auto-seeds a Starter Board (best-effort - a seeding failure never fails project creation).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
              example: { name: "Production" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "400":
          description: Missing name.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "name required" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NoOrganization" }
        "409":
          description: Project limit reached, or the name is taken within this org.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              examples:
                limit: { value: { error: "project limit reached" } }
                nameTaken: { value: { error: "name taken" } }

  /api/projects/{id}:
    delete:
      tags: [Projects]
      summary: Delete a project
      operationId: deleteProject
      security: [{ BearerAuth: [] }]
      description: Requires the org owner role (or superadmin). Refused if this is the org's last remaining project.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, enum: [true] } }
        "400":
          description: Invalid id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: This is the org's last project; deletion refused.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "cannot delete last project" }

  /api/projects/{id}/flag-secret:
    post:
      tags: [Projects]
      summary: Mint or rotate the project's flag secret
      operationId: rotateFlagSecret
      security: [{ BearerAuth: [] }]
      description: |
        Creates the project's flag secret, or replaces an existing one - the
        old secret stops working immediately. This is the ONLY place the full
        value is ever returned; afterwards `GET /api/projects` reports only
        `has_flag_secret`. Requires the org owner role (or superadmin), the
        same gate as project deletion: it is a credential, not a setting.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FlagSecretResponse" }
        "400":
          description: Invalid id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: No such project, or it belongs to another org (never 403).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "not found" }
    delete:
      tags: [Projects]
      summary: Revoke the project's flag secret
      operationId: revokeFlagSecret
      security: [{ BearerAuth: [] }]
      description: "Revokes without minting a replacement; local evaluation then 401s for that project. Same auth as the POST. Idempotent - revoking when none exists still answers ok: true."
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, enum: [true] } }
        "400":
          description: Invalid id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: No such project, or it belongs to another org (never 403).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "not found" }

  # =========================================================================
  # Reports
  # =========================================================================
  /api/reports:
    get:
      tags: [Reports]
      summary: List saved reports
      operationId: listReports
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SavedReport" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Reports]
      summary: Save a report
      operationId: createReport
      security: [{ BearerAuth: [] }]
      description: |
        `query` must be a valid v1 Query IR document (any object carrying `v`)
        or a v0 bag the upgrader recognises for `type`; anything else is a
        `400` with path-typed validation errors.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, type, query]
              properties:
                projectId: { type: string }
                name: { type: string }
                description: { type: string, nullable: true }
                type: { $ref: "#/components/schemas/ReportType" }
                query: { type: object, additionalProperties: true }
              example:
                name: "Weekly signups"
                type: "segmentation"
                query: { event: "Signed Up", from: "2026-07-01", to: "2026-07-07", unit: "day", type: "unique" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SavedReport" }
        "400":
          description: Missing name/type/query, or the query failed validation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/reports/{id}:
    get:
      tags: [Reports]
      summary: Get a saved report
      operationId: getReport
      security: [{ BearerAuth: [] }]
      description: Also returns `queryV1`/`queryWarnings` (the v0 -> v1 upgrade result) alongside the stored `query`, when upgradable.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SavedReport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Reports]
      summary: Update a saved report
      operationId: updateReport
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string, nullable: true }
                query: { type: object, additionalProperties: true }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SavedReport" }
        "400":
          description: The query failed validation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Reports]
      summary: Delete a saved report
      operationId: deleteReport
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, enum: [true] } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # =========================================================================
  # Boards
  # =========================================================================
  /api/boards:
    get:
      tags: [Boards]
      summary: List boards (preview only, no cards)
      operationId: listBoards
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/BoardListItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Boards]
      summary: Create an empty board
      operationId: createBoard
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                projectId: { type: string }
                name: { type: string }
              example: { name: "Growth" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Board" }
        "400":
          description: Missing name.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/boards/starter:
    post:
      tags: [Boards]
      summary: Seed the "Starter Board" for a project on demand
      operationId: seedStarterBoard
      security: [{ BearerAuth: [] }]
      description: Serves projects created before this feature shipped, or a project whose starter board was deleted. 409 if a board named "Starter Board" already exists.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                projectId: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BoardWithCards" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: A "Starter Board" already exists for this project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "starter board exists" }

  /api/boards/{id}:
    get:
      tags: [Boards]
      summary: Get a board with its cards
      operationId: getBoard
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BoardWithCards" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Boards]
      summary: Update a board (full card replace when `cards` is provided)
      operationId: updateBoard
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                dateRange:
                  oneOf:
                    - { $ref: "#/components/schemas/DateRangeSpec" }
                    - { type: "null" }
                filters:
                  type: array
                  items: { $ref: "#/components/schemas/FilterSpec" }
                cards:
                  type: array
                  items: { $ref: "#/components/schemas/BoardCardWrite" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BoardWithCards" }
        "400":
          description: Illegal card `span`/`height`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Boards]
      summary: Delete a board
      operationId: deleteBoard
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, enum: [true] } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # =========================================================================
  # Cohorts
  # =========================================================================
  /api/cohorts:
    get:
      tags: [Cohorts]
      summary: List cohorts
      operationId: listCohorts
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Cohort" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Cohorts]
      summary: Save a cohort
      operationId: createCohort
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, definition]
              properties:
                projectId: { type: string }
                name: { type: string }
                description: { type: string, nullable: true }
                definition: { $ref: "#/components/schemas/CohortDefinition" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Cohort" }
        "400":
          description: Missing name/definition, or an invalid definition.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "name, definition required" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/cohorts/preview:
    post:
      tags: [Cohorts]
      summary: Live membership preview for the cohort builder (no cohort saved)
      operationId: previewCohort
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [definition]
              properties:
                projectId: { type: string }
                definition: { $ref: "#/components/schemas/CohortDefinition" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CohortPreview" }
        "400":
          description: Missing/invalid definition.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "definition required" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/cohorts/{id}:
    get:
      tags: [Cohorts]
      summary: Get a cohort
      operationId: getCohort
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Cohort" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Cohorts]
      summary: Update a cohort
      operationId: updateCohort
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string, nullable: true }
                definition: { $ref: "#/components/schemas/CohortDefinition" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Cohort" }
        "400":
          description: Invalid definition.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "invalid definition" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Cohorts]
      summary: Delete a cohort
      operationId: deleteCohort
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, enum: [true] } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/cohorts/{id}/members:
    get:
      tags: [Cohorts]
      summary: Full member list (distinct_ids)
      operationId: getCohortMembers
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CohortMembers" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # =========================================================================
  # Flags
  # =========================================================================
  /api/flags:
    get:
      tags: [Flags]
      summary: List feature flags
      operationId: listFlags
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: archived
          in: query
          schema: { type: string, enum: ["1"] }
          description: "\"1\" includes archived flags alongside active ones. Omitted or any other value excludes archived flags (the default)."
      responses:
        "200":
          description: OK (ordered by created_at desc)
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/FeatureFlag" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Flags]
      summary: Create a feature flag
      operationId: createFlag
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key, name]
              properties:
                projectId: { type: string }
                key: { type: string, pattern: "^[a-z0-9][a-z0-9_-]{0,199}$" }
                name: { type: string }
                description: { type: string, nullable: true }
                active: { type: boolean, default: true }
                type: { allOf: [{ $ref: "#/components/schemas/FlagType" }], default: "release" }
                variants: { type: array, maxItems: 10, items: { $ref: "#/components/schemas/FlagVariant" } }
                rules: { type: array, maxItems: 10, items: { $ref: "#/components/schemas/FlagRule" } }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FeatureFlag" }
        "400":
          description: Invalid shape - the message names the offending field.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "variants: rolloutPct must sum to exactly 100 (got 90)" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Key already used by another flag in the project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "key taken" }

  /api/flags/{id}:
    get:
      tags: [Flags]
      summary: Get a feature flag
      operationId: getFlag
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FeatureFlag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Flags]
      summary: Update a feature flag (key is immutable)
      operationId: updateFlag
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string, nullable: true }
                active: { type: boolean }
                type: { $ref: "#/components/schemas/FlagType" }
                variants: { type: array, maxItems: 10, items: { $ref: "#/components/schemas/FlagVariant" } }
                rules: { type: array, maxItems: 10, items: { $ref: "#/components/schemas/FlagRule" } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FeatureFlag" }
        "400":
          description: Invalid shape - the message names the offending field.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Flags]
      summary: Delete a feature flag (only once archived)
      operationId: deleteFlag
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, enum: [true] } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The flag is not archived. Archive it first (POST /api/flags/{id}/archive).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "not archived" }

  /api/flags/{id}/archive:
    post:
      tags: [Flags]
      summary: Archive a feature flag
      description: |
        Archived flags are absent from POST /flags/evaluate and are excluded from
        GET /api/flags and GET /flags/local-evaluation by default. Idempotent:
        archiving an already-archived flag returns it unchanged.
      operationId: archiveFlag
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FeatureFlag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/flags/{id}/revive:
    post:
      tags: [Flags]
      summary: Revive an archived feature flag
      description: |
        Idempotent: reviving a flag that isn't archived returns it unchanged.
      operationId: reviveFlag
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FeatureFlag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/flags/{id}/preview:
    post:
      tags: [Flags]
      summary: Preview (Playground) evaluation for one flag
      description: |
        Evaluates this one flag for a given distinct id using the exact same
        evaluateFlags path as POST /flags/evaluate (same identity resolution,
        same hashing, same inactive handling, archived flags absent).
      operationId: previewFlag
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FlagPreviewRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FlagEvaluationResult" }
        "400":
          description: Missing distinctId.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/flags/{id}/events:
    get:
      tags: [Flags]
      summary: Flag audit trail (newest first)
      operationId: listFlagEvents
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/FlagEvent" } }
        "400":
          description: Invalid limit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # =========================================================================
  # Definitions
  # =========================================================================
  /api/definitions:
    get:
      tags: [Definitions]
      summary: List reusable definitions (derived events / calculated metrics)
      operationId: listDefinitions
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: kind
          in: query
          schema: { $ref: "#/components/schemas/ReusableDefinitionKind" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ReusableDefinition" } }
        "400":
          description: Invalid kind.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Definitions]
      summary: Create a reusable definition
      operationId: createDefinition
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, name, purpose, owner, interpretation, definition]
              properties:
                projectId: { type: string }
                kind: { $ref: "#/components/schemas/ReusableDefinitionKind" }
                name: { type: string }
                purpose: { type: string }
                owner: { type: string }
                interpretation: { type: string }
                definition:
                  oneOf:
                    - { $ref: "#/components/schemas/DerivedEventRule" }
                    - { $ref: "#/components/schemas/CalculatedMetricRule" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReusableDefinition" }
        "400":
          description: Missing required fields, or an invalid definition for `kind`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: A definition with this name already exists in the project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "definition name already exists" }

  /api/definitions/{id}:
    put:
      tags: [Definitions]
      summary: Update a reusable definition
      operationId: updateDefinition
      security: [{ BearerAuth: [] }]
      description: The definition's `name` is immutable after creation and must be echoed back unchanged.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, purpose, owner, interpretation, definition]
              properties:
                name: { type: string }
                purpose: { type: string }
                owner: { type: string }
                interpretation: { type: string }
                definition:
                  oneOf:
                    - { $ref: "#/components/schemas/DerivedEventRule" }
                    - { $ref: "#/components/schemas/CalculatedMetricRule" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReusableDefinition" }
        "400":
          description: Missing required fields, invalid definition, or attempted rename.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              examples:
                renameAttempt: { value: { error: "definition name cannot change" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Definitions]
      summary: Delete a reusable definition
      operationId: deleteDefinition
      security: [{ BearerAuth: [] }]
      description: Refused while any saved report still depends on it.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, enum: [true] } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: One or more saved reports still reference this definition.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "remove this definition from affected reports first" }

  # =========================================================================
  # Lexicon
  # =========================================================================
  /api/lexicon:
    get:
      tags: [Lexicon]
      summary: List lexicon entries (data dictionary)
      operationId: listLexicon
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: type
          in: query
          schema: { $ref: "#/components/schemas/LexiconEntityType" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/LexiconEntry" } }
        "400":
          description: Invalid type.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/lexicon/usage:
    get:
      tags: [Lexicon]
      summary: How many saved reports and cohorts name each event
      operationId: getLexiconUsage
      security: [{ BearerAuth: [] }]
      description: Keyed by raw event name (no merge aliasing) - a report pinned to a merged child still shows up against that child.
      parameters:
        - $ref: "#/components/parameters/projectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LexiconUsage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/lexicon/{type}/{name}:
    put:
      tags: [Lexicon]
      summary: Upsert lexicon metadata for one entity
      operationId: upsertLexiconEntry
      security: [{ BearerAuth: [] }]
      description: "`dropped`/`mergedInto` require the org owner role."
      parameters:
        - name: type
          in: path
          required: true
          schema: { $ref: "#/components/schemas/LexiconEntityType" }
        - name: name
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/projectId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LexiconPatch" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LexiconEntry" }
        "400":
          description: Invalid type/body, or an invalid adoptionStatus/reviewStatus value.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Non-owner attempted `dropped`/`mergedInto`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "409":
          description: Invalid merge (self-merge, chain, or merging a non-event).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "cannot merge an event into itself" }
    delete:
      tags: [Lexicon]
      summary: Clear lexicon metadata for one entity (owner only)
      operationId: deleteLexiconEntry
      security: [{ BearerAuth: [] }]
      parameters:
        - name: type
          in: path
          required: true
          schema: { $ref: "#/components/schemas/LexiconEntityType" }
        - name: name
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/projectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean } }
        "400":
          description: Invalid type.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # =========================================================================
  # Annotations
  # =========================================================================
  /api/annotations:
    get:
      tags: [Annotations]
      summary: List annotations in a date range
      operationId: listAnnotations
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Annotation" } }
        "400":
          description: Invalid date range.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Annotations]
      summary: Create an annotation
      operationId: createAnnotation
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [category, context, startsOn]
              properties:
                projectId: { type: string }
                category: { $ref: "#/components/schemas/AnnotationCategory" }
                context: { type: string }
                startsOn: { type: string, format: date }
                endsOn: { type: string, format: date, description: "Defaults to startsOn." }
              example:
                category: "release"
                context: "Shipped checkout redesign"
                startsOn: "2026-07-10"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Annotation" }
        "400":
          description: Missing/invalid category, context, or period.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/annotations/{id}:
    patch:
      tags: [Annotations]
      summary: Update an annotation
      operationId: updateAnnotation
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                category: { $ref: "#/components/schemas/AnnotationCategory" }
                context: { type: string }
                startsOn: { type: string, format: date }
                endsOn: { type: string, format: date }
                visible: { type: boolean }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Annotation" }
        "400":
          description: Invalid category/context/period.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Annotations]
      summary: Delete an annotation
      operationId: deleteAnnotation
      security: [{ BearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, enum: [true] } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # =========================================================================
  # Privacy
  # =========================================================================
  /api/privacy/requests:
    get:
      tags: [Privacy]
      summary: List privacy (export/delete) requests
      operationId: listPrivacyRequests
      security: [{ BearerAuth: [] }]
      description: Owner-role (or superadmin) only.
      parameters:
        - $ref: "#/components/parameters/projectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/PrivacyRequest" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/privacy/subjects/{distinctId}:
    get:
      tags: [Privacy]
      summary: Look up a subject (profile, aliases, event count) before acting
      operationId: getPrivacySubject
      security: [{ BearerAuth: [] }]
      description: Owner-role (or superadmin) only.
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: distinctId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PrivacySubject" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Privacy]
      summary: Delete a subject (profile + events), with an audit trail
      operationId: deletePrivacySubject
      security: [{ BearerAuth: [] }]
      description: |
        Owner-role (or superadmin) only. Requires the caller to echo the
        `distinctId` back as `confirmation` in the body, guarding against an
        accidental delete-by-URL. Records a `privacy_requests` row.
      parameters:
        - $ref: "#/components/parameters/projectId"
        - name: distinctId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirmation]
              properties:
                projectId: { type: string }
                confirmation: { type: string, description: "Must equal the `distinctId` path parameter." }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PrivacyDeleteResult" }
        "400":
          description: "`confirmation` does not match `distinctId`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "confirmation does not match" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/privacy/subjects/{distinctId}/export:
    post:
      tags: [Privacy]
      summary: Export a subject's full data (profile + events), with an audit trail
      operationId: exportPrivacySubject
      security: [{ BearerAuth: [] }]
      description: Owner-role (or superadmin) only. Records a `privacy_requests` row.
      parameters:
        - name: distinctId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                projectId: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PrivacyExport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # =========================================================================
  # Account
  # =========================================================================
  /api/me:
    get:
      tags: [Account]
      summary: The caller's identity, org, role, quotas, and usage
      operationId: getMe
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/Me" }
                  - { $ref: "#/components/schemas/MeSuperadmin" }
              examples:
                user:
                  value: { user: { id: 3, firebase_uid: "abc", email: "ada@example.com", display_name: "Ada", created_at: 1753900800000 }, org: null, role: null, quotas: null, usage: null }
                superadmin:
                  value: { user: null, superadmin: true }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/onboard:
    post:
      tags: [Account]
      summary: Consume an invite; create the caller's org (and optionally first project)
      operationId: onboard
      security: [{ BearerAuth: [] }]
      description: "`projectName` is optional: omit it to create the org with no project (the empty-state flow); `project` is then `null`."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [inviteCode, orgName]
              properties:
                inviteCode: { type: string }
                orgName: { type: string }
                projectName: { type: string }
              example: { inviteCode: "K3F9QZ2A", orgName: "Acme", projectName: "Production" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  org: { $ref: "#/components/schemas/Org" }
                  project:
                    oneOf:
                      - { $ref: "#/components/schemas/Project" }
                      - { type: "null" }
        "400":
          description: Missing inviteCode/orgName, or an invalid (bad/expired/revoked/exhausted) invite code.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              examples:
                missing: { value: { error: "inviteCode, orgName required" } }
                badInvite: { value: { error: "invalid invite" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Called with a superadmin credential (onboarding is user-only).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "409":
          description: The caller already belongs to an org.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "already onboarded" }

  /api/orgs/current:
    patch:
      tags: [Account]
      summary: Rename the caller's org (owner only)
      operationId: updateCurrentOrg
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Org" }
        "400":
          description: Missing name.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/orgs/current/members:
    get:
      tags: [Account]
      summary: List the caller's org members
      operationId: listCurrentOrgMembers
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/OrgMember" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NoOrganization" }

  /api/usage:
    get:
      tags: [Account]
      summary: The caller's org usage against its plan quotas
      operationId: getUsage
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NoOrganization" }

  # =========================================================================
  # Admin (backoffice)
  # =========================================================================
  /admin/stats:
    get:
      tags: [Admin]
      summary: Platform-wide counters
      operationId: getAdminStats
      security: [{ SuperadminAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminStats" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /admin/invites:
    get:
      tags: [Admin]
      summary: List invites
      operationId: listInvites
      security: [{ SuperadminAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Invite" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Admin]
      summary: Create an invite
      operationId: createInvite
      security: [{ SuperadminAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InviteCreate" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invite" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /admin/invites/{id}:
    delete:
      tags: [Admin]
      summary: Revoke an invite
      operationId: revokeInvite
      security: [{ SuperadminAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, enum: [true] } }
        "400":
          description: Invalid id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /admin/users:
    get:
      tags: [Admin]
      summary: List every user across every org
      operationId: adminListUsers
      security: [{ SuperadminAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/AdminUser" } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /admin/orgs:
    get:
      tags: [Admin]
      summary: List every org
      operationId: adminListOrgs
      security: [{ SuperadminAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/AdminOrg" } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /admin/orgs/{id}:
    get:
      tags: [Admin]
      summary: Org detail (members, projects, usage)
      operationId: getAdminOrg
      security: [{ SuperadminAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrgDetail" }
        "400":
          description: Invalid id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Admin]
      summary: Override an org's plan/quotas
      operationId: updateAdminOrg
      security: [{ SuperadminAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdminOrgPatch" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Org" }
        "400":
          description: Invalid id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/incidents:
    get:
      tags: [Admin]
      summary: List incidents and maintenance windows
      operationId: listAdminIncidents
      security: [{ SuperadminAuth: [] }]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 100 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [incidents]
                properties:
                  incidents: { type: array, items: { $ref: "#/components/schemas/Incident" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Admin]
      summary: Create an incident or maintenance window
      operationId: createIncident
      security: [{ SuperadminAuth: [] }]
      description: |
        `kind` defaults to `incident`. When `body` is given it becomes the
        first IncidentUpdate. Validation 400s: unknown component id,
        status/kind mismatch, empty title, or a maintenance without
        `scheduled_start`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IncidentCreate" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Incident" }
        "400":
          description: Validation failed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /admin/incidents/{id}:
    patch:
      tags: [Admin]
      summary: Update an incident or maintenance window
      operationId: updateIncident
      security: [{ SuperadminAuth: [] }]
      description: |
        Any field may be omitted (left unchanged). `kind` is not patchable, so
        `status` is validated against the incident's stored kind.
        `resolved_at` is set automatically when `status` becomes
        `resolved`/`completed` and cleared when it moves back to an open
        status; an explicit `resolved_at` in the body overrides that.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IncidentPatch" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Incident" }
        "400":
          description: Validation failed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Admin]
      summary: Delete an incident or maintenance window
      operationId: deleteIncident
      security: [{ SuperadminAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, enum: [true] } }
        "400":
          description: Invalid id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /admin/incidents/{id}/updates:
    post:
      tags: [Admin]
      summary: Post a status update on an incident or maintenance window
      operationId: createIncidentUpdate
      security: [{ SuperadminAuth: [] }]
      description: Appends the update, syncs `incidents.status` to it, and sets/clears `resolved_at`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IncidentUpdateCreate" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Incident" }
        "400":
          description: Validation failed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
