openapi: 3.0.3

# TiDB connector for Workato, v1.
#
# Fed to Workato's SDK "import via OAS" to generate a connector. Every construct here is chosen
# for that importer, not for elegance:
#   - 3.0.3 exactly. 3.1 breaks the importer, Swagger 2.0 is rejected outright.
#   - No oneOf / anyOf / allOf in request bodies. The importer flattens them and required-ness is lost.
#   - Request bodies at most two levels deep, so they render as usable forms.
#   - enum on every mode-like field, so Workato renders a picklist instead of a free-text box.
#   - example on every response, because Workato builds design-time datapills from them and a
#     free-form row object gives it nothing else to work with.
#
# Two operations are connector infrastructure and are hidden from the action picker after import:
# `ping` becomes the connection test lambda (the generated one is hardcoded true), and
# `describeTable` feeds object_definitions.
#
# Open items this spec deliberately does not resolve, to be settled by the import test:
#   1. whether the templated `servers` host survives as a connection field
#   2. whether `rows[]` renders as something a builder can map fields into
#   3. whether declaring `format: date-time` makes Workato re-zone a naive DATETIME
# See connector-design/round2-review.md.

info:
  title: TiDB
  version: '1.0.0'
  description: |
    Search, read and write TiDB tables from a recipe, without writing SQL.

    What this adds over a plain MySQL connection:

    - **Semantic search.** Give it a sentence and it finds rows that mean the same thing, using
      the vector and full-text indexes together. Expressing that in SQL takes two CTEs and a rank
      fusion, which is not something a recipe builder should have to write.
    - **It knows which columns you must not fill in.** TiDB has columns the database owns:
      generated columns (including auto-generated embeddings) reject an explicit value, and
      AUTO_RANDOM primary keys are invisible to ordinary MySQL introspection, so a generic
      connector shows them as writable and every insert from that recipe fails. This connector
      classifies each column and hides the ones you cannot write.
    - **Approved queries.** Anything this connector cannot express is registered once by an
      operator as a named, parameterized query and appears here as its own action. Recipes never
      send SQL.

    Scope of v1: one deployment serves one TiDB cluster and one database. There is no update, no
    delete, and no schema creation. See the operation descriptions for what each one does not do.

servers:
  - url: https://{host}/v1
    description: Your TiDB connector deployment.
    variables:
      host:
        default: workato-poc-mcp.fly.dev
        description: >
          Host of your deployment. Each deployment is bound to one TiDB cluster and one database
          by its own configuration; nothing about the cluster is sent from the recipe.

security:
  - bearerAuth: []

tags:
  - name: Connection
    description: Connection health and capability. Not builder-facing.
  - name: Discovery
    description: Table metadata. Not builder-facing; drives field lists.
  - name: Search
    description: Semantic and keyword retrieval.
  - name: Rows
    description: Reading and writing rows.
  - name: Approved queries
    description: Named queries registered by an operator. The SQL is fixed; you supply parameters.

