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. **Account token** (Better Auth JWT, from `GET /api/auth/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, account tokens always rejected.
  - name: Errors
    description: Error issues (ADR-0019) - the read API and triage over what the error worker recorded. Counts come from the Query IR; received/recorded/discarded are the worker's operational counters.
  - name: Symbols
    description: Symbol uploads (VEL-1766, docs/SOURCEMAP-INDEX.md) - source map indexes, R8 mappings and symcaches the error worker symbolicates with, and the project upload tokens that send them.
  - name: Replays
    description: Session replay (VEL-1782, docs/SESSION-REPLAY.md) - rrweb recordings sent by `@cohorly/web`, stored gzipped in the blob store, listed and read one session at a time.
  - name: Alerts
    description: Error alerts (VEL-1771) - per-project rules that send new error issue, regression, ingestion stopped and worker stalled notifications to every org member by e-mail and/or to a signed webhook.

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 an account
        token (Better Auth JWT). 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.
    UploadToken:
      type: http
      scheme: bearer
      description: |
        A project upload token (`chrly_upl_...`), minted by
        `POST /api/projects/{id}/upload-tokens`. It authorizes exactly one
        route, `PUT /api/symbols/{kind}`, and is refused by every other
        `/api/*` route; that route accepts no other credential.
        `401 { error: "invalid upload token" }` otherwise.
    SuperadminAuth:
      type: http
      scheme: bearer
      description: |
        The superadmin key ONLY (`COHORLY_API_KEY`). Account 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 signed-in account 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." }

    AlertTrigger:
      type: string
      enum: [new_issue, regression, ingestion_stopped, worker_stalled]
    AlertRuleInput:
      type: object
      description: |
        Filters (empty = all) apply only to the issue triggers. At least one
        destination: `emailMembers` or `webhookUrl`. In production the
        webhook must be https and must not resolve to a private address
        (unless COHORLY_ALERT_WEBHOOK_ALLOW_PRIVATE).
      properties:
        projectId: { type: integer }
        name: { type: string, minLength: 1, maxLength: 80 }
        enabled: { type: boolean, default: true }
        triggers: { type: array, minItems: 1, items: { $ref: "#/components/schemas/AlertTrigger" } }
        levels: { type: array, items: { type: string, enum: [fatal, error, warning, info] } }
        releases: { type: array, maxItems: 20, items: { type: string, minLength: 1, maxLength: 100 } }
        platforms:
          type: array
          maxItems: 20
          description: Exact match on the exception's `$lib`.
          items: { type: string, minLength: 1, maxLength: 100 }
        emailMembers: { type: boolean }
        webhookUrl: { type: [string, "null"] }
    AlertRule:
      type: object
      required: [id, projectId, name, enabled, triggers, levels, releases, platforms, emailMembers, webhookUrl, createdAt, updatedAt]
      properties:
        id: { type: integer }
        projectId: { type: integer }
        name: { type: string }
        enabled: { type: boolean }
        triggers: { type: array, items: { $ref: "#/components/schemas/AlertTrigger" } }
        levels: { type: array, items: { type: string } }
        releases: { type: array, items: { type: string } }
        platforms: { type: array, items: { type: string } }
        emailMembers: { type: boolean }
        webhookUrl: { type: [string, "null"] }
        webhookSecret:
          type: [string, "null"]
          description: "`whsec_...` signing secret. Present in owner responses only."
        createdBy: { type: [string, "null"] }
        createdAt: { type: integer }
        updatedAt: { type: integer }
    AlertSettings:
      type: object
      required: [newIssueHourlyLimit, ingestionSilenceMinutes]
      properties:
        newIssueHourlyLimit: { type: integer, minimum: 1, maximum: 1000, default: 10 }
        ingestionSilenceMinutes:
          type: integer
          minimum: 0
          maximum: 10080
          default: 60
          description: 0 turns the ingestion stopped notification off; otherwise 5 to 10080.

    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

    ErrorIssue:
      type: object
      description: "An error issue (ADR-0019): operational state written by the error worker and triage."
      required: [id, project_id, short_id, title, level, status, unsymbolicated, merged_into, first_seen, last_seen]
      properties:
        id: { type: integer }
        project_id: { type: integer }
        short_id: { type: string, example: "SHOP-12", description: "<PREFIX>-<n>, stored at creation (stable on project rename)." }
        title: { type: string }
        type: { type: [string, "null"] }
        value: { type: [string, "null"] }
        level: { type: string, enum: [fatal, error, warning, info] }
        status: { type: string, enum: [active, resolved, ignored] }
        unsymbolicated: { type: boolean }
        merged_into: { type: [integer, "null"] }
        first_seen: { type: integer, description: "Worker receive time (ms)." }
        last_seen: { type: integer, description: "Worker receive time (ms)." }
        first_release: { type: [string, "null"] }
        last_release: { type: [string, "null"] }
        resolved_in_release: { type: [string, "null"] }
        resolved_at: { type: [integer, "null"] }

    ErrorIssueListItem:
      allOf:
        - { $ref: "#/components/schemas/ErrorIssue" }
        - type: object
          required: [events, users, received, recorded, sampled_events, sampled_users]
          properties:
            events: { type: number, description: "Recorded `$exception` events in the period (Query IR)." }
            users: { type: number, description: "Unique affected users in the period (Query IR)." }
            received: { type: integer, description: "Operational: payloads the worker received in the period, discarded ones included." }
            recorded: { type: integer, description: "Operational: payloads the worker recorded in the period." }
            sampled_events: { type: boolean, description: "True when the worker discarded payloads of this error issue in the period (`events` is a sample)." }
            sampled_users: { type: boolean, description: "True when the 1,000 new users cap discarded payloads of this error issue in the period (`users` may be low)." }

    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, auth_subject, email, display_name, created_at]
      properties:
        id: { type: integer }
        auth_subject:
          type: string
          description: Subject of the account provider that created the user - a legacy subject for users that predate Better Auth, or a Better Auth user id (ADR-0016).
        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 }
        quota_exceptions_month: { type: integer, nullable: true, description: "Recorded exceptions per month; NULL = plan default (100000)." }
        exception_retention_days: { type: integer, nullable: true, description: "Days staged exception payloads are kept; NULL = plan default (90)." }
        quota_symbol_bytes: { type: integer, nullable: true, description: "Distinct uploaded symbol bytes kept; NULL = plan default (1 GiB)." }
        quota_replays_month: { type: integer, nullable: true, description: "New session replays per month; NULL = plan default (5000)." }
        replay_retention_days: { type: integer, nullable: true, description: "Days a session replay is kept; NULL = plan default (30)." }
        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 }

    OrgInvite:
      type: object
      required: [id, email, role, status, invitedBy, createdAt, expiresAt, lastSentAt]
      properties:
        id: { type: integer }
        email: { type: string, format: email }
        role: { $ref: "#/components/schemas/OrgRole" }
        status: { type: string, enum: [pending, expired] }
        invitedBy:
          oneOf:
            - type: object
              required: [name, email]
              properties:
                name: { type: [string, "null"] }
                email: { type: string }
            - { type: "null" }
        createdAt: { type: integer }
        expiresAt: { type: integer }
        lastSentAt: { type: integer }

    OrgInvitePreview:
      type: object
      required: [orgName, email, role, invitedBy, expiresAt]
      properties:
        orgName: { type: string }
        email: { type: string, format: email }
        role: { $ref: "#/components/schemas/OrgRole" }
        invitedBy: { type: [string, "null"] }
        expiresAt: { type: integer }

    PlanQuotas:
      type: object
      required: [eventsPerMonth, maxProjects, retentionMonths]
      properties:
        eventsPerMonth: { type: integer }
        maxProjects: { type: integer }
        retentionMonths: { type: integer }
        exceptionsPerMonth: { type: integer }
        exceptionRetentionDays: { type: integer }
        symbolStorageBytes: { type: integer }
        replaysPerMonth: { type: integer }
        replayRetentionDays: { type: integer }

    ReplaySession:
      type: object
      required: [session_id, distinct_id, started_at, ended_at, duration_ms, chunk_count, bytes, mode, received_at]
      properties:
        session_id: { type: string }
        distinct_id: { type: string }
        started_at: { type: integer, description: "Unix ms of the first rrweb event." }
        ended_at: { type: integer, description: "Unix ms of the last rrweb event." }
        duration_ms: { type: integer }
        chunk_count: { type: integer }
        bytes: { type: integer, description: "Stored (compressed) bytes." }
        mode: { type: string, enum: [session, error], description: "`error` only if no session-mode chunk arrived." }
        received_at: { type: integer }

    SymbolAssociation:
      type: object
      required: [id, kind, release, dist, file, debug_id, size, created_at]
      properties:
        id: { type: integer }
        kind: { type: string, enum: [sourcemap-index, r8-mapping, symcache] }
        release: { type: string }
        dist: { type: string, description: "Empty string when the upload named none." }
        file: { type: string, description: "`~/`-prefixed bundle path for a source map index, empty for R8, the debug id for a symcache." }
        debug_id: { type: [string, "null"] }
        size: { type: integer, description: "Stored bytes of the artifact." }
        created_at: { type: integer, description: "Last upload to this association (ms)." }

    UploadToken:
      type: object
      required: [id, projectId, name, prefix, createdAt, expiresAt, lastUsedAt, revokedAt]
      properties:
        id: { type: integer }
        projectId: { type: integer }
        name: { type: string }
        prefix: { type: string, description: "First characters after `chrly_upl_`, to recognise a token; never the secret." }
        createdAt: { type: integer }
        expiresAt: { type: [integer, "null"] }
        lastUsedAt: { type: [integer, "null"] }
        revokedAt: { type: [integer, "null"] }

    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 }
        exceptions: { type: integer, description: "Exceptions the error worker recorded this cycle (never counted in `events`)." }
        exceptionLimit: { type: integer }
        exceptionsRefused: { type: integer, description: "Exceptions /track refused this cycle (exception quota or rate limit) and never stored." }
        exceptionRetentionDays: { type: integer }
        symbolBytes: { type: integer, description: "Distinct symbol bytes stored for the org (each blob counted once)." }
        symbolBytesLimit: { type: integer, description: "The org's symbol storage ceiling (`symbolStorageBytes`)." }
        replays: { type: integer, description: "Session-mode replays counted this cycle (against `replayLimit`), each session once." }
        replaysOnError: { type: integer, description: "Sessions opened in error mode this cycle (against twice `replayLimit`)." }
        replayLimit: { type: integer, description: "The org's `replaysPerMonth`." }
        replayRetentionDays: { 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 }
        quota_exceptions_month: { type: integer, nullable: true }
        exception_retention_days: { type: integer, nullable: true }
        quota_symbol_bytes: { type: integer, nullable: true }
        quota_replays_month: { type: integer, nullable: true, minimum: 0, description: "400 unless an integer >= 0 or null." }
        replay_retention_days: { type: integer, nullable: true, minimum: 1, description: "400 unless an integer >= 1 or null." }

    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" }

  /api/auth-methods:
    get:
      tags: [Health, Account]
      summary: Sign-in methods this server offers
      operationId: authMethods
      security: []
      description: |
        No credential required. `google` is true only when the server has both
        `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. The sign-in endpoints
        themselves are Better Auth's own, under `/api/auth/*` (ADR-0016).
        `mode` is `COHORLY_DEPLOYMENT_MODE`. `inviteRequired` is false only on
        a `self-host` instance without `COHORLY_BOOTSTRAP_ADMIN_EMAIL` that
        has no org owner yet: that first account may call `POST /api/onboard`
        without an invite code.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [emailPassword, google, mode, inviteRequired]
                properties:
                  emailPassword: { type: boolean, const: true }
                  google: { type: boolean }
                  mode: { type: string, enum: [cloud, self-host] }
                  inviteRequired: { type: boolean }
              example: { emailPassword: true, google: false, mode: cloud, inviteRequired: true }

  # =========================================================================
  # 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.

        `$exception_issue_id` is a reserved key and is stripped from every
        event. Events named `$exception` (ADR-0019) are never inserted into
        `events` here: they are PII-scrubbed (e-mails, bearer/JWT/`token=`
        secrets, Luhn card numbers in exception values, `error_title` and
        breadcrumbs), truncated (value 1024 chars, title 255, 10 exceptions,
        last 100 frames per stack, newest 100 breadcrumbs) and staged for the
        error worker, deduplicated by `$insert_id` per receive month.
        `inserted` counts staged exceptions plus inserted events. Exceptions
        have their own per-project rate limit and monthly quota of recorded
        exceptions; neither rejects the batch - a refused exception is simply
        not stored and not counted. The analytics rate limit and event quota
        only ever look at non-exception events: when they answer 429, the
        batch's exceptions are already staged (an SDK retry dedups them).
      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 an account 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" }

  /replay:
    post:
      tags: [Ingestion, Replays]
      summary: Send one session replay chunk (rrweb events)
      operationId: replay
      security: [{}, { ProjectToken: [] }]
      description: |
        docs/SESSION-REPLAY.md section 2. Token via `X-Cohorly-Token` or
        `?token=`; gzip via `Content-Encoding: gzip` or `?compression=gzip`.
        1 MB on the wire, 10 MB inflated, 1..5000 events whose timestamps
        are safe integers within 48 h before / 1 h after the receive time.
        Per session at most 2000 chunks and 100 MB stored (413 `replay too
        large`). Same per-IP rate limit as `/track`, its own per-project
        bucket (`COHORLY_REPLAY_RATE_*`, default 300 / 10 s); never counted
        as events. Quota: a new session-mode session (or an error-mode
        session's first session-mode chunk) over `replaysPerMonth`, or a new
        error-mode session over twice it, is refused (429, `Retry-After` to
        the next UTC month). A duplicate `(session_id, seq)`, or a chunk of a
        deleted session (kept 48 h as a tombstone), answers 200 and stores
        nothing.
      parameters:
        - { name: X-Cohorly-Token, in: header, required: false, schema: { type: string } }
        - { name: token, in: query, required: false, schema: { type: string } }
        - { name: compression, in: query, required: false, schema: { type: string, enum: [gzip] } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [session_id, distinct_id, seq, mode, events]
              properties:
                session_id: { type: string, pattern: "^[A-Za-z0-9_-]{1,64}$" }
                distinct_id: { type: string, minLength: 1, maxLength: 255 }
                seq: { type: integer, minimum: 0, maximum: 1000000 }
                mode: { type: string, enum: [session, error] }
                lib: { type: string }
                lib_version: { type: string }
                events:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    type: object
                    required: [timestamp]
                    additionalProperties: true
                    properties:
                      timestamp: { type: integer, description: "Unix ms, within 48 h before / 1 h after the receive time." }
      responses:
        "200":
          description: Stored, already stored, or dropped (deleted session).
          content:
            application/json:
              schema: { type: object, properties: { status: { type: integer, example: 1 } } }
        "400":
          description: Malformed gzip or JSON, or a chunk outside the contract.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "413":
          description: Over the wire or inflate cap, or over a per-session ceiling (`replay too large`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestErrorEnvelope" }
        "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, auth_subject: "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`.
        `inviteCode` is required, except on a `self-host` instance (`COHORLY_DEPLOYMENT_MODE`) that has no org owner yet:
        that first account onboards without one and becomes owner of the org it creates. A code that is sent is always validated.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [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, an invalid (bad/expired/revoked/exhausted) invite code, or a self-host instance whose first owner already exists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              examples:
                missing: { value: { error: "inviteCode, orgName required" } }
                badInvite: { value: { error: "invalid invite" } }
                firstOwnerTaken: { value: { error: "invite code required" } }
        "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/orgs/current/invites:
    post:
      tags: [Account]
      summary: Invite people to the caller's org by e-mail (owner only)
      operationId: createOrgInvites
      security: [{ BearerAuth: [] }]
      description: |
        One invite per address (1..20 per request, normalized to lowercase and
        deduplicated). Each invitee is e-mailed a single-use link to
        `<COHORLY_DASHBOARD_ORIGIN>/invite?token=...` that expires in 7 days;
        without SMTP the link is written to the server log. An address that
        already has a live invite gets it refreshed (new token, new expiry,
        the new role). An address that already belongs to a member is listed
        in `skipped`. The e-mails are sent after the response and never fail it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails]
              properties:
                emails: { type: array, items: { type: string }, minItems: 1, maxItems: 20 }
                role: { $ref: "#/components/schemas/OrgRole" }
              example: { emails: ["ada@example.com"], role: "member" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [invites, skipped]
                properties:
                  invites: { type: array, items: { $ref: "#/components/schemas/OrgInvite" } }
                  skipped:
                    type: array
                    items:
                      type: object
                      required: [email, reason]
                      properties:
                        email: { type: string }
                        reason: { type: string, enum: [member] }
        "400":
          description: Missing or malformed `emails`, too many addresses, an unknown role, or invalid addresses (listed in `emails`).
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error: { type: string }
                  emails: { type: array, items: { type: string } }
              example: { error: "invalid email", emails: ["not-an-email"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    get:
      tags: [Account]
      summary: List the org's pending and expired invites (owner only)
      operationId: listOrgInvites
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Not accepted, not revoked; newest first.
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/OrgInvite" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/orgs/current/invites/{id}/resend:
    post:
      tags: [Account]
      summary: Re-send an invite with a new token and a fresh 7-day expiry (owner only)
      operationId: resendOrgInvite
      security: [{ BearerAuth: [] }]
      description: The previous link stops working.
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrgInvite" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/orgs/current/invites/{id}:
    delete:
      tags: [Account]
      summary: Revoke an invite (owner only)
      operationId: revokeOrgInvite
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "204": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/invites/{token}:
    get:
      tags: [Account]
      summary: Preview an org invite (no credential)
      operationId: previewOrgInvite
      security: []
      description: Rate limited per IP. Unknown, revoked and accepted invites all answer `404 invalid invite`.
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrgInvitePreview" }
        "404":
          description: Unknown, revoked or already accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "invalid invite" }
        "410":
          description: Expired.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "invite expired" }
        "429":
          description: Too many previews from this IP.
          headers:
            Retry-After: { $ref: "#/components/headers/RetryAfter" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "rate limited" }

  /api/invites/{token}/accept:
    post:
      tags: [Account]
      summary: Accept an org invite as the signed-in account
      operationId: acceptOrgInvite
      security: [{ BearerAuth: [] }]
      description: |
        Account token only (works for a user with no org; the superadmin key
        gets 403). The account's e-mail must equal the invited address
        (case-insensitive). Single use: the invite is marked accepted in the
        same transaction that adds the membership.
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Joined.
          content:
            application/json:
              schema:
                type: object
                required: [org, role]
                properties:
                  org: { $ref: "#/components/schemas/Org" }
                  role: { $ref: "#/components/schemas/OrgRole" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Superadmin key, or the account's e-mail is not the invited one.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "invite email mismatch" }
        "404":
          description: Unknown, revoked or already accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "invalid invite" }
        "409":
          description: The account already belongs to an org.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "already in an organization" }
        "410":
          description: Expired.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
              example: { error: "invite expired" }

  /api/errors/issues:
    get:
      tags: [Errors]
      summary: List error issues
      operationId: listErrorIssues
      security: [{ BearerAuth: [] }]
      description: |
        Error issues seen (worker receive time) in the period, merged-away
        ones hidden. `events` and `users` come from one Query IR (trend,
        `$exception` total + unique, breakdown `$exception_issue_id`) run
        through the same pipeline as `POST /api/query`; `received` /
        `recorded` / `sampled_events` / `sampled_users` are the worker's
        operational counters.
        `sort=last_seen` (default) pages normally (limit <= 50).
        `sort=events|users` ranks only the top `rankLimit` (50, the IR
        breakdown cap): `offset + limit` above 50 is a 400, and `total` is
        how many were ranked (at most 50), not how many error issues match.
        `release` keeps error issues with at least one recorded `$exception`
        whose `$exception_release` equals it in the period (decided by the IR,
        not by first/last release) and restricts the counts to it.
        `sort=events|users` and `release` decide among at most
        `candidateLimit` (1000) error issues matching status, search and
        period, the most recently seen first; `truncated` is true when more
        matched. Both fields are present only on those requests.
      parameters:
        - { $ref: "#/components/parameters/projectId" }
        - { name: status, in: query, required: false, schema: { type: string, enum: [active, resolved, ignored] } }
        - { name: release, in: query, required: false, schema: { type: string } }
        - { name: search, in: query, required: false, schema: { type: string }, description: "Case-insensitive substring of title, type, value or short id." }
        - { name: from, in: query, required: false, schema: { type: string, format: date }, description: "With `to`; default the last 14 days in the server time zone." }
        - { name: to, in: query, required: false, schema: { type: string, format: date } }
        - { name: sort, in: query, required: false, schema: { type: string, enum: [last_seen, events, users], default: last_seen } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 50, default: 25 } }
        - { name: offset, in: query, required: false, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [range, issues, total]
                properties:
                  range:
                    type: object
                    required: [from, to]
                    properties:
                      from: { type: string, format: date }
                      to: { type: string, format: date }
                  issues:
                    type: array
                    items: { $ref: "#/components/schemas/ErrorIssueListItem" }
                  total:
                    type: integer
                    description: "sort=last_seen: matching error issues. sort=events|users: ranked error issues, at most rankLimit."
                  rankLimit: { type: integer, enum: [50], description: "sort=events|users only." }
                  candidateLimit: { type: integer, enum: [1000], description: "sort=events|users or release only." }
                  truncated: { type: boolean, description: "With candidateLimit: more error issues matched than were considered." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/errors/issues/{id}:
    get:
      tags: [Errors]
      summary: Error issue detail
      operationId: getErrorIssue
      security: [{ BearerAuth: [] }]
      description: |
        `id` is the numeric id or the short id. A merged-away id keeps
        resolving: `issue` is the surviving error issue and `requested_id` the
        one asked for. `counters` are operational (receive time, period
        `from`/`to`), never analysis numbers. `latest_event` is one staging
        row: the newest recorded payload's stack (resolved when a map resolved
        it, else the raw frames) and breadcrumbs. `eventOffset` steps back
        through older recorded payloads (0 = newest, across every fingerprint
        of the error issue, merged ones included); when it is passed, the
        response also carries `event_offset` and `has_older_event`, and
        `latest_event` is null past the oldest payload.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/projectId" }
        - { name: from, in: query, required: false, schema: { type: string, format: date } }
        - { name: to, in: query, required: false, schema: { type: string, format: date } }
        - { name: eventOffset, in: query, required: false, schema: { type: integer, minimum: 0, maximum: 10000 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [issue, requested_id, merged_issue_ids, fingerprints, range, counters, sampled_events, sampled_users, latest_event]
                properties:
                  issue: { $ref: "#/components/schemas/ErrorIssue" }
                  requested_id: { type: integer }
                  merged_issue_ids: { type: array, items: { type: integer } }
                  fingerprints:
                    type: array
                    items:
                      type: object
                      properties:
                        fingerprint: { type: string }
                        first_seen: { type: integer }
                        last_seen: { type: integer }
                        min_event_time: { type: [integer, "null"] }
                        max_event_time: { type: [integer, "null"] }
                  range:
                    type: object
                    properties:
                      from: { type: string, format: date }
                      to: { type: string, format: date }
                  counters:
                    type: object
                    properties:
                      received: { type: integer }
                      recorded: { type: integer }
                      discarded: { type: integer }
                      discarded_user_cap: { type: integer }
                  sampled_events: { type: boolean, description: "counters.discarded > 0." }
                  sampled_users: { type: boolean, description: "counters.discarded_user_cap > 0." }
                  latest_event:
                    oneOf:
                      - type: object
                        additionalProperties: true
                      - { type: "null" }
                  replay:
                    description: The session replay of the shown event (its `$session_id`), null when that session has no recording (VEL-1782).
                    oneOf:
                      - type: object
                        required: [session_id, started_at]
                        properties:
                          session_id: { type: string }
                          started_at: { type: integer }
                      - { type: "null" }
                  event_offset: { type: integer, description: "Only when eventOffset was passed." }
                  has_older_event: { type: boolean, description: "Only when eventOffset was passed." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Errors]
      summary: Resolve, ignore or reactivate an error issue
      operationId: updateErrorIssue
      security: [{ BearerAuth: [] }]
      description: |
        Owners and members. `resolved` records `resolvedInRelease` (default:
        the error issue's last seen release). A resolved error issue that
        receives a new event is reopened by the worker (regression).
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/projectId" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                projectId: { type: integer }
                status: { type: string, enum: [active, resolved, ignored] }
                resolvedInRelease: { type: [string, "null"] }
            example: { status: resolved, resolvedInRelease: "web@2026.11.4" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorIssue" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/errors/fingerprints:
    get:
      tags: [Errors]
      summary: Resolve event fingerprints to error issues
      operationId: lookupErrorFingerprints
      security: [{ BearerAuth: [] }]
      description: |
        The error issue each `$exception_fingerprint` resolves to today,
        merges included (a merged-away issue's fingerprints map to the
        survivor). Project-scoped: a fingerprint of another project, or one
        never seen, is absent from `issues`. Used by the user profile timeline
        to link a stored `$exception` event to its error issue.
      parameters:
        - { $ref: "#/components/parameters/projectId" }
        - name: fingerprint
          in: query
          required: true
          description: Repeat the parameter, 1..100 values.
          schema: { type: array, items: { type: string }, minItems: 1, maxItems: 100 }
          style: form
          explode: true
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [issues]
                properties:
                  issues:
                    type: object
                    additionalProperties:
                      type: object
                      required: [id, short_id, title, status]
                      properties:
                        id: { type: integer }
                        short_id: { type: string }
                        title: { type: string }
                        status: { type: string, enum: [active, resolved, ignored] }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /api/errors/issues/{id}/merge:
    post:
      tags: [Errors]
      summary: Merge an error issue into another (owner only, final)
      operationId: mergeErrorIssue
      security: [{ BearerAuth: [] }]
      description: |
        Repoints every fingerprint of `id` (and of anything already merged
        into it) to `into`; `id` keeps its row with `merged_into` set and keeps
        resolving to the survivor. No row of `events` changes - analyses that
        name the survivor now count both, on purpose (a live reference). No
        unmerge. Both ends follow an earlier merge to their survivor. 400 when
        they are the same error issue, 404 when `into` is not in the project.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/projectId" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [into]
              properties:
                projectId: { type: integer }
                into: { oneOf: [{ type: integer }, { type: string }], description: "Target id or short id." }
      responses:
        "200":
          description: The surviving error issue.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorIssue" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: A concurrent merge moved one of the two error issues; retry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }

  /api/alerts/rules:
    get:
      tags: [Alerts]
      summary: List the project's alert rules (owners and members)
      operationId: listAlertRules
      security: [{ BearerAuth: [] }]
      parameters:
        - { $ref: "#/components/parameters/projectId" }
      responses:
        "200":
          description: The rules; `webhookSecret` only for owners.
          content:
            application/json:
              schema:
                type: object
                required: [rules, limit]
                properties:
                  rules: { type: array, items: { $ref: "#/components/schemas/AlertRule" } }
                  limit: { type: integer, description: Maximum rules per project (20). }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Alerts]
      summary: Create an alert rule (owner)
      operationId: createAlertRule
      security: [{ BearerAuth: [] }]
      description: A rule with a webhook gets a server-generated `whsec_` signing secret. 400 with the reason on any invalid field or past 20 rules.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AlertRuleInput" }
      responses:
        "201":
          description: The rule, with its signing secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertRule" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/alerts/rules/{id}:
    put:
      tags: [Alerts]
      summary: Update an alert rule (owner)
      operationId: updateAlertRule
      security: [{ BearerAuth: [] }]
      description: Fields left out keep their value. Removing the webhook drops its secret; adding one mints a new one. A rule of another project is 404.
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AlertRuleInput" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertRule" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Alerts]
      summary: Delete an alert rule (owner)
      operationId: deleteAlertRule
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/projectId" }
      responses:
        "200":
          description: Deleted, with its pending deliveries.
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/alerts/rules/{id}/rotate-secret:
    post:
      tags: [Alerts]
      summary: Replace a rule's webhook signing secret (owner)
      operationId: rotateAlertRuleSecret
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/projectId" }
      responses:
        "200":
          description: The rule with its new secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertRule" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/alerts/rules/{id}/test:
    post:
      tags: [Alerts]
      summary: Send a test alert through the rule's destinations now (owner)
      operationId: testAlertRule
      security: [{ BearerAuth: [] }]
      description: Synchronous, never recorded, never counted toward the hourly limit. `type` is `test` in the webhook body and the `x-cohorly-event` header.
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/projectId" }
      responses:
        "200":
          description: What each destination did; null for a destination the rule does not have.
          content:
            application/json:
              schema:
                type: object
                required: [email, webhook]
                properties:
                  email:
                    oneOf:
                      - { type: "null" }
                      - { type: object, required: [recipients], properties: { recipients: { type: integer } } }
                  webhook:
                    oneOf:
                      - { type: "null" }
                      - type: object
                        required: [ok]
                        properties:
                          ok: { type: boolean }
                          status: { type: integer }
                          error: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/alerts/settings:
    get:
      tags: [Alerts]
      summary: Read the project's alert settings (owners and members)
      operationId: getAlertSettings
      security: [{ BearerAuth: [] }]
      parameters:
        - { $ref: "#/components/parameters/projectId" }
      responses:
        "200":
          description: The settings, defaults when never saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [Alerts]
      summary: Change the project's alert settings (owner)
      operationId: updateAlertSettings
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/AlertSettings" }
                - { type: object, properties: { projectId: { type: integer } } }
      responses:
        "200":
          description: The saved settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertSettings" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # One path item for both: OpenAPI forbids two templated paths that differ
  # only in the parameter name. On PUT `ref` is the kind, on DELETE the id.
  /api/symbols/{ref}:
    put:
      tags: [Symbols]
      summary: Upload a source map index, an R8 mapping or a symcache
      operationId: uploadSymbol
      security: [{ UploadToken: [] }]
      description: |
        The project comes from the upload token; a projectId in the query is
        ignored. Body gzip (`Content-Encoding: gzip`) or plain, at most 50 MiB
        as sent and 512 MiB inflated (413). `sourcemap-index` needs `release`
        and a `file` starting with `~/`, and must be a valid index v1;
        `r8-mapping` needs `release`; `symcache` needs `debug_id`. A debug id
        already stored with different bytes is 409; past the org's
        `symbolStorageBytes` it is 413. Nothing is written on any refusal.
      parameters:
        - { name: ref, in: path, required: true, description: "The artifact kind.", schema: { type: string, enum: [sourcemap-index, r8-mapping, symcache] } }
        - { name: release, in: query, schema: { type: string } }
        - { name: dist, in: query, schema: { type: string } }
        - { name: file, in: query, schema: { type: string } }
        - { name: debug_id, in: query, schema: { type: string } }
        - { name: map_id, in: query, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "201":
          description: Stored; the id of the association.
          content:
            application/json:
              schema: { type: object, required: [id], properties: { id: { type: string } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: The debug id was already uploaded with different content.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }
        "413":
          description: Too large as sent, too large once inflated, or over the org's symbol storage quota.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorMessage" }

    delete:
      tags: [Symbols]
      summary: Remove a symbol association (owner only); its artifact goes when nothing else points at it
      operationId: deleteSymbol
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: ref, in: path, required: true, description: "The association id.", schema: { type: integer } }
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/replays:
    get:
      tags: [Replays]
      summary: List a project's session replays, newest first
      operationId: listReplays
      security: [{ BearerAuth: [] }]
      parameters:
        - { $ref: "#/components/parameters/projectId" }
        - { name: distinctId, in: query, description: "Matches the whole identity cluster of that user.", schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [sessions, total]
                properties:
                  sessions: { type: array, items: { $ref: "#/components/schemas/ReplaySession" } }
                  total: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/replays/{sessionId}:
    get:
      tags: [Replays]
      summary: One session replay and its chunk list
      operationId: getReplay
      security: [{ BearerAuth: [] }]
      parameters:
        - { $ref: "#/components/parameters/projectId" }
        - { name: sessionId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/ReplaySession" }
                  - type: object
                    required: [chunks]
                    properties:
                      chunks:
                        type: array
                        items:
                          type: object
                          required: [seq, first_ts, last_ts, bytes]
                          properties:
                            seq: { type: integer }
                            first_ts: { type: integer }
                            last_ts: { type: integer }
                            bytes: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Replays]
      summary: Delete a session replay, chunk rows and blobs (owners)
      description: The session row stays 48 h as a tombstone; chunks sent for it meanwhile are dropped.
      operationId: deleteReplay
      security: [{ BearerAuth: [] }]
      parameters:
        - { $ref: "#/components/parameters/projectId" }
        - { name: sessionId, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/replays/{sessionId}/chunks/{seq}:
    get:
      tags: [Replays]
      summary: The rrweb events of one chunk (served gzip encoded)
      operationId: getReplayChunk
      security: [{ BearerAuth: [] }]
      parameters:
        - { $ref: "#/components/parameters/projectId" }
        - { name: sessionId, in: path, required: true, schema: { type: string } }
        - { name: seq, in: path, required: true, schema: { type: integer, minimum: 0 } }
      responses:
        "200":
          description: "The chunk's `events` array, sent with `Content-Encoding: gzip`."
          content:
            application/json:
              schema: { type: array, items: { type: object, additionalProperties: true } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/symbols:
    get:
      tags: [Symbols]
      summary: List the uploaded symbol associations of a project (org members)
      operationId: listSymbols
      security: [{ BearerAuth: [] }]
      parameters:
        - { $ref: "#/components/parameters/projectId" }
        - { name: release, in: query, schema: { type: string } }
        - { name: kind, in: query, schema: { type: string, enum: [sourcemap-index, r8-mapping, symcache] } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [symbols]
                properties:
                  symbols: { type: array, items: { $ref: "#/components/schemas/SymbolAssociation" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/symbols/releases:
    get:
      tags: [Symbols]
      summary: List the releases a project has symbols for, newest upload first (org members)
      operationId: listSymbolReleases
      security: [{ BearerAuth: [] }]
      parameters:
        - { $ref: "#/components/parameters/projectId" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [releases]
                properties:
                  releases: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/projects/{id}/upload-tokens:
    get:
      tags: [Symbols]
      summary: List a project's upload tokens (org members; never the secret)
      operationId: listUploadTokens
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [tokens]
                properties:
                  tokens: { type: array, items: { $ref: "#/components/schemas/UploadToken" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Symbols]
      summary: Mint an upload token (owner only); the only response that carries the secret
      operationId: createUploadToken
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
                expiresInDays: { type: integer, minimum: 1, maximum: 3650, description: "Omitted: the token never expires." }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required: [token, row]
                properties:
                  token: { type: string, description: "`chrly_upl_...`, shown once." }
                  row: { $ref: "#/components/schemas/UploadToken" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/projects/{id}/upload-tokens/{tokenId}:
    delete:
      tags: [Symbols]
      summary: Revoke an upload token (owner only)
      operationId: revokeUploadToken
      security: [{ BearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: tokenId, in: path, required: true, schema: { type: integer } }
      responses:
        "204": { description: Revoked. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /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" }
