openapi: 3.1.0
info:
  title: datahashi control-plane API
  version: 0.7.0
  description: |
    The **control plane** for datahashi — public self-serve SaaS management API around the
    datahashi data-plane engine. This is the **contract-first source of truth** (R4): handlers
    and generated clients follow this file; CI asserts no drift.

    Scope of this version: the **first slice (B1)** — magic-code identity, org lifecycle +
    multi-org membership, durable sealed warehouse connections (register/rotate/delete),
    data-plane credential (JWT) minting/revoke/rotate, per-tenant RLS + cost policy, and
    onboarding orchestration.

    Auth model:
    - **Product session** (`sessionAuth`, bearer) — the short-lived **access** token from
      `/v1/auth/verify` (or `/v1/auth/refresh`); authorizes the management endpoints below.
      Login also returns a long-lived **refresh** token used only at `/v1/auth/refresh` to mint
      a new access+refresh pair. Every org-scoped route enforces RLS (R1): the caller reaches
      only orgs they are a member of, and mutations are gated by role.
    - **Data-plane credential** — an API key minted by `/v1/orgs/{orgId}/api-keys` is an HS256
      JWT used against the *engine's* query API, not this control-plane API. Its `exp` is a
      short, bounded TTL; revoke stops listing/minting but a live token is valid until `exp`
      (no engine revocation list).

    This `/v1/*` surface + `/healthz` is the versioned contract the drift check enforces.
    Operational endpoints served by the runtime — `/readyz`, `/metrics`, `/docs`,
    `/openapi.yaml` — are deliberately **outside** this document (an explicit exempt set), not
    drift.

    Not covered here: billing, model-authoring UX, team invitations (later pillars).
  contact:
    name: datahashi
servers:
  - url: http://localhost:8080
    description: local dev
  - url: https://api.datahashi.example
    description: production (placeholder)

tags:
  - name: system
  - name: auth
  - name: orgs
  - name: members
  - name: connections
  - name: api-keys
  - name: policy
  - name: model
  - name: onboarding
  - name: rollups
  - name: invitations
  - name: admin
    description: |
      Operator support surface (`/v1/admin/*`). Separately authorized by an operator bearer
      (`operatorAuth`), NOT the product session — a product session token is rejected 401.
      Cross-org reads go through a dedicated system RLS path and never expose secrets. Opt-in:
      mounted only when the operator token set is configured.
  - name: internal
    description: |
      Service-to-service ingest surface (`/v1/internal/*`). Authorized by a service bearer
      (`serviceAuth`), NOT the product session — a product session token is rejected 401. Writes
      cross-org through a dedicated system RLS path. Opt-in: mounted only when the service token is
      configured.
  - name: metering
    description: |
      Org-scoped usage read (`GET /v1/orgs/{orgId}/usage`). Any member may read THEIR org's
      aggregated usage over a period, RLS-scoped through the org request path — never another
      tenant's. Counts and sums only; no query text, no event ids, no secrets.
  - name: audit
    description: |
      Org-scoped audit trail read (`GET /v1/orgs/{orgId}/audit`). Any member may list THEIR
      org's sensitive-action log (actor/action/target/ts), RLS-scoped through the org request
      path — never another tenant's. Non-secret identifiers only; no payload. The operator
      cross-org twin is `GET /v1/admin/audit` under the admin tag.
  - name: model-create
    description: |
      AI-assisted Semantic Model authoring from a registered connection (ai-model-from-connection).
      An org owner/admin starts a session, answers interview messages, confirms table scope,
      composes/refines a native-YAML draft, and saves it as a model draft only — never publish.
      GET is member-readable; mutations are admin-gated. Session payloads never include DSN or
      sealed credentials. Production catalog is empty until a real engine Cataloger is wired;
      the interview collects the table include list.
  - name: dashboards
    description: |
      Persisted, org-scoped Dashboards, each holding an ordered collection of Widgets
      (ai-charts-and-dashboards, Round 1). Any org member may create/read/reorder/delete —
      member-gated, not owner- or admin-gated. A widget's semantic_query/chart_config are opaque
      JSON: validated only for well-formedness, never run or rendered here — widgets are LIVE,
      re-run by the frontend against the engine on every dashboard view.
  - name: chart-suggestions
    description: |
      A stateless endpoint (ai-charts-and-dashboards) that asks the existing LLM provider to
      pick a chart from a closed visualization catalog (bar, grouped_bar, stacked_bar, line,
      multi_line, area, scatter, pie) and map columns to that type's field roles. The server
      materializes the pick into a Vega-Lite mark+encoding fragment (with fold transform for
      multi-metric series). The request sends annotation plus optional sample_rows/row_count
      for cardinality guidance — not a full result dump, no catalog cross-reference.
      Any org member may call it; it writes no row. Invalid LLM output is a generic typed
      error, never the raw completion text.
  - name: chat
    description: |
      Conversational query interface (ai-charts-and-dashboards, Round 3). A member sends a message
      and optional conversation history; the service generates a semantic query via LLM, executes it
      against the engine, suggests a chart, and returns the raw result with a human-readable summary.
      Stateless: the frontend holds all history and sends it on each request. The response includes
      the semantic_query (opaque JSON), result (columns + rows from the engine), chart config (Vega-Lite,
      or null if not suggested), and a summary message.

security:
  - sessionAuth: []