paths:
  # ---------------------------------------------------------------------------
  # Connection
  # ---------------------------------------------------------------------------
  /ping:
    get:
      operationId: ping
      summary: Test connection
      tags: [Connection]
      description: >
        Confirms the token is valid and the database is reachable, and reports which TiDB features
        this deployment can use. Used as the connection test. Never fails because the cluster is
        cold: if the database does not answer in time it still returns 200 with `warm: false` and
        `features` unknown, so a connection can be saved and the first real action warms it.
      responses:
        '200':
          description: Reachable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PingResult' }
              example:
                ok: true
                warm: true
                database: workato_poc
                tidb_version: 8.0.11-TiDB-v8.5.3-serverless
                features:
                  auto_embedding: true
                  fulltext_search: true
                  vector_index: true
                server_time_utc: '2026-08-05 09:00:00'
                latency_ms: 41
                request_id: 01J9Z2K7Q1
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }

  # ---------------------------------------------------------------------------
  # Discovery
  # ---------------------------------------------------------------------------
  /tables:
    get:
      operationId: listTables
      summary: List tables
      tags: [Discovery]
      description: >
        Tables in this deployment's database, with flags saying which ones semantic search will
        actually work against. Drives the table picker so a builder never types a table name.
      responses:
        '200':
          description: Tables.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TableList' }
              example:
                tables:
                  - name: tickets
                    approx_rows: 22
                    primary_key: [ticket_id]
                    searchable: true
                    has_vector_column: true
                    has_fulltext_index: true
                  - name: accounts
                    approx_rows: 5
                    primary_key: [account_id]
                    searchable: false
                    has_vector_column: false
                    has_fulltext_index: false
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }

  /tables/{table}:
    get:
      operationId: describeTable
      summary: Describe table
      tags: [Discovery]
      description: >
        Columns of a table, each classified by whether a recipe may write to it.

        `write_class` is the point of this operation:

        - `writable`: fill it in normally.
        - `db_managed`: TiDB will fill it if you leave it out. Accepted if you send one, but the
          value you send replaces the database's own, which is almost never what you want.
          Hidden by default. Covers AUTO_INCREMENT keys and CURRENT_TIMESTAMP defaults.
        - `rejects_write`: TiDB refuses an explicit value, including an explicit null. Never
          offered as an input. Covers generated columns (auto-generated embeddings) and
          AUTO_RANDOM primary keys.

        AUTO_RANDOM is the case a generic MySQL connector gets wrong: it does not appear in
        `information_schema.COLUMNS.EXTRA`, so ordinary introspection renders it as a plain
        writable integer, a builder maps a value into it, and every insert fails.
      parameters:
        - $ref: '#/components/parameters/TablePath'
      responses:
        '200':
          description: Table schema.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TableSchema' }
              example:
                table: tickets
                primary_key: [ticket_id]
                unique_keys: []
                schema_fingerprint: 93cedf20
                search:
                  vector_columns: [embedding]
                  default_vector_column: embedding
                  fulltext_columns: [body, title]
                  has_vector_index: true
                  supports_hybrid: true
                write:
                  generated_key_column: null
                  reports_generated_key: >-
                    This table generates no key, so keys only ever echo what you sent.
                columns:
                  - name: ticket_id
                    type: varchar(32)
                    json_type: string
                    nullable: false
                    write_class: writable
                    write_class_reason: null
                  - name: title
                    type: varchar(512)
                    json_type: string
                    nullable: false
                    write_class: writable
                    write_class_reason: null
                  - name: created_at
                    type: datetime
                    json_type: string
                    nullable: false
                    write_class: writable
                    write_class_reason: null
                  - name: ingested_at
                    type: datetime
                    json_type: string
                    nullable: false
                    write_class: db_managed
                    write_class_reason: defaults to CURRENT_TIMESTAMP
                  - name: embedding
                    type: vector(1024)
                    json_type: array
                    nullable: true
                    write_class: rejects_write
                    write_class_reason: generated column, TiDB computes it from title and body
                    is_vector: true
                    vector_dims: 1024
                    embed_source_columns: [title, body, resolution]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # Search
  # ---------------------------------------------------------------------------
  /tables/{table}/search:
    post:
      operationId: searchTable
      summary: Search a table
      tags: [Search]
      description: >
        Finds rows that mean the same thing as your text, not just rows containing the same words.

        Runs vector similarity and full-text matching together and fuses the two rankings, so a
        paraphrased question and an exact error code both land.

        Each side searches one column, chosen from the table's own indexes, so the common case
        needs no column names. A table carrying more than one embedding, or more than one
        full-text index, is the case where the default is only a default: name `vector_column` or
        `fulltext_column` to reach another. `GET /tables/{table}` lists every choice under
        `search`. The keyword side is one column and not several because TiDB rejects two
        full-text predicates in one statement.

        Not paginated. Ranked results past the top few are noise, so ask for what you will use.
      parameters:
        - $ref: '#/components/parameters/TablePath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SearchRequest' }
      responses:
        '200':
          description: Ranked matches, best first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SearchResult' }
              example:
                matches:
                  - relevance: 0.032258
                    row:
                      ticket_id: SUP-1001
                      title: Connection refused right after enabling the public endpoint
                      resolution: Starter requires TLS on the public endpoint.
                  - relevance: 0.032018
                    row:
                      ticket_id: SUP-1007
                      title: Node.js client fails with self signed certificate in certificate chain
                      resolution: The runtime image had no system CA bundle.
                searched_with: hybrid
                query_time_ms: 118
                request_id: 01J9Z2K7Q4
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  # ---------------------------------------------------------------------------
  # Rows
  # ---------------------------------------------------------------------------
  /tables/{table}/upsert:
    post:
      operationId: upsertRows
      summary: Add or update rows
      tags: [Rows]
      description: >
        Writes rows, replacing any that already exist with the same primary key. Send up to 5000
        at a time rather than one row per recipe step.

        Leave out any column whose `write_class` is `rejects_write` or `db_managed`; TiDB fills
        those itself. Embeddings in particular are generated on write, so a recipe never sends a
        vector for a generated column.

        Rows are not written as one transaction. Rows that succeed are committed even when others
        fail, and every row's outcome comes back in `results`. If nothing succeeded at all the
        call fails outright, so a completely broken batch cannot look like success.

        Retrying is safe only when the table's primary key comes from your data. For a table whose
        key is generated by TiDB, a retry inserts a second copy.
      parameters:
        - $ref: '#/components/parameters/TablePath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpsertRequest' }
      responses:
        '200':
          description: >
            At least one row was written. Check `failed` before treating the step as clean.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WriteResult' }
              example:
                status: ok
                all_succeeded: true
                succeeded: 2
                failed: 0
                results:
                  - index: 0
                    ok: true
                    keys: { ticket_id: SUP-9001 }
                  - index: 1
                    ok: true
                    keys: { ticket_id: SUP-9002 }
                request_id: 01J9Z2K7Q5
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Every row failed. Nothing was written.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WriteResult' }
              example:
                status: failed
                all_succeeded: false
                succeeded: 0
                failed: 1
                results:
                  - index: 0
                    ok: false
                    error:
                      code: column_not_writable
                      message: >
                        Column "embedding" is generated by TiDB and cannot be given a value.
                        Remove it from the row.
                      column: embedding
                request_id: 01J9Z2K7Q6

  /tables/{table}/list:
    post:
      operationId: listRows
      summary: List rows
      tags: [Rows]
      description: >
        Reads rows with simple equality filters, in pages.

        Paging uses a cursor rather than a page number, so page 400 costs what page 1 costs. Hand
        the `cursor` from one response into the next call and stop when `has_more` is false. Rows
        written while you page may or may not appear; this is not a snapshot.

        Vector columns are left out unless you ask for them by name in `columns`, because a single
        embedding is a thousand numbers and a page of them is not something a recipe can use.
      parameters:
        - $ref: '#/components/parameters/TablePath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ListRequest' }
      responses:
        '200':
          description: A page of rows.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListResult' }
              example:
                rows:
                  - ticket_id: SUP-1102
                    account_id: ACC-1002
                    status: open
                    title: Monthly report job failed again with error 8176
                    created_at: '2026-07-21 23:05:00'
                has_more: false
                cursor: null
                returned: 1
                clamped_by: null
                schema_fingerprint: 93cedf20
                request_id: 01J9Z2K7Q7
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  /tables/{table}/count:
    post:
      operationId: countRows
      summary: Count rows
      tags: [Rows]
      description: >
        How many rows match, without reading them. The question you ask right after a load
        finished: did all of them land.
      parameters:
        - $ref: '#/components/parameters/TablePath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CountRequest' }
      responses:
        '200':
          description: The count.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CountResult' }
              example:
                count: 22
                request_id: 01J9Z2K7Q8
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # Approved queries.
  #
  # Generated from the operator's query template file, one operation per template. The SQL is
  # fixed by the operator and parameters bind positionally, so a recipe supplies values and never
  # a query. The four below are this deployment's templates; a different deployment publishes a
  # different set under the same /q/ prefix.
  # ---------------------------------------------------------------------------
  /q/search_known_issues:
    post:
      operationId: searchKnownIssues
      summary: Find similar resolved tickets
      tags: [Approved queries]
      description: >
        Finds resolved support tickets similar to a customer's question, matching both meaning and
        exact tokens like error codes, and returns the fix along with the ticket it came from so a
        reply can cite its source.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query_text, product_filter, top_k]
              properties:
                query_text:
                  type: string
                  description: The customer's question, in their own words.
                  example: our integration platform cannot complete the TLS handshake
                product_filter:
                  type: string
                  description: Restrict to one product, or leave empty for all products.
                  example: ''
                top_k:
                  type: integer
                  description: How many to return. 3 to 5 works well.
                  minimum: 1
                  maximum: 20
                  example: 3
      responses:
        '200':
          description: Similar resolved tickets, best first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QueryResult' }
              example:
                rows:
                  - ticket_id: SUP-1001
                    product: TiDB Cloud Starter
                    title: Connection refused right after enabling the public endpoint
                    resolution: Starter requires TLS on the public endpoint.
                    resolved_on: '2026-02-11'
                    relevance: 0.032258
                returned: 1
                request_id: 01J9Z2K7Q9
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  /q/get_account_context:
    post:
      operationId: getAccountContext
      summary: Get account context
      tags: [Approved queries]
      description: >
        Everything about an account before you reply: plan, entitlement, first-response SLA, how
        many tickets are open, how many times they contacted support in the last 30 days, and the
        durable facts remembered about their environment. One call, not four.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_id]
              properties:
                account_id:
                  type: string
                  example: ACC-1002
      responses:
        '200':
          description: One account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QueryResult' }
              example:
                rows:
                  - account_id: ACC-1002
                    account_name: Bluejay Analytics
                    plan: pro
                    entitlement: business-hours
                    sla_first_response_mins: 240
                    open_tickets: 2
                    contacts_30d: 3
                    remembered_facts: 'workaround: splitting the scan by month is no longer holding'
                returned: 1
                request_id: 01J9Z2K7QA
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  /q/check_repeat_contact:
    post:
      operationId: checkRepeatContact
      summary: Check repeat contact
      tags: [Approved queries]
      description: >
        How many tickets this account opened recently, with the ticket ids. Use this to decide
        escalation instead of judging it: the count is a fact you can quote and an escalation that
        cites ticket ids can be audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_id, days]
              properties:
                account_id:
                  type: string
                  example: ACC-1002
                days:
                  type: integer
                  description: Window size in days. Use 30 unless told otherwise.
                  minimum: 1
                  maximum: 365
                  example: 30
      responses:
        '200':
          description: Contact counts for the window.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QueryResult' }
              example:
                rows:
                  - contacts_in_window: 3
                    still_open: 2
                    ticket_ids: 'SUP-1014,SUP-1102,SUP-1103'
                    first_contact: '2026-07-10 16:20:00'
                    latest_contact: '2026-07-28 15:40:00'
                returned: 1
                request_id: 01J9Z2K7QB
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  /q/record_case:
    post:
      operationId: recordCase
      summary: Record a resolution
      tags: [Approved queries]
      description: >
        Writes a resolution back onto a ticket so the next similar question retrieves this answer.
        The stored embedding is recomputed automatically, so the ticket starts matching questions
        it could not answer before, with no reindexing step.

        Call it only once the issue is actually resolved: it stamps the resolution time on every
        call, so calling twice moves that timestamp.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ticket_id, resolution, status]
              properties:
                ticket_id:
                  type: string
                  example: SUP-1101
                resolution:
                  type: string
                  description: What actually fixed it, written so a future reader can act on it.
                  example: Supply the ISRG Root X1 CA in the connector's SSL settings.
                status:
                  type: string
                  enum: [resolved, escalated]
                  example: resolved
      responses:
        '200':
          description: Written.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WriteQueryResult' }
              example:
                affected_rows: 1
                applied: true
                request_id: 01J9Z2K7QC
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/NotConfigured' }
        '504': { $ref: '#/components/responses/GatewayTimeout' }
        '422': { $ref: '#/components/responses/Unprocessable' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        A token issued by whoever runs this deployment. It is not a TiDB credential: the database
        user and password stay in the deployment's own configuration and are never sent from a
        recipe.

  parameters:
    TablePath:
      name: table
      in: path
      required: true
      description: Table name. Pick from List tables.
      schema: { type: string, example: tickets }

  responses:
    BadRequest:
      description: >
        The request was malformed or named something the table does not have: an unknown column in
        a filter, in `columns` or in `order_by`, a cursor that is unreadable or was issued for a
        different sort, or `updated_since` on a table whose timestamp column cannot be worked out.
        The `column` field names the offender where there is one.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              {
                code: column_not_found,
                message: "Table 'tickets' has no column 'prioirty'.",
                column: prioirty,
                retryable: false,
              }
            request_id: 3f9c1a77-8e42-4d1b-9a05-6c7e2b4d8f10
    PayloadTooLarge:
      description: >
        The request body exceeded the size cap, or a write carried more rows than one call
        accepts. Split the batch; the caps are on the whole surface, not on one operation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              {
                code: too_many_rows,
                message: 'At most 5000 rows per call; received 8000.',
                retryable: false,
              }
            request_id: 91b4c0de-7a13-4f52-8c66-1d2e9f3a5b78
    InternalError:
      description: >
        Something failed that this connector does not recognize. The cause is deliberately not in
        this response: it can carry index names, column names and fragments of the statement. Quote
        `request_id` to an operator, who can find the one log line that has it.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              {
                code: internal_error,
                message: 'The request could not be completed.',
                retryable: false,
              }
            request_id: c4a7e219-05bd-4e83-b71f-8a6d3c1092ef
    NotConfigured:
      description: >
        This deployment has no database configured. A configuration error rather than a request
        error, so it is the same answer for every call until an operator fixes it. Retrying will
        not help.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              {
                code: not_configured,
                message: 'This deployment has no database configuration.',
                retryable: false,
              }
            request_id: 7d2f8b40-1c96-4a5e-9038-e5b1a7c46d23
    GatewayTimeout:
      description: >
        The statement did not finish inside the server's own limit and was cut off rather than
        left to outlive the caller. Narrow the filter, lower the limit, or ask for fewer rows.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              {
                code: query_timeout,
                message: 'Statement did not finish within 25000 ms.',
                retryable: false,
              }
            request_id: 2e6b9d31-4f70-4c18-a25d-b90c7f83e614
    RateLimited:
      description: >
        Too many requests in the interval. Every operation can answer this: the limit is applied
        to the whole surface and before authentication, so an unauthenticated flood is bounded
        too. `Retry-After` gives the seconds to wait, and `retryable` is true.
      headers:
        Retry-After:
          description: Seconds until the window resets.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              {
                code: rate_limited,
                message: 'Too many requests. Retry after the interval given.',
                retryable: true,
              }
            request_id: 6f1b9a2c-4d3e-4a71-9f0c-2b8e5d7a1c34
    Unauthorized:
      description: Missing or invalid token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              { code: unauthorized, message: 'Token missing or not recognized.', retryable: false }
            request_id: 01J9Z2K7QD
    NotFound:
      description: No such table.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              {
                code: table_not_found,
                message: "No table named 'tikets' in workato_poc.",
                retryable: false,
              }
            request_id: 01J9Z2K7QE
    Unprocessable:
      description: The request was understood but cannot be run as written.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: feature_unavailable
              message: >
                Semantic search needs a vector index. Table 'accounts' has none, so only exact
                filters are available on it.
              retryable: false
            request_id: 01J9Z2K7QF

  schemas:
    # -- shared -------------------------------------------------------------

    Filter:
      type: object
      description: One equality condition. Conditions are combined with AND.
      required: [column, value]
      properties:
        column: { type: string, example: status }
        value:
          description: >
            Matched exactly. Send it as it is stored: text as text, whole numbers as numbers,
            dates as "YYYY-MM-DD HH:MM:SS" with no timezone suffix.
          example: open
      additionalProperties: false

    Row:
      type: object
      description: >
        One row, keyed by column name. Missing values come back as null rather than being left
        out, so a recipe field never silently disappears when a value is empty.
      additionalProperties: true

    ErrorEnvelope:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: >
                Stable machine-readable reason. One of: unauthorized, table_not_found,
                column_not_found, column_not_writable, duplicate_key, write_conflict,
                schema_changed, feature_unavailable, query_timeout, rate_limited, internal_error.
              example: column_not_writable
            message: { type: string, description: Plain description safe to show a builder. }
            column: { type: string, nullable: true }
            retryable:
              type: boolean
              description: True only for transient causes such as a write conflict or rate limiting.
        request_id: { type: string }

    # -- connection ---------------------------------------------------------

    PingResult:
      type: object
      properties:
        ok: { type: boolean }
        warm:
          type: boolean
          description: >
            False when the cluster did not answer in time and the database facts below are
            unknown. The connection is still valid; the first real action will warm it.
        database: { type: string, nullable: true }
        tidb_version: { type: string, nullable: true }
        features:
          type: object
          description: >
            What this cluster can do. Auto embedding and full-text search are available on TiDB
            Cloud Starter on AWS; elsewhere search falls back to what the table's indexes support.
          properties:
            auto_embedding: { type: boolean, nullable: true }
            fulltext_search: { type: boolean, nullable: true }
            vector_index: { type: boolean, nullable: true }
        server_time_utc: { type: string }
        latency_ms: { type: integer }
        request_id: { type: string }

    # -- discovery ----------------------------------------------------------

    TableList:
      type: object
      properties:
        tables:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              approx_rows: { type: integer }
              primary_key: { type: array, items: { type: string } }
              searchable:
                type: boolean
                description: True when Search will work on this table, that is it has a vector column or a full-text index.
              has_vector_column: { type: boolean }
              has_fulltext_index: { type: boolean }

    TableSchema:
      type: object
      properties:
        table: { type: string }
        primary_key: { type: array, items: { type: string } }
        unique_keys: { type: array, items: { type: array, items: { type: string } } }
        schema_fingerprint:
          type: string
          description: >
            Changes when the table's shape changes. Informational only, never enforced: a recipe
            can compare it across runs to notice a schema change instead of finding out through
            blank fields.
        search:
          type: object
          description: >
            What this table can be searched on, and what Search will pick if you say nothing.
            This is the list `vector_column` and `fulltext_column` are chosen from, which is what
            makes them discoverable rather than guessable.
          properties:
            vector_columns:
              type: array
              items: { type: string }
              description: Every embedding column on the table, indexed or not.
            default_vector_column:
              type: string
              nullable: true
              description: >
                The one Search uses unless told otherwise: the first vector column carrying a
                vector index. Null means searching by meaning is unavailable here.
            fulltext_columns:
              type: array
              items: { type: string }
              description: >
                Every full-text indexed column. Search uses the first unless told otherwise, and
                only ever one of them.
            has_vector_index:
              type: boolean
              description: >
                Whether an index backs the vector column. False still searches, by scanning, which
                is fine on a small table and not on a large one.
            supports_hybrid:
              type: boolean
              description: >
                Whether the two rankings can be fused here. Requires an embedding, a full-text
                index, and a single-column primary key to join the two ranked sets on. A composite
                primary key is the case that fails, and it fails with a named reason rather than
                by degrading to one side.
        write:
          type: object
          description: >
            What a write to this table will report back. Here so the rule is discoverable rather
            than inferred from an empty field: a caller who sends two rows and gets no key cannot
            otherwise tell that apart from a table that generates no key at all.
          properties:
            generated_key_column:
              type: string
              nullable: true
              description: >
                The column the database fills in itself (AUTO_RANDOM or AUTO_INCREMENT), or null
                when there is none. Only this column is ever filled in from a generated value; on
                a composite key the other parts report null unless you sent them.
            reports_generated_key:
              type: string
              description: >
                When that key comes back. Today, only for a request carrying exactly one row.
        columns:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              type: { type: string, description: The TiDB column type. }
              json_type:
                type: string
                enum: [string, number, boolean, array, object]
                description: >
                  How the value appears in JSON. Whole-number keys arrive as strings because they
                  can exceed what JSON numbers represent exactly; counts and scores arrive as
                  numbers so they can be compared directly.
              nullable: { type: boolean }
              write_class:
                type: string
                enum: [writable, db_managed, rejects_write]
              write_class_reason: { type: string, nullable: true }
              is_vector: { type: boolean }
              vector_dims: { type: integer, nullable: true }
              embed_source_columns:
                type: array
                nullable: true
                items: { type: string }
                description: Columns TiDB reads to build this embedding. Changing one recomputes it.

    # -- search -------------------------------------------------------------

    SearchRequest:
      type: object
      required: [query_text]
      properties:
        query_text:
          type: string
          description: What you are looking for, in ordinary words.
          example: customer cannot connect, the TLS handshake fails
        top_k:
          type: integer
          default: 5
          minimum: 1
          maximum: 50
          description: How many matches to return.
        filters:
          type: array
          items: { $ref: '#/components/schemas/Filter' }
          description: Narrow the search before ranking. Combined with AND.
        columns:
          type: array
          items: { type: string }
          description: Which columns to return. Leave empty for all except vectors.
        mode:
          type: string
          enum: [auto, hybrid, meaning, keyword]
          default: auto
          description: >
            Leave on auto. It uses meaning and keywords together when the table supports both, and
            falls back to whichever it has.
        vector_column:
          type: string
          nullable: true
          description: >
            Which embedding to search by meaning. Defaults to the table's first indexed vector
            column, which is the only one on most tables. Naming a column the table does not carry
            an embedding on is an error, not a silent fallback. Choices are in the `search` block
            of GET /tables/{table}.
          example: embedding
        fulltext_column:
          type: string
          nullable: true
          description: >
            Which column to match by keyword. Defaults to the table's first full-text indexed
            column. One column, not several: TiDB will not accept two full-text predicates in one
            statement. Choices are in the `search` block of GET /tables/{table}.
          example: body
      additionalProperties: false

    SearchResult:
      type: object
      properties:
        matches:
          type: array
          items:
            type: object
            properties:
              relevance:
                type: number
                description: Higher is a better match. Comparable within one response, not across responses.
              row: { $ref: '#/components/schemas/Row' }
        searched_with:
          type: string
          enum: [hybrid, meaning, keyword]
          description: What actually ran, so a keyword-only fallback is visible rather than silent.
        query_time_ms: { type: integer }
        request_id: { type: string }

    # -- rows ---------------------------------------------------------------

    UpsertRequest:
      type: object
      required: [rows]
      properties:
        rows:
          type: array
          minItems: 1
          maxItems: 5000
          items: { $ref: '#/components/schemas/Row' }
          description: >
            The rows to write. Include only columns whose write_class is writable.
      additionalProperties: false

    WriteResult:
      type: object
      properties:
        status:
          type: string
          enum: [ok, partial, failed]
        all_succeeded:
          type: boolean
          description: Branch on this. False means read `results` to see which rows did not land.
        succeeded: { type: integer }
        failed: { type: integer }
        results:
          type: array
          items:
            type: object
            properties:
              index:
                type: integer
                description: Position in the rows you sent.
              ok: { type: boolean }
              keys:
                type: object
                additionalProperties: true
                nullable: true
                description: >
                  The row's primary key after writing. A key you sent is echoed back. A key the
                  database generated (AUTO_RANDOM, AUTO_INCREMENT) is reported only when the
                  request contained exactly ONE row; send one row per call when you need the key
                  back. A multi-row call reports null for generated keys, because one statement
                  reports one key however many rows it wrote and there is no way to tell which
                  row it belongs to.

                  Generated keys arrive as strings. They routinely exceed what a JSON number
                  holds exactly, so comparing one numerically will not do what you expect; treat
                  it as an opaque identifier.

                  On a composite key, only the column the database actually generates is filled
                  in. The others report null unless you sent them.
              error:
                nullable: true
                type: object
                properties:
                  code: { type: string }
                  message: { type: string }
                  column: { type: string, nullable: true }
        request_id: { type: string }

    ListRequest:
      type: object
      properties:
        filters:
          type: array
          items: { $ref: '#/components/schemas/Filter' }
        updated_since:
          type: string
          nullable: true
          description: >
            Only rows whose timestamp column is at or after this. Format "YYYY-MM-DD HH:MM:SS",
            no timezone suffix. Used as a starting bound, never as the paging key, so rows sharing
            a timestamp are not skipped at a page boundary.
          example: '2026-07-01 00:00:00'
        updated_since_column:
          type: string
          nullable: true
          description: >
            Which column `updated_since` bounds. Left empty, the column is guessed: `updated_at`,
            then any datetime column whose name ends in `_at`. A table where the guess finds
            nothing is a 400 naming this parameter, never a call that quietly returns every row,
            because an incremental sync that silently receives the whole table has no way to
            notice. Ignored when `updated_since` is empty.
          example: updated_at
        columns:
          type: array
          items: { type: string }
          description: Which columns to return. Leave empty for all except vectors.
        order_by:
          type: string
          nullable: true
          description: Column to sort by. Defaults to the primary key.
        limit:
          type: integer
          default: 500
          minimum: 1
          maximum: 10000
        cursor:
          type: string
          nullable: true
          description: Leave empty for the first page, then pass the cursor from the previous response.
      additionalProperties: false

    ListResult:
      type: object
      properties:
        rows:
          type: array
          items: { $ref: '#/components/schemas/Row' }
        returned: { type: integer }
        has_more:
          type: boolean
          description: Call again with `cursor` while this is true.
        cursor: { type: string, nullable: true }
        clamped_by:
          type: string
          nullable: true
          enum: [null, rows, bytes]
          description: >
            Set when the page was cut short. `bytes` means the rows were large and fewer were
            returned than you asked for. `has_more` is true in that case; it is never a silent
            truncation.
        schema_fingerprint: { type: string }
        request_id: { type: string }

    CountRequest:
      type: object
      properties:
        filters:
          type: array
          items: { $ref: '#/components/schemas/Filter' }
      additionalProperties: false

    CountResult:
      type: object
      properties:
        count:
          type: integer
          description: A number, not a string, so it can be compared directly in a recipe.
        request_id: { type: string }

    # -- approved queries ---------------------------------------------------

    QueryResult:
      type: object
      properties:
        rows:
          type: array
          items: { $ref: '#/components/schemas/Row' }
        returned: { type: integer }
        request_id: { type: string }

    WriteQueryResult:
      type: object
      properties:
        affected_rows:
          type: integer
          description: Rows the statement changed.
        applied:
          type: boolean
          description: >
            Branch on this, not on the count. False means nothing was written, and it is the
            answer both when no row matched the parameters and when the row already held these
            values. A bare zero reads as success to anything summarising the call, which is the
            mistake this field exists to stop.
        note:
          type: string
          description: >
            Present only when `applied` is false. Says which two situations produced it, because
            telling them apart would need a read before the write.
        request_id: { type: string }