paths:
  /healthz:
    get:
      tags: [system]
      summary: Liveness probe
      security: []
      responses:
        "200":
          description: alive
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }

  /v1/auth/start:
    post:
      tags: [auth]
      summary: Start signup/login — issue a magic code
      description: |
        Unified signup + login. Issues a single-use, short-TTL code to the email and delivers
        it via the `EmailSender` port (R2) — **console/print** in dev, Resend in prod. Always
        returns 202 regardless of whether the email exists (no account-enumeration oracle).
        Rate-limited per email and per IP (R3).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AuthStartRequest" }
      responses:
        "202":
          description: code issued (if the address is deliverable)
        "429": { $ref: "#/components/responses/RateLimited" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/auth/verify:
    post:
      tags: [auth]
      summary: Verify a magic code — issue a product session
      description: |
        Consumes the code (single-use). On first successful verify for a new email, an
        **account + org (tenant) + owner membership** are auto-provisioned. Returns a short-lived
        **access** token (`session_token`, used as `sessionAuth`) plus a long-lived **refresh**
        token used only at `/v1/auth/refresh`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AuthVerifyRequest" }
      responses:
        "200":
          description: verified
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401":
          description: invalid, expired, or already-used code
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/auth/refresh:
    post:
      tags: [auth]
      summary: Rotate a refresh token — issue a new access + refresh pair
      description: |
        Single-use rotate: a valid refresh token returns a new `Session` (new access + new
        refresh) and revokes the presented refresh. Reuse of an already-rotated refresh revokes
        the entire token family and still returns an opaque 401. Refresh tokens cannot be used as
        `sessionAuth` bearers.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AuthRefreshRequest" }
      responses:
        "200":
          description: rotated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401":
          description: invalid, expired, or already-used refresh token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/me:
    get:
      tags: [auth]
      summary: Current account and its orgs
      responses:
        "200":
          description: the authenticated account + memberships
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/logout:
    post:
      tags: [auth]
      summary: Revoke the current product session
      responses:
        "204":
          description: session revoked (idempotent)
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/orgs:
    post:
      tags: [orgs]
      summary: Create an org
      description: |
        Creates a new org (tenant). The generated `id` is the canonical engine tenant id —
        immutable, unique, constrained charset. The caller becomes its `owner`. (Signup also
        auto-provisions a first org; this creates additional ones for the multi-org model.)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrgCreate" }
      responses:
        "201":
          description: created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Org" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: slug already taken
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/orgs/{orgId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [orgs]
      summary: Get an org
      responses:
        "200":
          description: org
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Org" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    patch:
      tags: [orgs]
      summary: Rename an org (display name only)
      description: The `id` (tenant id) is immutable; only the display `name` changes.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrgUpdate" }
      responses:
        "200":
          description: updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Org" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/orgs/{orgId}/members:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [members]
      summary: List org members
      responses:
        "200":
          description: members
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Member" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/orgs/{orgId}/members/{accountId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/AccountId"
    patch:
      tags: [members]
      summary: Change a member's role
      description: |
        Owner/admin only. Rejected if it would leave the org with zero owners (last-owner
        protection).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemberUpdate" }
      responses:
        "200":
          description: updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Member" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [members]
      summary: Remove a member (or leave, if self)
      description: Rejected if it would remove the last owner.
      responses:
        "204":
          description: removed
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/orgs/{orgId}/connections:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [connections]
      summary: List warehouse connections (safe descriptors)
      description: Returns descriptors only — backend + host. The sealed DSN is never returned.
      responses:
        "200":
          description: connections
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ConnectionDescriptor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [connections]
      summary: Register a warehouse connection
      description: |
        Validates + **egress-checks** the DSN host (SSRF policy), **probes** connectivity, then
        **seals** the DSN (AES-256-GCM, same key the engine opens) and persists it. The plaintext
        DSN is never returned or logged.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConnectionCreate" }
      responses:
        "201":
          description: registered
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectionDescriptor" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: a connection with that name already exists in the org (never a silent overwrite)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: DSN failed egress policy or connectivity probe
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/orgs/{orgId}/connections/{connectionId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ConnectionId"
    patch:
      tags: [connections]
      summary: Rename a connection
      description: |
        Updates the model-facing connection handle (`name`) only. Backend and sealed DSN are
        unchanged. Models that still reference the previous `source:` name will fail until their
        YAML is updated — this endpoint does not cascade. A name already taken in the org is 409.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConnectionUpdate" }
      responses:
        "200":
          description: renamed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectionDescriptor" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: a connection with that name already exists in the org
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [connections]
      summary: Delete/disable a connection
      responses:
        "204":
          description: deleted
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/connections/{connectionId}/rotate:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ConnectionId"
    post:
      tags: [connections]
      summary: Rotate a connection's DSN
      description: Re-egress-checks, re-probes, and re-seals a new DSN under the same connection id.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConnectionRotate" }
      responses:
        "200":
          description: rotated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectionDescriptor" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: new DSN failed egress policy or connectivity probe
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/orgs/{orgId}/api-keys:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [api-keys]
      summary: List data-plane API keys (no secret)
      responses:
        "200":
          description: keys
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [api-keys]
      summary: Mint a data-plane API key (HS256 JWT)
      description: |
        Mints a data-plane credential for the engine's query API. The secret is an HS256 JWT
        carrying only identity (`tenant`, `mode`) — RLS + cost stay server-side. **The secret is
        shown once** in this response and only its hash + prefix are stored.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApiKeyCreate" }
      responses:
        "201":
          description: created — secret shown once
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyCreated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/orgs/{orgId}/api-keys/{keyId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/KeyId"
    patch:
      tags: [api-keys]
      summary: Relabel an API key
      description: |
        Updates the human-facing `label` only. Mode and secret are immutable (rotate to change
        the credential). An empty label clears it. Revoked or unknown keys are 404.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApiKeyUpdate" }
      responses:
        "200":
          description: updated metadata (no secret)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKey" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [api-keys]
      summary: Revoke an API key
      description: |
        Delists the key and blocks future mint/rotate. The already-minted JWT stays valid at
        the engine until its `exp` (no engine revocation list) — the accepted, bounded-blast
        trade-off.
      responses:
        "204":
          description: revoked (idempotent)
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/api-keys/{keyId}/rotate:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/KeyId"
    post:
      tags: [api-keys]
      summary: Rotate an API key
      description: Mints a fresh token (secret shown once) and revokes the old one.
      responses:
        "201":
          description: rotated — new secret shown once
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyCreated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/policy:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [policy]
      summary: Get the tenant's data-plane cost policy
      responses:
        "200":
          description: policy
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Policy" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [policy]
      summary: Set the tenant's data-plane cost policy
      description: |
        The per-query cost caps the engine's gateway enforces on every query issued by this org's
        credentials (R1, layer 1). Server-side only — never carried in a token.

        Since v2 this is COST ONLY. RLS predicates are per-MODEL — see
        `PUT /v1/orgs/{orgId}/models/{name}/rls` (ADR-0013 #7): predicates name Dimensions, and
        Dimension names are model-scoped, so a per-tenant filter applied to an arbitrary model
        could silently restrict the wrong column. Cost is not dimension-specific, so it stays
        per-tenant.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Policy" }
      responses:
        "200":
          description: updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Policy" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/orgs/{orgId}/models/{name}/rls:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelName"
    get:
      tags: [policy]
      summary: Get a model's row-level-security predicates
      description: |
        The RLS predicates the engine appends to every query against THIS model — the engine's
        `RLS(tenant, model)` (ADR-0013 #7). A model with no predicates set reads as an empty list:
        deny-nothing, never another model's filters.
      responses:
        "200":
          description: the model's RLS predicates
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModelRls" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [policy]
      summary: Set a model's row-level-security predicates
      description: |
        Stores the RLS predicates for the model named by `{name}`, keyed `(orgId, name)`.

        Every predicate is validated against the Dimensions **that model declares**: a filter
        naming a Dimension the model does not define is rejected `422` and nothing is stored
        (ADR-0013 #7). This is the row-leak guard — Dimension names are model-scoped, so a
        predicate can only ever be authored against the model whose Dimensions it names, and can
        never silently restrict a different model's column of the same name. The model is
        addressed in the PATH and never in the body, so predicates meant for one model cannot land
        on another.

        A name with no published model is likewise `422`: with no Dimensions to validate against,
        the predicates are refused rather than stored unchecked. An empty list clears the model's
        RLS (deny-nothing). A malformed predicate (unknown op, wrong value arity) or a malformed
        model name is a `400`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ModelRls" }
      responses:
        "200":
          description: the stored predicates
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModelRls" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: >-
            a predicate names a Dimension the model does not declare, or the name has no published
            model to validate against — nothing is stored
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/orgs/{orgId}/models:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [model]
      summary: List the org's semantic models
      description: |
        Enumerates the org's distinct model names (ADR-0013 #3), each with its CURRENT published
        version and status — or, for a name that has never been published, its working draft's
        version and `status: draft` (so a fresh model is discoverable). Ordered by name. RLS-scoped;
        an org with no models returns an empty array.
      responses:
        "200":
          description: the org's models
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ModelSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/orgs/{orgId}/models/{name}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelName"
    get:
      tags: [model]
      summary: Get a named model's current published version
      description: |
        The current published version of the model NAMED {name} — the highest-version row with
        `status: published` for (orgId, name) (ADR-0013 #3). Prior published versions are retained as
        history but never returned here. A malformed name is 400; an unknown/unpublished name is 404.
        RLS-scoped.
      responses:
        "200":
          description: the named model's current published version
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [model]
      summary: Delete a named model — every version, draft + published + history
      description: |
        Removes EVERY row of the model NAMED {name} for this org (ADR-0013 #3) — the working draft,
        the current published version, and every retained prior published version — in one
        transaction. Name-scoped, not version-scoped: there is no partial delete of a single version.
        Blocked with 409 if the name is still referenced by any `rollups` row, or by a `model_rls`
        row whose RLS predicate is non-empty, for this org (no foreign key enforces this — both are
        plain text columns); the specific machine code (model_in_use) rides in Error.message along
        with the exact reference counts. A malformed name is 400; an absent or cross-org name is 404
        (RLS-scoped, indistinguishable from a foreign org's model). A published model deletes the
        same way as a draft — no extra confirmation beyond the normal request. Admin-gated + audited.
      responses:
        "204":
          description: deleted
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/ModelInUse" }

  /v1/orgs/{orgId}/models/{name}/draft:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelName"
    get:
      tags: [model]
      summary: Get a named model's working draft
      description: |
        The single working draft (`status: draft`) of the model NAMED {name} — at most one draft per
        (orgId, name). 400 on a malformed name; 404 when no draft exists for the name. RLS-scoped.
      responses:
        "200":
          description: the named model's working draft
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [model]
      summary: Create or replace a named model's working draft
      description: |
        Stores the supplied native-YAML model as the working draft of the model NAMED {name}. The
        YAML is validated with the engine's `model.Parse` first; an invalid model is rejected 422 and
        never stored. Replacing the name's draft keeps the same version; a fresh draft takes the next
        version for (orgId, name) — a name's version line is independent of every other name's
        (ADR-0013 #3). A malformed name is 400. Admin-gated + audited.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ModelDraft" }
      responses:
        "200":
          description: stored draft
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableModel" }

  /v1/orgs/{orgId}/models/{name}/publish:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelName"
    post:
      tags: [model]
      summary: Bless (publish) a named model's working draft
      description: |
        Flips the working draft of the model NAMED {name} to `status: published` at the highest
        version for (orgId, name) with `published_at` set; prior published versions of the name are
        retained as history. Because every step is scoped to (orgId, name), publishing one model
        never touches another (ADR-0013 #3). The draft is re-validated with the engine's `model.Parse`
        before publish (an invalid model is rejected 422). The model's `source:` binding is also
        validated (ADR-0013 #2, #5): the named connection must exist in the org and pass a re-run
        egress check, and a model that omits `source:` is rejected when the org holds more than one
        connection (ambiguous; no default) — every such rejection is a 422 and nothing is published.
        A malformed name is 400; 404 when there is no draft under the name to publish. Admin-gated +
        audited.
      responses:
        "200":
          description: the newly-published model
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableModel" }

  /v1/orgs/{orgId}/model:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [model]
      summary: Get the current published version of the "main" model
      description: |
        Back-compat alias for `GET /v1/orgs/{orgId}/models/main` (ADR-0013 #3). The current
        published model is the highest-version row with `status: published` for (orgId, "main")
        (B3). Prior published versions are retained as history but never returned here. RLS-scoped.
      responses:
        "200":
          description: current published model
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [model]
      summary: Delete the "main" model — every version, draft + published + history
      description: |
        Back-compat alias for `DELETE /v1/orgs/{orgId}/models/main` (ADR-0013 #3). Same semantics:
        removes every row of the "main" model in one transaction, blocked with 409 (model_in_use,
        Error.message carries the reference counts) if a `rollups` row or a non-empty `model_rls`
        row still references it, 404 if none exists. Admin-gated + audited.
      responses:
        "204":
          description: deleted
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/ModelInUse" }

  /v1/orgs/{orgId}/model/draft:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [model]
      summary: Get the "main" model's working draft
      description: |
        Back-compat alias for `GET /v1/orgs/{orgId}/models/main/draft` (ADR-0013 #3). The single
        working draft (`status: draft`) — the model a human is editing before blessing it (ADR-0006,
        UI-first). 404 when no draft exists. RLS-scoped.
      responses:
        "200":
          description: current draft model
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [model]
      summary: Create or replace the "main" model's working draft
      description: |
        Back-compat alias for `PUT /v1/orgs/{orgId}/models/main/draft` (ADR-0013 #3). Stores the
        supplied native-YAML model as the "main" model's single working draft (consume-introspection:
        an introspection-produced draft is supplied here and kept as a draft, never auto-published —
        B3 D3). The YAML is validated with the engine's `model.Parse` first; an invalid model is
        rejected 422 and never stored. Replacing the draft keeps the same version; a fresh draft
        takes the next version. Admin-gated + audited.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ModelDraft" }
      responses:
        "200":
          description: stored draft
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableModel" }

  /v1/orgs/{orgId}/model/publish:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    post:
      tags: [model]
      summary: Bless (publish) the "main" model's working draft
      description: |
        Back-compat alias for `POST /v1/orgs/{orgId}/models/main/publish` (ADR-0013 #3). Flips the
        "main" model's working draft to `status: published` at the highest version with
        `published_at` set; prior published versions are retained as history (B3 D4). The draft is
        re-validated with the engine's `model.Parse` before publish (an invalid model is rejected
        422 and never published). The model's `source:` binding is validated too (ADR-0013 #2, #5):
        a `source:` naming a missing connection or an egress-blocked host — or an omitted `source:`
        when the org holds more than one connection — is rejected 422 and nothing is published.
        404 when there is no draft to publish. Admin-gated + audited.
      responses:
        "200":
          description: the newly-published model
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableModel" }

  /v1/orgs/{orgId}/onboarding:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    post:
      tags: [onboarding]
      summary: Run onboarding orchestration
      description: |
        Chains the runtime provisioning loop: register connection → (enqueue introspect) →
        persist draft model → register rollups → (enqueue build). Idempotent per
        `Idempotency-Key` (R3). Job enqueue may be stubbed until the engine reads the shared DB.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OnboardingRequest" }
      responses:
        "202":
          description: accepted — orchestration started
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OnboardingStatus" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/orgs/{orgId}/onboarding/{onboardingId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/OnboardingId"
    get:
      tags: [onboarding]
      summary: Get onboarding run status
      responses:
        "200":
          description: status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OnboardingStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/models/{name}/rollups:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelName"
    get:
      tags: [rollups]
      summary: List the model's registered rollups with their build state
      description: |
        The rollups registered under the model named `{name}` (ADR-0013 #3) — rollups are scoped to
        `(orgId, name)`, so this never returns another model's rollups. Each rollup's `build_state`
        is derived from the shared `river_job` lifecycle (B2) — the latest `rollup_build` job
        enqueued for it: `none` (never enqueued) / `queued` / `running` / `built` / `failed`.
        RLS-scoped: a caller only ever sees this org's own registered rollups. A malformed `{name}`
        is a 400.
      responses:
        "200":
          description: the model's rollups
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RollupStatus" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [rollups]
      summary: Register a rollup spec under a model
      description: |
        Registers a new rollup spec `{id, measures[], dimensions[{name,grain}], grain}` under the
        model named `{name}` (RM-AC1, ADR-0013 #3) — `id` is the caller-facing rollup_id, unique
        within `(orgId, name)`. Because the uniqueness key includes the model, a DIFFERENT model may
        hold a rollup with the same `id`; a repeat `id` under THIS model is 409.

        The spec is validated to resolve against the **published** model named `{name}` (RM-AC2):
        every measure names a declared measure, every dimension a declared dimension, and every
        grain a supported grain — so a rollup is never validated against a different model bound to
        another source. A spec that does not resolve, or a `{name}` with no published model
        (including an unknown name), is rejected 422 and nothing is stored. A malformed `{name}` is a
        400. Admin-gated + audited.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RollupSpec" }
      responses:
        "201":
          description: the registered rollup with its build state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RollupStatus" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableRollup" }

  /v1/orgs/{orgId}/models/{name}/rollups/{rollupId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelName"
      - $ref: "#/components/parameters/RollupId"
    put:
      tags: [rollups]
      summary: Replace a rollup spec
      description: |
        Replaces the measures/dimensions/grain of the rollup identified by `({name}, {rollupId})`
        (RM-AC1); the rollup_id itself is not renamed. The spec is re-validated to resolve against
        the published model named `{name}` (RM-AC2); an unresolved spec is rejected 422 and the
        update is rolled back. 404 when no such rollup exists under this model — a rollup with the
        same id under a DIFFERENT model is untouched. A malformed `{name}` is a 400. Admin-gated +
        audited.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RollupSpec" }
      responses:
        "200":
          description: the updated rollup with its build state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RollupStatus" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableRollup" }
    delete:
      tags: [rollups]
      summary: Delete a rollup spec
      description: |
        Deletes the rollup identified by `({name}, {rollupId})` (RM-AC1). 404 when no such rollup
        exists under this model in this org (indistinguishable from a cross-org id — never discloses
        one); a rollup with the same id under a DIFFERENT model is untouched. Any already-queued
        `rollup_build` job is left on the shared queue and "unknown rollup"s at the engine. A
        malformed `{name}` is a 400. Admin-gated + audited.
      responses:
        "204": { description: deleted }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/models/{name}/rollups/{rollupId}/build:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelName"
      - $ref: "#/components/parameters/RollupId"
    post:
      tags: [rollups]
      summary: Trigger a build/refresh of a rollup
      description: |
        Enqueues a `rollup_build` job for the rollup identified by `({name}, {rollupId})` on the
        shared `river_job` queue (RM-AC3) — the engine worker builds it out of process (no
        in-process build). The job keys on (tenant, rollup) and is idempotent by river ByArgs: a
        repeat trigger while a build is in flight collapses onto the one durable job. 404 when no
        such rollup exists under this model in this org — only a registered rollup can be built. A
        malformed `{name}` is a 400. Admin-gated + audited.
      responses:
        "202": { description: accepted — a build/refresh was enqueued }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/model-create-sessions:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    post:
      tags: [model-create]
      summary: Start an AI model-create session from a connection
      description: |
        Creates a `model_create_conversations` row plus a `model_create_sessions` sidecar
        bound to an existing connection in the org. Initial phase is `interviewing`; no model row
        is written. A missing or cross-org `connection_name` is 404 with nothing persisted.
        Admin-gated (owner/admin). Session payloads never include DSN or sealed credentials.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateModelCreateSessionRequest" }
      responses:
        "201":
          description: the created model-create session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModelCreateSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/model-create-sessions/{sessionId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelCreateSessionId"
    get:
      tags: [model-create]
      summary: Get a model-create session
      description: |
        Returns the session's connection_name, phase, scope (if confirmed), light catalog (table
        names only), proposed model name / draft YAML when present, and optional interview
        transcript (`?include=messages`). Never includes DSN or sealed credentials. Member-readable
        within the org; an unknown or cross-org `sessionId` is 404.
      parameters:
        - name: include
          in: query
          required: false
          description: Set to `messages` to include the interview transcript.
          schema: { type: string, enum: [messages] }
      responses:
        "200":
          description: the session (with messages when requested)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModelCreateSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/model-create-sessions/{sessionId}/messages:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelCreateSessionId"
    post:
      tags: [model-create]
      summary: Post an interview message on a model-create session
      description: |
        Durably commits a user interview turn on the session's conversation before any agent
        follow-up. Prefer structured `answer` (multi-choice + optional free_text). Legacy
        `{content}` free-text remains accepted when the current question allows free text.
        Returns the updated session (including `current_question` / `intent` / `interview_status`).
        Admin-gated.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PostModelCreateMessageRequest" }
      responses:
        "200":
          description: the updated session after the user turn
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModelCreateSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/model-create-sessions/{sessionId}/scope:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelCreateSessionId"
    post:
      tags: [model-create]
      summary: Confirm table scope for a model-create session
      description: |
        Stores the include/exclude table list and advances phase to `scoping_confirmed`. Compose
        and save refuse until scope is confirmed. Admin-gated.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ModelCreateScope" }
      responses:
        "200":
          description: the updated session with confirmed scope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModelCreateSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/model-create-sessions/{sessionId}/compose:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelCreateSessionId"
    post:
      tags: [model-create]
      summary: Build model draft (designer → parse → reviewer)
      description: |
        Explicit Build: AI designer emits native-YAML from durable intent + in-scope tables,
        mechanical `model.Parse` (+ one repair), then AI reviewer against intent (bounded redesign).
        Stores `draft_yaml` on the session; phase `ready` on success. Without confirmed scope the
        call is 422. Never publishes. Admin-gated. Without an LLM, uses deterministic template +
        stub review.
      responses:
        "200":
          description: the session with draft_yaml and phase ready or composing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModelCreateSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableModel" }

  /v1/orgs/{orgId}/model-create-sessions/{sessionId}/refine:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelCreateSessionId"
    post:
      tags: [model-create]
      summary: Refine the session draft with a natural-language instruction
      description: |
        Patches the current draft YAML from an NL instruction (e.g. rename), re-validates with
        `model.Parse`, and replaces the session draft. Requires an existing draft. Never publishes.
        Admin-gated.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefineModelCreateSessionRequest" }
      responses:
        "200":
          description: the session with the refined draft
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModelCreateSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/model-create-sessions/{sessionId}/save:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/ModelCreateSessionId"
    post:
      tags: [model-create]
      summary: Save the session draft as a named model draft (never publish)
      description: |
        Calls `model.Service.SaveDraft` with the confirmed name and session YAML (injecting
        `source: <connection_name>` if needed). On success the session phase becomes `saved` and
        the body carries `{session, model}` — a **draft** only. There is no publish path on this
        surface; publish remains `POST .../models/{name}/publish`. Invalid name is 400; invalid
        YAML is 422; phase not ready is 400. Admin-gated.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SaveModelCreateSessionRequest" }
      responses:
        "200":
          description: updated session (phase saved) and the stored model draft
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SaveModelCreateSessionResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableModel" }

  /v1/orgs/{orgId}/invitations:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [invitations]
      summary: List the org's pending invitations
      description: |
        The org's outstanding invitations (`status: pending`) — never the `code_hash` (only the
        emailed plaintext exists, and it is never stored). Admin-gated + RLS-scoped.
      responses:
        "200":
          description: pending invitations
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Invitation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [invitations]
      summary: Invite an email into the org with a role
      description: |
        Creates a pending invitation for `{email, role}` and delivers a single-use, TTL'd code via
        the email port (INV-AC1/2). Only the code's hash is stored. Idempotent per (org, email): a
        repeat while a pending invite exists is a 409 (INV-AC5) — no duplicate pending row. Admin-gated
        + audited.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvitationCreate" }
      responses:
        "201":
          description: created — a code was emailed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invitation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /v1/orgs/{orgId}/invitations/{invitationId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/InvitationId"
    delete:
      tags: [invitations]
      summary: Revoke a pending invitation
      description: |
        Marks the invitation `revoked` so it can no longer be accepted (INV-AC4). Admin-gated. An
        unknown, already-consumed, already-revoked, or cross-org id is a 404 — indistinguishable, so
        a caller cannot probe another org's invitations or re-revoke.
      responses:
        "204":
          description: revoked
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/invitations/accept:
    post:
      tags: [invitations]
      summary: Accept an invitation via magic-code identity
      description: |
        Consumes a pending invitation by its plaintext `code` and creates a membership with the
        invited role for the authenticated caller (INV-AC3). Session-gated only — NOT org-scoped: the
        invitee is not a member yet, and the org is learned from the invite. Acceptance is bound to
        identity — the caller's account email MUST equal the invited email (else 403). A wrong,
        expired, revoked, or already-consumed code is a generic 404/410 with no enumeration oracle.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvitationAccept" }
      responses:
        "200":
          description: accepted — a membership was created with the invited role
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InvitationAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410": { $ref: "#/components/responses/Gone" }

  /v1/orgs/{orgId}/usage:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [metering]
      summary: Read this org's aggregated usage over a period
      description: |
        Aggregates the org's `usage_events` into per-period buckets over a half-open `[from, to)`
        window: summed `rows` and `bytes`, the total event count, and the event counts broken down
        by `routing` and by `outcome`. `granularity` (`day` | `month`) sets the bucket size. RLS-
        scoped through the org request path — a member only ever sees THEIR org's usage, never
        another tenant's, even for the same period. Any member may read (member-gated); the body
        carries counts and sums only — no query text, no event ids, no secrets. `from` defaults to
        `to` − 30 days and `to` to now; an unknown granularity, a malformed timestamp, or a range
        that would produce more than 366 buckets is 400. An empty window returns `buckets: []`.
      parameters:
        - name: from
          in: query
          required: false
          description: Inclusive lower `ts` bound (RFC3339). Defaults to `to` − 30 days.
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: false
          description: Exclusive upper `ts` bound (RFC3339). Defaults to now.
          schema: { type: string, format: date-time }
        - name: granularity
          in: query
          required: false
          description: Bucket size — the period each bucket aggregates.
          schema: { type: string, enum: [day, month], default: day }
      responses:
        "200":
          description: the per-period usage aggregation for this org
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageReport" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }


  /v1/orgs/{orgId}/audit:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [audit]
      summary: List this org's audit trail (member, filtered, paged)
      description: |
        Returns this org's `audit_log` rows — id, org_id, actor, action, target, ts — newest-first,
        read through the org request RLS path (not the operator system path): a member only ever
        sees THEIR org's trail. Optional exact-match filters (`actor`, `action`) and a half-open
        time window (`from` inclusive, `to` exclusive) narrow the set. Keyset paging: `limit` rows
        per page (default 50, clamped to 200) and an opaque `cursor` (the previous page's last
        `id`) walk `id` descending with no overlap or gaps; `next_cursor` is set when a full page
        was returned and null on the last page. Any member may read (member-gated). The body
        carries only non-secret audit identifiers — the audit base records no payload. Path
        `{orgId}` is the sole tenant bound (there is no `org_id` query param).
      parameters:
        - name: actor
          in: query
          required: false
          description: Exact-match actor filter (an account id, or `operator:<id>` for admin actions).
          schema: { type: string }
        - name: action
          in: query
          required: false
          description: Exact-match action filter — a stable dotted verb, e.g. `connection.register`.
          schema: { type: string }
        - name: from
          in: query
          required: false
          description: Inclusive lower `ts` bound (RFC3339).
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: false
          description: Exclusive upper `ts` bound (RFC3339).
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          required: false
          description: Max rows per page — default 50, clamped to a maximum of 200.
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          description: Opaque keyset cursor — the previous page's last `id`. A malformed cursor is 400.
          schema: { type: string }
      responses:
        "200":
          description: the matching audit page for this org, newest-first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/orgs/{orgId}/subscription:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [billing]
      summary: Get this org's current subscription
      description: |
        The org's current plan binding — plan id, status, and the current billing period. Plans are
        a static in-code catalog (`free` | `pro` | `enterprise`); this returns only the commercial
        fact, never the enforcement values (the plan's cost caps live in the org's policy). Any
        member may read (member-gated). 404 when the org has never subscribed. RLS-scoped.
      responses:
        "200":
          description: the org's current subscription
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [billing]
      summary: Subscribe this org to a plan
      description: |
        Puts the org onto the named plan for a fresh billing period and maps the plan's cost caps
        onto the org's data-plane policy, PRESERVING the org's existing RLS predicates (Get → merge
        cost → Set, since the policy write overwrites the whole row). The action is audited
        (`billing.subscribe`); the policy mapping writes its own `policy.update` audit row.
        Owner/admin-gated. An unknown `plan_id` (outside the static catalog) is 422 with nothing
        written. Re-subscribing rolls the plan/period forward on the single subscription row.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubscriptionUpdate" }
      responses:
        "200":
          description: the updated subscription
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: the plan_id is not in the static catalog; nothing was written
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/orgs/{orgId}/invoices:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    post:
      tags: [billing]
      summary: Build this org's usage invoice for a period
      description: |
        Builds — on demand, nothing stored — the org's usage invoice for a half-open `[from, to)`
        period: one line item per usage metric (`rows` | `bytes` | `events`), each carrying the
        QUANTITY summed from metering's aggregation over the period. Usage quantities only — no
        pricing (amounts/tax/proration/dunning are out of scope). `plan_id` is the org's current plan,
        or empty when it has no subscription. POST (not GET) because building an invoice asks the
        payment provider to sync it — a side effect. Owner/admin-gated. A missing or non-ascending
        `from`/`to` range is 400. RLS-scoped — the usage is read only for this org.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvoiceRequest" }
      responses:
        "200":
          description: the org's usage invoice for the period
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/orgs/{orgId}/webhooks:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [notifications]
      summary: List this org's webhooks (no secrets)
      description: |
        Returns the org's registered webhooks — id, url, event mask, status, created_at. The signing
        secret is **never** included (it is shown once at registration and sealed at rest). Any member
        may read (member-gated). RLS-scoped — only THIS org's webhooks.
      responses:
        "200":
          description: this org's webhooks
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [notifications]
      summary: Register a webhook (ownership-verified, SSRF-guarded)
      description: |
        Registers an endpoint to receive signed event deliveries. Before any row is persisted the
        destination URL's host is checked against the egress/SSRF policy (metadata/loopback/private are
        rejected) and the endpoint's **ownership is verified**: the server sends a signed confirmation
        ping the endpoint must answer `2xx`. Only then is a `webhooks` row created with a per-webhook
        HMAC-SHA256 signing **secret shown once** in this response (sealed at rest, never re-returned).
        A host blocked by the SSRF policy, or an endpoint that does not confirm, is `422` with nothing
        written. Owner/admin-gated.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookCreate" }
      responses:
        "201":
          description: registered — signing secret shown once
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookCreated" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: the host is blocked by the SSRF policy, or the endpoint did not confirm ownership; nothing was written
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/orgs/{orgId}/webhooks/{webhookId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/WebhookId"
    get:
      tags: [notifications]
      summary: Read one webhook (no secret)
      description: The webhook's id, url, event mask, status, created_at — never the signing secret. Any member may read. RLS-scoped; an unknown id is 404.
      responses:
        "200":
          description: the webhook
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [notifications]
      summary: Delete a webhook
      description: Removes the webhook so it receives no further deliveries. Owner/admin-gated. RLS-scoped; an unknown id is 404.
      responses:
        "204":
          description: deleted
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/webhooks/{webhookId}/deliveries:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/WebhookId"
    get:
      tags: [notifications]
      summary: Read a webhook's delivery log
      description: |
        Returns the webhook's recent delivery attempts — id, event, status (pending|delivered|failed),
        attempt count, last HTTP status, next scheduled attempt, and timestamps — newest first. The
        signed payload and the signing secret are **never** included. Any member may read (member-gated).
        RLS-scoped; a webhookId not in this org is 404.
      responses:
        "200":
          description: the webhook's recent deliveries
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Delivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/dashboards:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    get:
      tags: [dashboards]
      summary: List the org's dashboards
      description: |
        Returns every dashboard registered for this org — id, name, created_at, updated_at (no
        widgets; use GET .../dashboards/{dashboardId} for the full detail with widgets). RLS-scoped:
        another org's dashboards, even with a guessed id, never appear. Any member may read.
      responses:
        "200":
          description: this org's dashboards
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Dashboard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [dashboards]
      summary: Create a dashboard
      description: |
        Creates a dashboard `{name}` owned by the caller (account_id) in this org. Audited
        (`dashboard.create`). Any member may create — dashboards are org-scoped, not
        owner-scoped; any member may later read/edit/delete it.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DashboardCreate" }
      responses:
        "201":
          description: the created dashboard
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dashboard" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/orgs/{orgId}/dashboards/{dashboardId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/DashboardId"
    get:
      tags: [dashboards]
      summary: Read a dashboard and its widgets
      description: |
        Returns the dashboard plus its widgets, ordered by `position`. Widgets are LIVE
        references — no cached/snapshotted result rows; the frontend re-runs each widget's
        semantic_query against the engine on every view (same call path Playground already uses).
        RLS-scoped: a cross-org or unknown id is 404.
      responses:
        "200":
          description: the dashboard with its widgets
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [dashboards]
      summary: Rename a dashboard
      description: |
        Updates the dashboard's display `name` only. RLS-scoped: a cross-org or unknown id is 404.
        Audited (`dashboard.rename`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DashboardUpdate" }
      responses:
        "200":
          description: the renamed dashboard
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dashboard" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [dashboards]
      summary: Delete a dashboard
      description: |
        Deletes the dashboard and all its widgets (`ON DELETE CASCADE`). Audited
        (`dashboard.delete`). RLS-scoped: a cross-org or unknown id is 404.
      responses:
        "204": { description: deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/dashboards/{dashboardId}/widgets:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/DashboardId"
    post:
      tags: [dashboards]
      summary: Append a widget to a dashboard
      description: |
        Creates a widget `{title, semantic_query, chart_config}` on the dashboard, at
        `position = max(existing position for this dashboard) + 1` (0 for the first widget).
        semantic_query and chart_config are opaque JSON — validated only for well-formedness,
        never a fixed shape (this endpoint never runs the query or renders the chart). RLS-scoped:
        an unknown or cross-org dashboardId is 404.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WidgetCreate" }
      responses:
        "201":
          description: the created widget
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Widget" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/dashboards/{dashboardId}/widgets/{widgetId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - $ref: "#/components/parameters/DashboardId"
      - $ref: "#/components/parameters/WidgetId"
    patch:
      tags: [dashboards]
      summary: Update a widget (title and/or position)
      description: |
        Partial update: set `title` to rename, `position` to reorder, or both. Omitted fields are
        left unchanged; at least one field is required. Other widgets' positions are left
        untouched when only one widget is reordered. RLS-scoped: an unknown widget, or one that
        is not a child of {dashboardId}, or cross-org, is 404.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WidgetUpdate" }
      responses:
        "200":
          description: the updated widget
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Widget" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [dashboards]
      summary: Delete a widget
      description: |
        Hard-deletes the widget. Audited (`dashboard.widget.delete`). RLS-scoped: an unknown
        widget, or one that is not a child of {dashboardId}, or cross-org, is 404.
      responses:
        "204": { description: deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/chart-suggestions:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    post:
      tags: [chart-suggestions]
      summary: Suggest a chart for a query result
      description: |
        Given a SemanticQuery (opaque), QueryResult annotation, and optional sample_rows /
        row_count, asks the LLM to pick a catalog chart_type and field roles, then materializes
        a validated Vega-Lite mark+encoding (+ optional fold transform) fragment. Encoding
        fields must name annotation columns or synthetic fold fields (__series__/__value__).
        Stateless: no row is written. Invalid LLM output is a generic typed 500 — never the
        raw completion text.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChartSuggestionRequest" }
      responses:
        "200":
          description: the suggested chart config
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChartConfig" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/SuggestionUnavailable" }

  /v1/orgs/{orgId}/chat:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    post:
      tags: [chat]
      summary: Conversational query interface (sync)
      description: |
        Accept a user message and optional conversation history, generate a semantic query via LLM,
        execute it against the engine, suggest a chart, and return the result with a summary.
        Shares implementation with `/chat/stream`. Stream contract versions:
        console baseline v0.1 (`docs/features/insights-a1-trust/chat-stream-v0.1.md`),
        **public freeze v1.0** (`docs/features/analytics-chat-api/chat-stream-v1.0.md`).
        Auth: product session (org member) **or** governed data-plane API key (`tenant`=orgId).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChatRequest" }
      responses:
        "200":
          description: the query result and suggested chart
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/BadRequest" }
        "500": { $ref: "#/components/responses/InternalServerError" }

  /v1/orgs/{orgId}/chat/stream:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    post:
      tags: [chat]
      summary: Conversational query interface (SSE stream)
      description: |
        Same body as POST `/chat`. Streams `event: status` phases, then `event: result` (full
        ChatResponse) and `event: done`, or `event: error`. Emits `stream_id` on status events
        for use with POST `/chat/abort`. **Public contract:** stream **v1.0**
        (`docs/features/analytics-chat-api/chat-stream-v1.0.md`); console baseline remains
        v0.1.x under `docs/features/insights-a1-trust/`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChatRequest" }
      responses:
        "200":
          description: text/event-stream of status | result | done | error events
          content:
            text/event-stream:
              schema:
                type: string
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/BadRequest" }

  /v1/orgs/{orgId}/chat/abort:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    post:
      tags: [chat]
      summary: Abort an in-flight chat stream
      description: |
        Cancels a stream previously started via POST `/chat/stream` using the `stream_id`
        from status events. Idempotent not-found when the stream already finished.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [stream_id]
              properties:
                stream_id: { type: string }
      responses:
        "200":
          description: aborted
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  stream_id: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/orgs/{orgId}/chat/conversations/cleanup-empty:
    parameters:
      - $ref: "#/components/parameters/OrgId"
    post:
      tags: [chat]
      summary: Delete empty conversation shells
      description: |
        Removes conversations that have zero messages (orphan rows from older non-atomic saves).
        Session auth required (org member).
      responses:
        "200":
          description: cleanup result
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  deleted: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/admin/orgs:
    get:
      tags: [admin]
      summary: List all tenants (operator, cross-org, no secrets)
      description: |
        Returns every org — id, slug, name, created_at, suspended state, member count —
        regardless of org membership, read through the dedicated system RLS path (not the org
        policy). Operator-authorized (`operatorAuth`), not the product session. The body carries
        only safe summary fields; no secret (DSN, sealed material, JWT/key, magic code, session
        token) is ever included.
      security:
        - operatorAuth: []
      responses:
        "200":
          description: all tenants
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AdminOrgSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/admin/orgs/{orgId}:
    get:
      tags: [admin]
      summary: Inspect one tenant (operator, cross-org, no secrets)
      description: |
        Returns one org's operator support view — connection descriptors (no DSN/sealed bytes),
        api-key metadata (id/prefix/mode/created/revoked — never the JWT or its hash), the tenant
        policy (RLS predicates + cost caps), the current published model's status, and the org's
        rollups with build state — read cross-org through the dedicated system RLS path (not the
        org policy). Operator-authorized (`operatorAuth`), not the product session. The body carries
        only safe/redacted fields; no secret (DSN, sealed material, JWT/key hash, magic code,
        session token) is ever included. An unknown orgId is 404.
      security:
        - operatorAuth: []
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        "200":
          description: the tenant's support view
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrgInspect" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/admin/orgs/{orgId}/suspend:
    post:
      tags: [admin]
      summary: Suspend a tenant (operator)
      description: |
        Marks the org suspended (`orgs.suspended_at`). A subsequent PRODUCT request scoped to that
        org (a member's session on any `/v1/orgs/{orgId}/...` route) is then rejected `403` with a
        machine-distinct `org_suspended` reason at org-context resolution; admin routes still
        operate on it. Idempotent — suspending an already-suspended org is `200`, not an error.
        Writes an `audit_log` row (`operator:<id>` / `admin.suspend` / `orgId`). Operator-authorized
        (`operatorAuth`), not the product session.
      security:
        - operatorAuth: []
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        "200":
          description: the tenant's summary, now suspended
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrgSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/admin/orgs/{orgId}/reactivate:
    post:
      tags: [admin]
      summary: Reactivate a suspended tenant (operator)
      description: |
        Clears the org's suspension (`orgs.suspended_at = NULL`); the org's product routes work
        again. Idempotent — reactivating an active org is `200`, not an error. Writes an `audit_log`
        row (`operator:<id>` / `admin.reactivate` / `orgId`). Operator-authorized (`operatorAuth`),
        not the product session.
      security:
        - operatorAuth: []
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        "200":
          description: the tenant's summary, now active
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrgSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/admin/orgs/{orgId}/impersonate:
    post:
      tags: [admin]
      summary: Impersonate a member for support (operator, audited)
      description: |
        Mints a SHORT-TTL product session acting as the target `account_id`, provided that account is
        a member of the org. The `admin.impersonate` `audit_log` row (`operator:<id>` / target =
        `account_id`) is written BEFORE the token is returned. A non-member `account_id` is `404` and
        mints nothing. The returned session is a real, server-side-revocable product session with a
        short expiry. Operator-authorized (`operatorAuth`), not the product session.
      security:
        - operatorAuth: []
      parameters:
        - $ref: "#/components/parameters/OrgId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdminImpersonateRequest" }
      responses:
        "200":
          description: a short-TTL product session acting as the impersonated member
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminImpersonateResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/admin/audit:
    get:
      tags: [admin]
      summary: Query the audit log (operator, cross-org, filterable, paged)
      description: |
        Returns `audit_log` rows across ALL tenants — id, org_id, actor, action, target, ts —
        newest-first, read through the dedicated system RLS path (not the org policy): a request
        with no org context still returns rows, which is the cross-org proof. Optional exact-match
        filters (`org_id`, `actor`, `action`) and a half-open time window (`from` inclusive, `to`
        exclusive) narrow the set. Keyset paging: `limit` rows per page (default 50, clamped to 200)
        and an opaque `cursor` (the previous page's last `id`) walk `id` descending with no overlap
        or gaps; `next_cursor` is set when a full page was returned and null on the last page.
        Operator-authorized (`operatorAuth`), not the product session. The body carries only
        non-secret audit identifiers — the audit base records no payload.
      security:
        - operatorAuth: []
      parameters:
        - name: org_id
          in: query
          required: false
          description: Exact-match tenant filter.
          schema: { type: string }
        - name: actor
          in: query
          required: false
          description: Exact-match actor filter (an account id, or `operator:<id>` for admin actions).
          schema: { type: string }
        - name: action
          in: query
          required: false
          description: Exact-match action filter — a stable dotted verb, e.g. `admin.suspend`.
          schema: { type: string }
        - name: from
          in: query
          required: false
          description: Inclusive lower `ts` bound (RFC3339).
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: false
          description: Exclusive upper `ts` bound (RFC3339).
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          required: false
          description: Max rows per page — default 50, clamped to a maximum of 200.
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          description: Opaque keyset cursor — the previous page's last `id`. A malformed cursor is 400.
          schema: { type: string }
      responses:
        "200":
          description: the matching audit page, newest-first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminAuditPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/internal/usage:
    post:
      tags: [internal]
      summary: Ingest a batch of usage events (service, idempotent, cross-org)
      description: |
        Writes a batch of the engine's per-query telemetry into `usage_events` — one row per event
        (`org_id`, `event_id`, `ts`, `rows`, `bytes`, `routing`, `outcome`). Idempotent on
        `(org_id, event_id)`: a re-delivered event (engine retry or restart) writes no duplicate row
        and is reported as a duplicate, not double-counted. A batch may span MULTIPLE orgs — every
        event is written in one transaction through a dedicated system RLS path, not an org request
        context. Authorized by a service bearer (`serviceAuth`), NOT the product session — a session
        token is rejected 401. The batch is bounded (1..1000 events); a malformed batch is 400 with
        nothing written, and an event naming an unknown org is 422 with the whole batch rolled back.
        Opt-in: mounted only when the service token is configured.
      security:
        - serviceAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UsageIngestRequest" }
      responses:
        "202":
          description: the batch was accepted; counts of newly-written vs duplicate events
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageIngestResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: an event names an org that does not exist; nothing was written
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

components:
  securitySchemes:
    sessionAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: Product session token from `/v1/auth/verify`.
    operatorAuth:
      type: http
      scheme: bearer
      bearerFormat: operatorId:secret
      description: |
        Operator bearer for the admin surface — `operatorId:secret`, provisioned out-of-band via
        `DATAHASHI_ADMIN_TOKENS`. Distinct from the product session; a session token is rejected 401.
    serviceAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: |
        Service bearer for the metering ingest surface — a single shared secret provisioned via
        `DATAHASHI_METERING_TOKEN`. Distinct from the product session; a session token is rejected 401.

  parameters:
    OrgId:
      name: orgId
      in: path
      required: true
      description: Org (tenant) id — the canonical engine tenant id. The caller must be a member (RLS).
      schema: { type: string, pattern: "^[a-z0-9][a-z0-9-]{1,62}$" }
    AccountId:
      name: accountId
      in: path
      required: true
      schema: { type: string }
    ConnectionId:
      name: connectionId
      in: path
      required: true
      schema: { type: string }
    KeyId:
      name: keyId
      in: path
      required: true
      schema: { type: string }
    RollupId:
      name: rollupId
      in: path
      required: true
      description: >-
        caller-facing rollup_id — targets update/delete/build. Unique within (orgId, model_name)
        since v2 (ADR-0013 #3), NOT per org, so it is always addressed under its model's {name}.
      schema: { type: string }
    OnboardingId:
      name: onboardingId
      in: path
      required: true
      schema: { type: string }
    ModelCreateSessionId:
      name: sessionId
      in: path
      required: true
      description: >-
        model-create session id (equals the underlying conversation id). RLS-scoped; a
        cross-org id is indistinguishable from unknown (404).
      schema: { type: string }
    InvitationId:
      name: invitationId
      in: path
      required: true
      description: invitation id (inv_ prefix) — targets revoke.
      schema: { type: string }
    WebhookId:
      name: webhookId
      in: path
      required: true
      description: webhook id (wh_ prefix) — targets read/delete.
      schema: { type: string }
    DashboardId:
      name: dashboardId
      in: path
      required: true
      description: >-
        dashboard id (dash_ prefix). RLS-scoped; a cross-org id is indistinguishable from
        unknown (404).
      schema: { type: string }
    WidgetId:
      name: widgetId
      in: path
      required: true
      description: >-
        widget id (wgt_ prefix), scoped to its parent {dashboardId}. RLS-scoped; a cross-org id,
        or one that is not a child of {dashboardId}, is indistinguishable from unknown (404).
      schema: { type: string }
    ModelName:
      name: name
      in: path
      required: true
      description: >-
        model name — the tenant-scoped handle a model is addressed by (ADR-0013 #3). The
        draft/published lifecycle keys on (orgId, name). A malformed name is a 400. "main" is the
        back-compat default the singular /model routes alias.
      schema: { type: string, pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$" }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string }

  responses:
    BadRequest:
      description: malformed request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: missing or invalid session
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: authenticated but not a member of this org, or role too low (RLS)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: resource not found in this org
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: too many requests
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UnprocessableModel:
      description: the model YAML is well-formed JSON but fails semantic-model validation (model.Parse)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UnprocessableRollup:
      description: >-
        the rollup spec does not resolve against the org's published model (or none is published) —
        the specific machine code (rollup_unresolved / no_published_model) rides in Error.message.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: the resource already exists (e.g. a rollup_id already registered in this org)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ModelInUse:
      description: >-
        the model name is still referenced by at least one rollup or non-empty RLS predicate in this
        org — delete is blocked, never cascaded; the specific machine code (model_in_use) rides in
        Error.message, along with the exact reference counts.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Gone:
      description: >-
        the target is no longer actionable — an invitation that is expired, revoked, or already
        accepted cannot be accepted again (INV-AC4).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    SuggestionUnavailable:
      description: >-
        the LLM's chart suggestion failed validation (an unrecognized mark/channel/type, or a
        field not present in the request's own annotation) or the provider call itself failed.
        The message is a fixed, generic string — the raw LLM completion text never reaches this
        response, by design (ai-charts-and-dashboards Round 2).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [code, message]
      properties:
        code: { type: string, description: stable machine-readable error code }
        message: { type: string }
        request_id: { type: string }

    Backend:
      type: string
      description: physical warehouse backend (engine `dialect.Backend`).
      enum: [postgres, duckdb, snowflake, bigquery, mysql]

    Mode:
      type: string
      description: data-plane access mode — governed exposes metrics only; explore adds raw measures.
      enum: [governed, explore]

    Op:
      type: string
      enum: [eq, ne, lt, le, gt, ge, in, not_in]

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

    AuthVerifyRequest:
      type: object
      required: [email, code]
      properties:
        email: { type: string, format: email }
        code: { type: string }

    Account:
      type: object
      required: [id, email]
      properties:
        id: { type: string }
        email: { type: string, format: email }
        created_at: { type: string, format: date-time }

    Org:
      type: object
      required: [id, slug, role]
      properties:
        id:
          type: string
          description: >-
            the canonical engine tenant id — immutable, unique, safe charset. This (not slug)
            is what the engine keys on and what an api-key's `tenant` claim carries.
          pattern: "^[a-z0-9][a-z0-9-]{1,62}$"
        slug: { type: string, description: display handle only — not the tenant id }
        name: { type: string }
        role: { type: string, enum: [owner, admin, member] }

    OrgCreate:
      type: object
      required: [name]
      properties:
        name: { type: string }
        slug: { type: string, description: optional display handle; the immutable tenant id is generated }

    OrgUpdate:
      type: object
      required: [name]
      properties:
        name: { type: string }

    Member:
      type: object
      required: [account_id, email, role]
      properties:
        account_id: { type: string }
        email: { type: string, format: email }
        role: { type: string, enum: [owner, admin, member] }

    MemberUpdate:
      type: object
      required: [role]
      properties:
        role: { type: string, enum: [owner, admin, member] }

    AuthRefreshRequest:
      type: object
      required: [refresh_token]
      properties:
        refresh_token: { type: string }

    Session:
      type: object
      required: [session_token, expires_at, account, orgs]
      properties:
        session_token:
          type: string
          description: Short-lived access bearer for `sessionAuth` routes.
        expires_at:
          type: string
          format: date-time
          description: Access token expiry.
        refresh_token:
          type: string
          description: |
            Long-lived rotate-only credential for `/v1/auth/refresh`. Present on login and
            refresh responses; omitted for access-only mints (e.g. operator impersonation).
        refresh_expires_at:
          type: string
          format: date-time
          description: Refresh token expiry; present when `refresh_token` is present.
        account: { $ref: "#/components/schemas/Account" }
        orgs:
          type: array
          items: { $ref: "#/components/schemas/Org" }

    Me:
      type: object
      required: [account, orgs]
      properties:
        account: { $ref: "#/components/schemas/Account" }
        orgs:
          type: array
          items: { $ref: "#/components/schemas/Org" }

    ConnectionCreate:
      type: object
      required: [name, backend, dsn]
      properties:
        name:
          type: string
          description: |
            Tenant-unique connection handle a model's `source: <name>` references (ADR-0013 #2).
            A conservative, YAML-safe identifier — ASCII letter/digit first, then letters, digits,
            '-' or '_'; 1..63 chars. Unique per org (a duplicate is a 409); validated (a
            malformed/empty name is a 400). Onboarding auto-assigns "default".
          pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$'
          minLength: 1
          maxLength: 63
        backend: { $ref: "#/components/schemas/Backend" }
        dsn:
          type: string
          description: warehouse DSN (may embed the credential). Sealed at rest; never returned.
          writeOnly: true
        max_conns: { type: integer, minimum: 1, description: per-tenant pool cap (defaults server-side) }

    ConnectionRotate:
      type: object
      required: [dsn]
      properties:
        dsn:
          type: string
          description: replacement DSN. Egress-checked, probed, and re-sealed; never returned.
          writeOnly: true

    ConnectionUpdate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: |
            New model-facing connection handle. Same grammar as ConnectionCreate.name. Models that
            still reference the previous name must be updated separately.
          pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$'
          minLength: 1
          maxLength: 63

    ConnectionDescriptor:
      type: object
      description: safe connection metadata — no credential, ever.
      required: [id, org_id, name, backend, host]
      properties:
        id: { type: string, description: connection id — targets rotate/delete }
        org_id: { type: string }
        name: { type: string, description: "tenant-unique connection handle a model's source binding references (ADR-0013 #2)" }
        backend: { $ref: "#/components/schemas/Backend" }
        host: { type: string }
        created_at: { type: string, format: date-time }

    AdminOrgSummary:
      type: object
      description: |
        Safe cross-org tenant summary for the operator list — no secret material, ever. member_count
        is the org's membership row count; suspended reflects orgs.suspended_at IS NOT NULL.
      required: [id, slug, name, created_at, suspended, member_count]
      properties:
        id: { type: string }
        slug: { type: string }
        name: { type: string }
        created_at: { type: string, format: date-time }
        suspended: { type: boolean }
        member_count: { type: integer }

    AdminOrgInspect:
      type: object
      description: |
        One tenant's operator support view (AD-AC3) — safe, redacted fields only: no DSN or sealed
        bytes, no JWT or key hash, no session/magic material. Composed from each feature's existing
        safe read shape, read cross-org through the system RLS path. policy/model are omitted when
        the tenant has none set/published.
      required: [org, connections, api_keys, rollups]
      properties:
        org: { $ref: "#/components/schemas/AdminOrgSummary" }
        connections:
          type: array
          items: { $ref: "#/components/schemas/AdminConnection" }
        api_keys:
          type: array
          items: { $ref: "#/components/schemas/ApiKey" }
        policy: { $ref: "#/components/schemas/AdminPolicy" }
        model: { $ref: "#/components/schemas/AdminModel" }
        rollups:
          type: array
          items: { $ref: "#/components/schemas/RollupStatus" }

    AdminPolicy:
      type: object
      description: >-
        the tenant's stored data-plane policy as an OPERATOR sees it. It differs from the tenant's
        own Policy because the two halves now live in different places (ADR-0013 #7): `cost` is the
        org's single per-tenant cap set, while `rls` is keyed per MODEL — an object mapping a model
        name to that model's predicates. An operator must see WHICH model each filter governs,
        because the same dimension name means different columns in different models. Tenant config,
        not secret material.
      properties:
        rls:
          type: object
          description: model name → that model's RLS predicates
          additionalProperties:
            type: array
            items: { $ref: "#/components/schemas/RlsFilter" }
        cost: { $ref: "#/components/schemas/Cost" }

    AdminConnection:
      type: object
      description: |
        Safe connection descriptor for the operator inspect — the connection feature's Descriptor
        shape (id/backend/host/created_at). It has NO dsn/dsn_sealed: the credential never crosses
        the admin boundary.
      required: [id, backend, host]
      properties:
        id: { type: string }
        backend: { $ref: "#/components/schemas/Backend" }
        host: { type: string }
        created_at: { type: string, format: date-time }

    AdminModel:
      type: object
      description: |
        The tenant's current published model status for the operator inspect — version, status, and
        published_at only, not the full YAML body.
      required: [version, status]
      properties:
        version: { type: integer }
        status: { type: string, enum: [draft, published] }
        published_at: { type: string, format: date-time, nullable: true }

    AdminImpersonateRequest:
      type: object
      description: |
        The impersonation request — the id of the account (a member of the path org) the operator
        wants a short-TTL support session as.
      required: [account_id]
      properties:
        account_id: { type: string }

    AdminImpersonateResponse:
      type: object
      description: |
        A short-TTL product session acting as the impersonated member — the opaque bearer token and
        its expiry. It is a real, server-side-revocable session (not a stateless bearer); no account
        or org roster is echoed (the operator already inspected the tenant).
      required: [session_token, expires_at]
      properties:
        session_token: { type: string }
        expires_at: { type: string, format: date-time }

    AdminAuditEntry:
      type: object
      description: |
        One audit_log row in the operator query (AD-AC6) — non-secret identifiers only: who (actor)
        did what (action) to which target in which org, and when (ts). id is a stringified bigint
        that doubles as the keyset cursor value. The audit base records no payload, so no secret
        (DSN, sealed bytes, JWT/key, magic code, session token) can ever appear here.
      required: [id, org_id, actor, action, target, ts]
      properties:
        id: { type: string }
        org_id: { type: string }
        actor: { type: string }
        action: { type: string }
        target: { type: string }
        ts: { type: string, format: date-time }

    AdminAuditPage:
      type: object
      description: |
        One page of the operator audit query — entries newest-first plus an opaque next_cursor.
        next_cursor is the last entry's id when a full page was returned (pass it back as `cursor` to
        fetch the next page); it is null on the final page.
      required: [entries]
      properties:
        entries:
          type: array
          items: { $ref: "#/components/schemas/AdminAuditEntry" }
        next_cursor: { type: string, nullable: true }


    AuditEntry:
      type: object
      description: |
        One audit_log row in the org-scoped member query (AU-AC1) — non-secret identifiers only:
        who (actor) did what (action) to which target in this org, and when (ts). id is a
        stringified bigint that doubles as the keyset cursor value. The audit base records no
        payload, so no secret (DSN, sealed bytes, JWT/key, magic code, session token) can ever
        appear here. Same row shape as AdminAuditEntry; scope differs (this org only).
      required: [id, org_id, actor, action, target, ts]
      properties:
        id: { type: string }
        org_id: { type: string }
        actor: { type: string }
        action: { type: string }
        target: { type: string }
        ts: { type: string, format: date-time }

    AuditPage:
      type: object
      description: |
        One page of the org-scoped audit query — entries newest-first plus an opaque next_cursor.
        next_cursor is the last entry's id when a full page was returned (pass it back as `cursor`
        to fetch the next page); it is null on the final page. entries is always an array (never
        null) — empty when no rows match.
      required: [entries]
      properties:
        entries:
          type: array
          items: { $ref: "#/components/schemas/AuditEntry" }
        next_cursor: { type: string, nullable: true }

    UsageEvent:
      type: object
      description: |
        One usage event in an ingest batch — the engine's per-query telemetry. event_id is a stable
        per-query id; re-delivering the same (org_id, event_id) is idempotent (no double-count).
        rows/bytes are non-negative counters; routing/outcome are free-form labels (e.g. cache vs
        live, ok vs error). ts is the event time (RFC3339).
      required: [org_id, event_id, ts, rows, bytes]
      properties:
        org_id: { type: string }
        event_id: { type: string, minLength: 1 }
        ts: { type: string, format: date-time }
        rows: { type: integer, format: int64, minimum: 0 }
        bytes: { type: integer, format: int64, minimum: 0 }
        routing: { type: string }
        outcome: { type: string }

    UsageIngestRequest:
      type: object
      description: |
        A batch of usage events to ingest. Bounded to 1..1000 events per request; the batch is
        validated whole before any write, so a malformed event rejects the batch (400) with nothing
        written.
      required: [events]
      properties:
        events:
          type: array
          minItems: 1
          maxItems: 1000
          items: { $ref: "#/components/schemas/UsageEvent" }

    UsageIngestResponse:
      type: object
      description: |
        The result of an ingest batch — how many events became new usage_events rows (accepted) and
        how many were already present and skipped (duplicates). accepted + duplicates equals the
        number of events in the request.
      required: [accepted, duplicates]
      properties:
        accepted: { type: integer }
        duplicates: { type: integer }

    UsageReport:
      type: object
      description: |
        Per-period usage aggregation for one org over a half-open [from, to) window. Buckets are
        ascending by period; an empty window yields an empty buckets array. Counts and sums only —
        no query text, no event ids, no secrets.
      required: [from, to, granularity, buckets]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        granularity: { type: string, enum: [day, month] }
        buckets:
          type: array
          items: { $ref: "#/components/schemas/UsageBucket" }

    UsageBucket:
      type: object
      description: |
        One period's aggregate: the UTC period start (date_trunc), summed rows and bytes, the total
        event count, and the event counts broken down by routing and by outcome (label → count).
      required: [period, rows, bytes, events, by_routing, by_outcome]
      properties:
        period: { type: string, format: date-time }
        rows: { type: integer, format: int64 }
        bytes: { type: integer, format: int64 }
        events: { type: integer }
        by_routing:
          type: object
          additionalProperties: { type: integer }
        by_outcome:
          type: object
          additionalProperties: { type: integer }

    Subscription:
      type: object
      description: |
        An org's current plan binding (B4). The commercial fact only — which plan, its status, and
        the current billing period; the plan's data-plane cost caps are mapped onto the org's policy,
        not carried here.
      required: [plan_id, status, current_period_start, current_period_end]
      properties:
        plan_id: { type: string, description: "a static-catalog plan id: free | pro | enterprise" }
        status: { type: string }
        current_period_start: { type: string, format: date-time }
        current_period_end: { type: string, format: date-time }

    SubscriptionUpdate:
      type: object
      description: the plan to put the org onto (PUT body). An unknown plan_id is 422.
      required: [plan_id]
      properties:
        plan_id: { type: string, description: "a static-catalog plan id: free | pro | enterprise" }

    Invoice:
      type: object
      description: |
        An org's usage invoice for a half-open [from, to) period (B4), built on demand from metering's
        usage aggregation — nothing is stored. Line items carry usage QUANTITIES only, no pricing.
        plan_id is the org's current plan, or empty when the org has no subscription.
      required: [org_id, plan_id, period_start, period_end, line_items]
      properties:
        org_id: { type: string }
        plan_id: { type: string, description: "the org's current plan id, or empty if it has no subscription" }
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        line_items:
          type: array
          items: { $ref: "#/components/schemas/InvoiceLineItem" }

    InvoiceLineItem:
      type: object
      description: |
        One usage dimension summed over the invoice period: the metric (rows | bytes | events) and the
        quantity from metering's aggregation. A usage quantity only — no money amount.
      required: [metric, quantity]
      properties:
        metric: { type: string, enum: [rows, bytes, events] }
        quantity: { type: integer, format: int64 }

    InvoiceRequest:
      type: object
      description: the half-open [from, to) period to invoice (POST body). A missing or non-ascending range is 400.
      required: [from, to]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }

    ApiKeyCreate:
      type: object
      required: [mode]
      properties:
        mode: { $ref: "#/components/schemas/Mode" }
        label: { type: string }
        ttl_seconds:
          type: integer
          format: int64
          description: >
            Optional requested credential lifetime, in seconds. Omitted or non-positive ⇒ the
            operator-configured default TTL (90 days out of the box, DATAHASHI_APIKEY_DEFAULT_TTL).
            A value above the operator-configured max (DATAHASHI_APIKEY_MAX_TTL, 90 days out of the
            box) is clamped down to it, never rejected.

    ApiKeyUpdate:
      type: object
      required: [label]
      properties:
        label:
          type: string
          description: human-facing label; empty string clears it

    ApiKey:
      type: object
      required: [id, prefix, mode]
      properties:
        id: { type: string }
        prefix: { type: string, description: non-secret leading segment for identification }
        mode: { $ref: "#/components/schemas/Mode" }
        label: { type: string }
        created_at: { type: string, format: date-time }
        revoked_at: { type: string, format: date-time, nullable: true }

    ApiKeyCreated:
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          required: [api_key]
          properties:
            api_key:
              type: string
              description: the HS256 JWT data-plane credential — shown once, never retrievable again.

    Webhook:
      type: object
      description: |
        A registered webhook endpoint (B4, F-40). The read shape — id, destination url, subscribed
        event mask, status, and created_at. The per-webhook HMAC signing secret is NEVER carried here:
        it is shown once at registration (WebhookCreated) and sealed at rest.
      required: [id, org_id, url, events, status, created_at]
      properties:
        id: { type: string, description: "webhook id (wh_ prefix)" }
        org_id: { type: string }
        url: { type: string, format: uri, description: the destination the signed event POSTs to }
        events:
          type: array
          description: "the subscribed event mask, e.g. [build-complete]"
          items: { type: string }
        status: { type: string, description: "active (a webhook only persists once ownership is confirmed)" }
        created_at: { type: string, format: date-time }

    WebhookCreate:
      type: object
      description: |
        The registration body (POST). The url's host is SSRF-checked and the endpoint's ownership is
        verified by a signed confirmation ping it must 2xx before any row is written. A blocked host or
        an unconfirmed endpoint is 422.
      required: [url, events]
      properties:
        url: { type: string, format: uri }
        events:
          type: array
          description: the events this endpoint subscribes to; at least one is required.
          items: { type: string }

    WebhookCreated:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: the per-webhook HMAC-SHA256 signing secret (whsec_ prefix) — shown once, never retrievable again.

    Delivery:
      type: object
      description: |
        One row of a webhook's delivery log (B4, F-40) — the operational record of an outbox attempt.
        It carries WHETHER and WHEN a delivery happened and its HTTP outcome, never the signed payload,
        the last-error diagnostic, or any signature/secret. last_status is null until the first attempt
        returns an HTTP status.
      required: [id, event, status, attempts, next_attempt_at, created_at, updated_at]
      properties:
        id: { type: string, description: "delivery id (wd_ prefix)" }
        event: { type: string, description: "the event delivered, e.g. build-complete" }
        status: { type: string, description: "pending | delivered | failed" }
        attempts: { type: integer, description: delivery attempts made so far }
        last_status: { type: integer, description: "the last HTTP status the endpoint returned (null before the first attempt)" }
        next_attempt_at: { type: string, format: date-time, description: when the next attempt is scheduled }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    RlsFilter:
      type: object
      required: [dimension, op, value]
      properties:
        dimension: { type: string }
        op: { $ref: "#/components/schemas/Op" }
        value:
          description: scalar for eq/ne/lt/le/gt/ge; array for in/not_in.
          anyOf:
            - type: string
            - type: number
            - type: boolean
            - type: array
              items: {}

    Cost:
      type: object
      properties:
        max_rows: { type: integer, minimum: 0 }
        timeout_ms: { type: integer, minimum: 0 }
        max_bytes: { type: integer, format: int64, minimum: 0 }

    Policy:
      type: object
      description: >-
        per-tenant server-side access policy consumed by the engine gateway (R1). Since v2 this is
        the COST half only — the per-query caps, which are not dimension-specific and so stay
        per-tenant. RLS moved to ModelRls, keyed (orgId, model name), because Dimension names are
        model-scoped (ADR-0013 #7).
      properties:
        cost: { $ref: "#/components/schemas/Cost" }

    ModelRls:
      type: object
      description: >-
        one model's row-level-security predicates — the ordered filters the engine appends to every
        query against that model, its `RLS(tenant, model)` (ADR-0013 #7). The model is the
        addressing key (the `{name}` path segment), never a field here, so predicates cannot be
        authored for one model and land on another. Every predicate must name a Dimension the model
        declares; an absent one is rejected 422. An empty list is deny-nothing.
      properties:
        rls:
          type: array
          items: { $ref: "#/components/schemas/RlsFilter" }

    Model:
      type: object
      description: >-
        a stored semantic model row (B3) — the native-YAML the engine's model.Parse validates, with
        its name, lifecycle status, and monotonic per-(org, name) version. Since v2 a model is
        addressed by name (ADR-0013 #3); published_at is set only once a draft is blessed.
      required: [name, version, status, yaml]
      properties:
        name: { type: string, description: "the model name — the tenant-scoped handle the lifecycle keys on (ADR-0013 #3); \"main\" for the singular /model alias" }
        version: { type: integer, description: monotonic per-(org, name) version (max(version)+1 within the name) }
        status: { type: string, enum: [draft, published] }
        yaml: { type: string, description: the native-YAML semantic model (ADR-0006) }
        published_at: { type: string, format: date-time, nullable: true }

    ModelSummary:
      type: object
      description: >-
        one entry in the by-name model catalog (GET .../models, ADR-0013 #3) — a model name with its
        current published version and status, or its working-draft version and status='draft' when
        the name has never been published.
      required: [name, version, status]
      properties:
        name: { type: string, description: the model name (tenant-scoped handle) }
        version: { type: integer, description: the current published version, or the draft version when never published }
        status: { type: string, enum: [draft, published] }

    ModelDraft:
      type: object
      description: a native-YAML semantic model supplied to create or replace the working draft (B3). The target model name is the {name} path parameter (or "main" for the singular /model route), never the body.
      required: [yaml]
      properties:
        yaml: { type: string, description: the native-YAML semantic model (ADR-0006) }

    RollupSpec:
      type: object
      required: [id, measures]
      properties:
        id: { type: string }
        measures:
          type: array
          items: { type: string }
        dimensions:
          type: array
          items:
            type: object
            required: [name]
            properties:
              name: { type: string }
              grain: { type: string, enum: [day, week, month, quarter, year] }
        grain: { type: string, enum: [day, week, month, quarter, year] }

    RollupStatus:
      type: object
      description: >-
        a registered rollup plus its build state, derived server-side from the shared
        river_job lifecycle (B2) — never client-supplied.
      required: [rollup_id, model_name, measures, build_state]
      properties:
        rollup_id: { type: string }
        model_name:
          type: string
          description: >-
            the model this rollup is scoped to (ADR-0013 #3) — the {name} route segment it was
            registered under. rollup_id is unique only WITHIN (orgId, model_name), so this names
            which model's measures/dimensions the spec resolved against.
        measures:
          type: array
          items: { type: string }
        dimensions:
          type: array
          items:
            type: object
            required: [name]
            properties:
              name: { type: string }
              grain: { type: string, enum: [day, week, month, quarter, year] }
        grain: { type: string, enum: [day, week, month, quarter, year] }
        build_state:
          type: string
          description: >-
            the latest rollup_build river_job's state for this rollup, coarsened: none (no
            job ever enqueued) / queued / running / built / failed.
          enum: [none, queued, running, built, failed]

    Dashboard:
      type: object
      description: >-
        a persisted, org-scoped dashboard (ai-charts-and-dashboards). account_id (the creator) is
        not exposed here — the audit trail owns "who"; any org member may read/edit/delete any
        dashboard in their org (member-gated, not owner-gated).
      required: [id, name, created_at, updated_at]
      properties:
        id: { type: string }
        name: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    DashboardCreate:
      type: object
      required: [name]
      properties:
        name: { type: string }

    DashboardUpdate:
      type: object
      required: [name]
      properties:
        name: { type: string, description: new display name (non-empty after trim) }

    DashboardDetail:
      type: object
      description: a dashboard plus its widgets, ordered by position — no cached/snapshotted result rows.
      allOf:
        - $ref: "#/components/schemas/Dashboard"
        - type: object
          required: [widgets]
          properties:
            widgets:
              type: array
              items: { $ref: "#/components/schemas/Widget" }

    Widget:
      type: object
      description: >-
        a dashboard's live query+chart reference. semantic_query and chart_config are OPAQUE
        JSON — validated only for well-formedness (non-empty, well-formed JSON), never run or
        rendered by this API; the frontend re-runs semantic_query against the engine and renders
        chart_config on every dashboard view.
      required: [id, title, semantic_query, chart_config, position]
      properties:
        id: { type: string }
        title: { type: string }
        semantic_query: {}
        chart_config: {}
        position: { type: integer, description: order among this dashboard's widgets, ascending }

    WidgetCreate:
      type: object
      required: [title, semantic_query, chart_config]
      properties:
        title: { type: string }
        semantic_query: {}
        chart_config: {}

    WidgetReorder:
      type: object
      description: >-
        Deprecated alias of WidgetUpdate with only position. Prefer WidgetUpdate.
      required: [position]
      properties:
        position: { type: integer }

    WidgetUpdate:
      type: object
      description: >-
        Partial widget update — at least one of title or position must be present.
      properties:
        title: { type: string, description: new title (non-empty after trim when provided) }
        position: { type: integer, description: new order among this dashboard's widgets }

    AnnotationEntry:
      type: object
      description: >-
        one query-result column's name/kind/type — mirrors the datahashi query engine's
        QueryResult.annotation[] entry field-for-field (no shared Go type exists between this
        service and the engine's separate module; internal/mcpclient/wire.go re-declares the
        engine's other wire shapes the same way). kind is dimension|measure|metric; type is
        int|float|decimal|string|bool|date|timestamp (both vocabularies owned by the engine —
        this endpoint forwards them into the prompt without validating either enum).
      required: [name, kind, type]
      properties:
        name: { type: string }
        kind: { type: string }
        type: { type: string }

    ChartSuggestionRequest:
      type: object
      description: >-
        semantic_query is accepted opaquely (validated only for non-empty, well-formed JSON —
        it plays no role in prompt construction). annotation drives column roles and must be
        non-empty. sample_rows (optional, capped server-side) and row_count help the model
        judge cardinality and value shape without a full result dump.
      required: [semantic_query, annotation]
      properties:
        semantic_query: {}
        annotation:
          type: array
          items: { $ref: "#/components/schemas/AnnotationEntry" }
        sample_rows:
          type: array
          description: optional row-major sample (same column order as annotation); server caps length
          items:
            type: array
            items: {}
        row_count:
          type: integer
          description: full result length (not just the sample)

    EncodingField:
      type: object
      description: >-
        one encoding channel's field+type. field MUST match an annotation column name, or a
        synthetic fold field (__series__/__value__) when transform is present; type MUST be
        one of Vega-Lite's four standard encoding types.
      required: [field, type]
      properties:
        field: { type: string }
        type: { type: string, enum: [quantitative, nominal, ordinal, temporal] }

    FoldTransform:
      type: object
      description: Vega-Lite fold (wide multi-metric → long series)
      required: [fold, as]
      properties:
        fold:
          type: array
          items: { type: string }
        as:
          type: array
          items: { type: string }
          minItems: 2
          maxItems: 2

    ChartConfig:
      type: object
      description: >-
        a validated Vega-Lite mark+encoding fragment produced from a catalog chart_type.
        chart_type is the product catalog id (bar, grouped_bar, multi_line, …). transform is
        set for multi-metric series (fold). Encoding channels include xOffset for grouped bars
        and theta for pie. Synthetic fields __series__/__value__ appear only with fold.
      required: [mark, encoding]
      properties:
        chart_type:
          type: string
          enum: [kpi, dual_axis, bar, grouped_bar, stacked_bar, line, multi_line, area, scatter, pie]
        mark: { type: string, enum: [bar, line, point, area, arc, text] }
        encoding:
          type: object
          additionalProperties: { $ref: "#/components/schemas/EncodingField" }
        transform:
          type: array
          items: { $ref: "#/components/schemas/FoldTransform" }
        dual_y:
          allOf:
            - $ref: "#/components/schemas/EncodingField"
          description: >-
            Right-axis metric for chart_type dual_axis (encoding.y is the left axis).
            Scales are independent so incomparable units stay readable.

    Message:
      type: object
      description: >-
        one message row on a model-create session's interview transcript — the member's
        question, or the assistant's reply.
      required: [id, conversation_id, role, content, created_at]
      properties:
        id: { type: string }
        conversation_id: { type: string }
        role: { type: string, enum: [user, assistant] }
        content: { type: string }
        created_at: { type: string, format: date-time }

    CreateModelCreateSessionRequest:
      type: object
      required: [connection_name]
      properties:
        connection_name:
          type: string
          description: name of an existing connection in the org (never a DSN)

    ModelCreateScope:
      type: object
      description: confirmed table include/exclude set for a model-create session
      properties:
        include:
          type: array
          items: { type: string }
          description: table names (bare or schema.table) to include
        exclude:
          type: array
          items: { type: string }
          description: table names to exclude after include

    ModelCreateCatalog:
      type: object
      description: light schema/table-name listing only — never DSN, never column profiles
      properties:
        tables:
          type: array
          items: { type: string }

    ModelCreateInterviewChoice:
      type: object
      required: [id, label]
      properties:
        id: { type: string }
        label: { type: string }
        description: { type: string }
        recommended: { type: boolean }

    ModelCreateInterviewQuestion:
      type: object
      description: active multi-choice interview card for the SPA
      required: [id, prompt, selection, required, choices, allow_free_text]
      properties:
        id: { type: string }
        prompt: { type: string }
        selection: { type: string, enum: [single, multi] }
        required: { type: boolean }
        choices:
          type: array
          items: { $ref: "#/components/schemas/ModelCreateInterviewChoice" }
        allow_free_text: { type: boolean }
        free_text_placeholder: { type: string }
        free_text_min_chars: { type: integer }

    ModelCreateUserIntent:
      type: object
      description: progressive structured interview intent accumulated on the session
      properties:
        domain: { type: string }
        use_cases:
          type: array
          items: { type: string }
        kpis:
          type: array
          items: { type: string }
        time_grain: { type: string }
        include_tables:
          type: array
          items: { type: string }
        exclude_tables:
          type: array
          items: { type: string }
        proposed_name: { type: string }
        freeform_notes: { type: string }

    PostModelCreateMessageRequest:
      type: object
      description: >-
        Structured interview answer preferred; content alone is legacy free-text
        (accepted when current question allows free text).
      properties:
        content: { type: string, description: optional legacy free-text or note filler }
        answer:
          type: object
          required: [question_id]
          properties:
            question_id: { type: string }
            selected_choice_ids:
              type: array
              items: { type: string }
            free_text: { type: string }

    ModelCreateSession:
      type: object
      description: >-
        a model-create session (model_create_conversations row + model_create_sessions sidecar).
        Never carries DSN, dsn_sealed, or other credentials. messages is present only when
        GET ?include=messages.
      required: [id, org_id, account_id, connection_name, phase, created_at, updated_at]
      properties:
        id: { type: string }
        org_id: { type: string }
        account_id: { type: string }
        connection_name: { type: string, description: registered connection name (not a DSN) }
        phase:
          type: string
          enum: [interviewing, scoping_confirmed, composing, ready, saved, failed]
        scope: { $ref: "#/components/schemas/ModelCreateScope" }
        catalog: { $ref: "#/components/schemas/ModelCreateCatalog" }
        proposed_name: { type: string }
        draft_yaml: { type: string, description: latest proposed native-YAML (draft only) }
        error_detail: { type: string }
        interview_status:
          type: string
          enum: [need_user, intent_ready]
          description: interviewer await vs ready-for-design signal
        current_question:
          allOf:
            - $ref: "#/components/schemas/ModelCreateInterviewQuestion"
          nullable: true
        intent:
          allOf:
            - $ref: "#/components/schemas/ModelCreateUserIntent"
          nullable: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        messages:
          type: array
          items: { $ref: "#/components/schemas/Message" }
          description: interview transcript when GET ?include=messages

    RefineModelCreateSessionRequest:
      type: object
      required: [instruction]
      properties:
        instruction:
          type: string
          description: natural-language refine instruction (e.g. "rename revenue to bookings")

    SaveModelCreateSessionRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: model name for model.Service.SaveDraft (draft lifecycle only)

    SaveModelCreateSessionResponse:
      type: object
      required: [session, model]
      properties:
        session: { $ref: "#/components/schemas/ModelCreateSession" }
        model: { $ref: "#/components/schemas/Model" }

    OnboardingRequest:
      type: object
      required: [connection]
      properties:
        connection: { $ref: "#/components/schemas/ConnectionCreate" }
        model_yaml:
          type: string
          description: optional blessed model YAML; if omitted, introspection produces a draft.
        rollups:
          type: array
          items: { $ref: "#/components/schemas/RollupSpec" }

    OnboardingStatus:
      type: object
      required: [id, state, steps]
      properties:
        id: { type: string }
        state: { type: string, enum: [pending, running, succeeded, failed] }
        steps:
          type: array
          items:
            type: object
            required: [name, state]
            properties:
              name: { type: string, enum: [connect, introspect, model, rollups, build] }
              state: { type: string, enum: [pending, running, succeeded, failed, skipped, queued] }
              detail: { type: string }

    Invitation:
      type: object
      description: >-
        a pending invitation into an org (B3). The code is emailed and only its hash is stored, so
        code_hash is NEVER exposed here.
      required: [id, email, role, status, expires_at]
      properties:
        id: { type: string, description: invitation id (inv_ prefix) — targets revoke }
        email: { type: string, format: email, description: the invited address }
        role: { type: string, enum: [owner, admin, member] }
        status: { type: string, enum: [pending, accepted, revoked] }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    InvitationCreate:
      type: object
      required: [email, role]
      properties:
        email: { type: string, format: email }
        role: { type: string, enum: [owner, admin, member] }

    InvitationAccept:
      type: object
      required: [code]
      properties:
        code: { type: string, description: the plaintext invitation code delivered by email }

    InvitationAccepted:
      type: object
      description: the org joined and the role granted on accepting an invitation.
      required: [org_id, role]
      properties:
        org_id: { type: string, pattern: "^[a-z0-9][a-z0-9-]{1,62}$" }
        role: { type: string, enum: [owner, admin, member] }

    ChatMessage:
      type: object
      description: >-
        one message in a chat conversation — the user's input or the assistant's reply.
        used in conversation history sent by the client on each request.
      required: [role, content]
      properties:
        role: { type: string, enum: [user, assistant], description: "who sent this message" }
        content: { type: string, description: "the message text" }
        query:
          description: "prior semantic query JSON on assistant turns (structured history for follow-ups)"

    ChatRequest:
      type: object
      description: >-
        a conversational query request: a user message and optional prior conversation history.
        the model is required and specifies which semantic model to query. Generated queries are
        grounded against the model catalog before execution. A3 fields (idempotency_key, options,
        security_context) are optional and ignored by older console clients.
      required: [model, message]
      properties:
        model: { type: string, description: "published semantic model name" }
        message: { type: string, description: "current user message" }
        history:
          type: array
          description: "prior conversation turns (optional); include prior query JSON on assistant turns"
          items: { $ref: "#/components/schemas/ChatMessage" }
        conversation_id: { type: string, description: "optional existing conversation to append" }
        conversation_name: { type: string, description: "optional title when creating a conversation" }
        idempotency_key:
          type: string
          description: "optional client message id; successful responses may be replayed for the same org+key"
        options:
          type: object
          properties:
            include_chart: { type: boolean }
            include_follow_ups: { type: boolean }
            max_rows: { type: integer, minimum: 1 }
        security_context:
          type: object
          description: "embed end-user identity for future engine RLS mapping"
          properties:
            external_user_id: { type: string }
            attributes:
              type: object
              additionalProperties: { type: string }

    ChatResponse:
      type: object
      description: >-
        a chat response: the grounded semantic query, query result (columns + rows + annotation),
        optional chart config, summary, follow-up suggestions, and conversation ids.
      properties:
        query:
          description: "the grounded semantic query (opaque JSON) executed or attempted"
        result:
          description: "the query result: {columns, rows, annotation}, or null if not executed"
        chart:
          description: "suggested Vega-Lite chart config (mark+encoding+transform), or null if not suggested"
        results:
          type: array
          description: "multi-card answers (preferred when multi-query); each {id?, title?, query?, result?, chart?}"
          items:
            type: object
            properties:
              id: { type: string }
              title: { type: string }
              query: {}
              result: {}
              chart: {}
        message:
          type: string
          description: "human-readable summary of the result or query"
        conversation_id: { type: string }
        conversation_name: { type: string }
        follow_ups:
          type: array
          items: { type: string }
          description: "suggested next questions for the UI"
        status:
          type: string
          description: "ok | empty_result | execution_failed | query_only | talk | analyze | …"
        action:
          type: string
          description: "planner action: talk | query | analyze"
        stream_id:
          type: string
          description: "set on streaming turns for POST …/chat/abort"
        suggestions:
          type: array
          items: { type: string }
          description: "nearest catalog names when grounding fails (also on stream error)"

    # SSE event payloads for POST …/chat/stream (public contract v1.0; console baseline
    # v0.1.1). Transport is text/event-stream; each event's `data:` line is one of these
    # JSON shapes. See docs/features/analytics-chat-api/chat-stream-v1.0.md.
    ChatStreamStatusEvent:
      type: object
      required: [phase]
      properties:
        phase:
          type: string
          description: "started | interpreting | running | charting | summarizing | saving | done | …"
        detail: { type: string }
        stream_id: { type: string, description: "present when stream registry is enabled; use with /chat/abort" }

    ChatStreamDoneEvent:
      type: object
      properties:
        status: { type: string, description: "mirrors ChatResponse.status" }
        stream_id: { type: string }

    ChatStreamErrorEvent:
      type: object
      required: [error, code]
      properties:
        error: { type: string, description: "public user-safe message" }
        code:
          type: string
          description: "bad_request | llm_unavailable | query_generation_failed | query_not_grounded | query_execution_failed | catalog_too_large | aborted | internal_error"
        stream_id: { type: string }
        suggestions:
          type: array
          items: { type: string }
          description: "optional nearest catalog names on grounding failure"
