openapi: 3.2.0

info:
  title: Basis Stream API
  version: "1"
  description: >
    Take an xlsx. Get back the computation, the references, the media, and a
    certificate of trust — in a shape your language model can act on
    directly. Basis parses .xlsx/.xlsm workbooks and streams back newline-
    delimited JSON: a typed cell graph, a formula dependency edge list,
    cell-anchored embedded images, and an audit trail verifying every
    formula against Excel's own cached values.

    Two ways in: `QUERY /api/v1/stream` blocks until the parse is done and
    returns NDJSON directly — the parse is a safe, idempotent, deterministic
    computation (same bytes in, same NDJSON out), which is exactly what
    HTTP QUERY (RFC 10008) is for. `POST /api/v1/stream` still works
    identically as a deprecated compatibility fallback for clients/proxies
    that don't yet support QUERY. `POST /v1/jobs` decides per-request
    whether to respond inline or as a pollable/webhook-notified async job,
    based on file size and how fast the parse actually finishes.

    xlsx/xlsm are parsed natively. Legacy `.xls` and CSV-shaped plain text
    are also accepted on a best-effort basis: when the native parser rejects
    the format, the server converts the upload to `.xlsx` via headless
    LibreOffice and retries once before giving up. This adds latency
    (bounded, ~15s worst case) and isn't a lossless round-trip — see
    `/docs#xls-csv-fallback` for details. `xlsb`, `ods`, and encrypted `.xls`
    are still unsupported.

    Full human-readable reference: `/docs`. Machine-readable index:
    `/llms.txt`. Full docs as plain text: `/llms-full.txt`.
  contact:
    url: https://basis1.tech

servers:
  - url: /
    description: Relative to whichever host this spec was fetched from.

security:
  - bearerAuth: []

tags:
  - name: stream
    description: Synchronous parse.
  - name: jobs
    description: Async, pollable/webhook-notified parse.
  - name: meta
    description: Health and discovery.

paths:
  /healthz:
    get:
      tags: [meta]
      summary: Liveness check
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            text/plain:
              schema:
                type: string
                example: ok

  /api/v1/stream:
    query:
      tags: [stream]
      summary: Parse a workbook, blocking until done
      description: >
        Preferred entry point. The parse is a safe, idempotent, deterministic
        computation — same bytes in, same NDJSON out, no server-side
        mutation — which is exactly the contract HTTP QUERY (RFC 10008)
        exists for. Always blocks until the parse completes; for files near
        the size limit, prefer `POST /v1/jobs` instead — it won't hold the
        connection open for the duration of a slow parse.
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
              description: >
                Raw .xlsx/.xlsm bytes (parsed natively). Legacy .xls and
                CSV-shaped plain text are also accepted on a best-effort
                basis via server-side conversion — see `/docs#xls-csv-fallback`.
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      parameters:
        - name: Accept-Encoding
          in: header
          required: false
          schema:
            type: string
            example: gzip
          description: Send `gzip` — the response compresses ~15x for text records.
      responses:
        "200":
          description: Parse succeeded. Body is NDJSON, one record per line, in a fixed emission order (see `/docs#ordering`).
          headers:
            x-basis-api-version:
              schema: { type: string, example: "1" }
            content-encoding:
              schema: { type: string, example: gzip }
            Accept-Query:
              schema: { type: string, example: "application/octet-stream, multipart/form-data" }
              description: >
                Per RFC 10008 §Accept-Query — advertises which request-body
                media types this resource accepts via QUERY, for clients
                probing capability before sending a large body.
          content:
            application/x-ndjson:
              schema:
                type: string
                description: >
                  Newline-delimited JSON. Each line is one of the record
                  schemas under `#/components/schemas`: WorkbookMeta, Sheet,
                  Cell, Edge, Image, ImageSummary, or Audit.
                x-ndjson-line-schema:
                  oneOf:
                    - $ref: "#/components/schemas/WorkbookMeta"
                    - $ref: "#/components/schemas/Sheet"
                    - $ref: "#/components/schemas/Cell"
                    - $ref: "#/components/schemas/Edge"
                    - $ref: "#/components/schemas/Image"
                    - $ref: "#/components/schemas/ImageSummary"
                    - $ref: "#/components/schemas/Audit"
                  discriminator:
                    propertyName: type
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ParseFailed" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [stream]
      summary: Parse a workbook, blocking until done (deprecated — use QUERY)
      deprecated: true
      description: >
        Deprecated compatibility fallback for clients/proxies that don't yet
        support HTTP QUERY (RFC 10008). Identical behavior to `query:` above
        — prefer QUERY for new integrations. For files near the size limit,
        prefer `POST /v1/jobs` instead — it won't hold the connection open
        for the duration of a slow parse.
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
              description: >
                Raw .xlsx/.xlsm bytes (parsed natively). Legacy .xls and
                CSV-shaped plain text are also accepted on a best-effort
                basis via server-side conversion — see `/docs#xls-csv-fallback`.
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      parameters:
        - name: Accept-Encoding
          in: header
          required: false
          schema:
            type: string
            example: gzip
          description: Send `gzip` — the response compresses ~15x for text records.
      responses:
        "200":
          description: Parse succeeded. Body is NDJSON, one record per line, in a fixed emission order (see `/docs#ordering`).
          headers:
            x-basis-api-version:
              schema: { type: string, example: "1" }
            content-encoding:
              schema: { type: string, example: gzip }
            Accept-Query:
              schema: { type: string, example: "application/octet-stream, multipart/form-data" }
              description: >
                Per RFC 10008 §Accept-Query — advertises which request-body
                media types this resource accepts via QUERY, so a client
                talking POST today can discover it should switch.
          content:
            application/x-ndjson:
              schema:
                type: string
                description: >
                  Newline-delimited JSON. Each line is one of the record
                  schemas under `#/components/schemas`: WorkbookMeta, Sheet,
                  Cell, Edge, Image, ImageSummary, or Audit.
                x-ndjson-line-schema:
                  oneOf:
                    - $ref: "#/components/schemas/WorkbookMeta"
                    - $ref: "#/components/schemas/Sheet"
                    - $ref: "#/components/schemas/Cell"
                    - $ref: "#/components/schemas/Edge"
                    - $ref: "#/components/schemas/Image"
                    - $ref: "#/components/schemas/ImageSummary"
                    - $ref: "#/components/schemas/Audit"
                  discriminator:
                    propertyName: type
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ParseFailed" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/jobs:
    post:
      tags: [jobs]
      summary: Submit a workbook for parsing, inline or async
      description: >
        Same body intake as `/api/v1/stream`. Responds `200` with the full
        NDJSON body when the upload is small and the parse finishes quickly;
        otherwise falls back to `202` with a job handle. `?mode=sync` forces
        a blocking `200`; `?mode=job` forces a `202` regardless of size.
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      parameters:
        - name: mode
          in: query
          required: false
          schema: { type: string, enum: [sync, job] }
          description: Omit to let the server decide based on size and parse latency.
        - name: webhook_url
          in: query
          required: false
          schema: { type: string, format: uri }
          description: Delivered a single best-effort, HMAC-SHA256-signed POST when the job finishes or fails.
      responses:
        "200":
          description: Parsed inline — small and fast enough.
          headers:
            x-basis-api-version:
              schema: { type: string, example: "1" }
            x-basis-job-id:
              schema: { type: string, format: uuid }
            x-basis-job-status:
              schema: { type: string, example: completed }
          content:
            application/x-ndjson:
              schema: { type: string }
        "202":
          description: Accepted as an async job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobHandle" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ParseFailed" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/jobs/{id}:
    get:
      tags: [jobs]
      summary: Poll job status
      description: >
        Jobs are visible only to the token that created them — a mismatched
        token or an unknown/expired id both return 404, indistinguishably.
        Jobs are in-memory only; nothing is persisted to disk, and results
        are evicted after `expires_at` (15 minutes after creation by
        default).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Current job state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/jobs/{id}/stream:
    get:
      tags: [jobs]
      summary: Fetch a finished job's NDJSON result
      description: >
        Resumable fetch of a *finished* result, not a live feed of an
        in-flight parse. `?since=<record_index>` resumes from a record
        index for a client that got disconnected mid-download.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: since
          in: query
          required: false
          schema: { type: integer, minimum: 0 }
          description: Record index already downloaded; response contains only the remainder.
      responses:
        "200":
          description: The job's NDJSON body (same shape as `/api/v1/stream`'s 200).
          headers:
            x-basis-api-version:
              schema: { type: string, example: "1" }
            x-basis-job-id:
              schema: { type: string, format: uuid }
            x-basis-job-status:
              schema: { type: string, example: completed }
          content:
            application/x-ndjson:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Job is still processing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ParseFailed" }

  /v1/jobs/{id}/sheets:
    get:
      tags: [jobs]
      summary: List a finished job's sheets
      description: >
        One entry per worksheet, in tab order, once the job has completed.
        Use each entry's `stream_url` to fetch that sheet's own NDJSON
        records instead of buffering the whole job's stream — useful for
        large multi-sheet workbooks where a client only needs one sheet at
        a time.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The job's sheet list.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [index, title, state, cell_count, image_count, stream_url]
                  properties:
                    index: { type: integer, description: "Matches Sheet.index and Cell.sheet in the whole-job stream." }
                    title: { type: string }
                    state: { type: string, enum: [visible, hidden, veryHidden] }
                    cell_count: { type: integer }
                    image_count: { type: integer }
                    stream_url: { type: string, description: "GET /v1/jobs/{id}/sheets/{index}/stream" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Job is still processing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ParseFailed" }

  /v1/jobs/{id}/sheets/{index}/stream:
    get:
      tags: [jobs]
      summary: Fetch one sheet's NDJSON records, resumable
      description: >
        Same resumability contract as `/v1/jobs/{id}/stream`
        (`?since=<record_index>`), scoped to a single sheet. The body
        contains only that sheet's own `sheet` and `cell` records (and any
        `image` records anchored to it) — `workbook_meta`, `edge`,
        `image_summary`, and `audit` are job-level only and not available
        per-sheet, since edges can cross sheets and the others summarize the
        whole workbook. Fetch the full job stream instead if you need those.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: index
          in: path
          required: true
          schema: { type: integer, minimum: 0 }
          description: Sheet index, as listed by `GET /v1/jobs/{id}/sheets`.
        - name: since
          in: query
          required: false
          schema: { type: integer, minimum: 0 }
          description: Record index (within this sheet's sub-stream) already downloaded; response contains only the remainder.
      responses:
        "200":
          description: The sheet's NDJSON body (`sheet`, `cell`, and any anchored `image` records).
          headers:
            x-basis-api-version:
              schema: { type: string, example: "1" }
            x-basis-job-id:
              schema: { type: string, format: uuid }
            x-basis-job-status:
              schema: { type: string, example: completed }
          content:
            application/x-ndjson:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Job is still processing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ParseFailed" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        Per-customer API key: `Authorization: Bearer basis_live_<id>_<secret>`.
        Keys are issued and revoked independently, scope jobs to the issuing
        customer, and carry usage quotas (see the 402 response). A revoked key is
        rejected immediately.

  responses:
    BadRequest:
      description: Malformed or empty request body.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid bearer token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    QuotaExceeded:
      description: >
        The key's usage quota for the current period is exhausted. The response
        carries `X-Basis-Quota-Limit`, `X-Basis-Quota-Used`, and
        `X-Basis-Quota-Reset` (ISO-8601) headers, and the error body names the
        exceeded `dimension` (`requests`, `bytes_in`, or `cells_out`).
      headers:
        X-Basis-Quota-Limit:
          schema: { type: integer }
        X-Basis-Quota-Used:
          schema: { type: integer }
        X-Basis-Quota-Reset:
          schema: { type: string, format: date-time }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such route or job.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PayloadTooLarge:
      description: Body exceeded the max request size (200MB by default).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ParseFailed:
      description: >
        The bytes couldn't be parsed as xlsx/xlsm, and either weren't a
        format the .xls/CSV conversion fallback attempts, or that fallback
        also failed. See `/docs#xls-csv-fallback`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - bad_request
                - empty_body
                - unauthorized
                - payload_too_large
                - parse_failed
                - method_not_allowed
                - not_found
                - job_not_ready
                - internal_error
            message:
              type: string

    JobHandle:
      type: object
      required: [job_id, status, status_url, stream_url]
      properties:
        job_id: { type: string, format: uuid }
        status: { type: string, enum: [processing, completed, failed] }
        status_url: { type: string }
        stream_url: { type: string }

    JobStatus:
      type: object
      required: [job_id, status, created_at, updated_at, expires_at, bytes_in, stream_url]
      properties:
        job_id: { type: string, format: uuid }
        status: { type: string, enum: [processing, completed, failed] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        bytes_in: { type: integer }
        records_out: { type: [integer, "null"] }
        stream_url: { type: string }
        sheets_url: { type: string, description: "GET /v1/jobs/{id}/sheets. Populated once status is completed." }
        error:
          oneOf:
            - type: "null"
            - type: object
              required: [code, message]
              properties:
                code: { type: string }
                message: { type: string }

    StreamNumberValue:
      description: JSON forbids non-finite floats, so they render as string sentinels.
      oneOf:
        - type: number
        - type: string
          enum: ["NaN", "Infinity", "-Infinity"]
        - type: "null"

    WorkbookMeta:
      type: object
      description: Emitted once, first. Treat as the response header.
      required: [type, date_system, calc_iterative, calc_count, has_vba, sheet_count, core_properties, defined_names]
      properties:
        type: { type: string, enum: [workbook_meta] }
        date_system: { type: string, enum: ["1900", "1904"] }
        calc_iterative: { type: boolean }
        calc_count: { type: [integer, "null"] }
        has_vba: { type: boolean }
        sheet_count: { type: integer }
        core_properties:
          type: object
          additionalProperties: { type: string }
        defined_names:
          type: array
          items:
            type: object
            required: [name, refers_to, scope, is_builtin, is_broken, usable]
            properties:
              name: { type: string }
              refers_to: { type: string }
              scope: { type: [integer, "null"] }
              is_builtin: { type: boolean }
              is_broken: { type: boolean }
              usable: { type: boolean }

    Sheet:
      type: object
      description: One per worksheet, in tab order. `index` is what every other record's `sheet` field references.
      required: [type, index, title, state, nrows, ncells, merged, hidden_rows, hidden_cols, comments, data_tables]
      properties:
        type: { type: string, enum: [sheet] }
        index: { type: integer }
        title: { type: string }
        state: { type: string, enum: [visible, hidden, veryHidden] }
        nrows: { type: integer }
        ncells: { type: integer }
        merged:
          type: array
          items: { type: string }
          description: A1-notation ranges, e.g. "B2:D4".
        hidden_rows: { type: array, items: { type: integer } }
        hidden_cols: { type: array, items: { type: integer } }
        comments:
          type: array
          items:
            type: object
            required: [cell, author, text, ts, kind]
            properties:
              cell: { type: string }
              author: { type: [string, "null"] }
              text: { type: string }
              ts: { type: [string, "null"], format: date-time }
              kind: { type: string, description: "threaded | legacy | gsheets" }
        data_tables:
          type: array
          items:
            type: object
            required: [cell, ref, row_input, col_input, two_dimensional]
            properties:
              cell: { type: string }
              ref: { type: string }
              row_input: { type: string }
              col_input: { type: string }
              two_dimensional: { type: boolean }

    Cell:
      type: object
      description: One per non-empty cell, column-major within a sheet.
      required: [type, sheet, ref, kind, num, str, formula, fmt, err]
      properties:
        type: { type: string, enum: [cell] }
        sheet: { type: integer }
        ref: { type: string, example: AG441 }
        kind: { type: string, enum: [number, string, bool, error, formula_number, formula_other] }
        num: { $ref: "#/components/schemas/StreamNumberValue" }
        str: { type: [string, "null"] }
        formula: { type: [string, "null"], description: "No leading =." }
        fmt: { type: [string, "null"], description: 'Number format code; null = General.' }
        err: { type: [string, "null"], description: 'Excel error literal, e.g. "#DIV/0!".' }

    Edge:
      type: object
      description: Every formula reference, after shared-formula expansion and named-ref resolution.
      required: [type, src_sheet, src_cell, tgt_sheet, tgt_ref, raw, named_ref, kind, is_range, is_cross_sheet, is_external, is_ref_error]
      properties:
        type: { type: string, enum: [edge] }
        src_sheet: { type: string, description: "Display name, not index." }
        src_cell: { type: string }
        tgt_sheet: { type: [string, "null"] }
        tgt_ref: { type: string }
        raw: { type: string }
        named_ref: { type: [string, "null"] }
        kind: { type: string, enum: [cell, range, named, error, data_table_input] }
        is_range: { type: boolean }
        is_cross_sheet: { type: boolean }
        is_external: { type: boolean }
        is_ref_error: { type: boolean }

    Image:
      type: object
      description: Every embedded picture. One record per drawing anchor.
      required: [type, sheet, index, anchor, from, to, ext_cx_emu, ext_cy_emu, col_off_emu, row_off_emu, mime, sha256, byte_len, alt, data_base64]
      properties:
        type: { type: string, enum: [image] }
        sheet: { type: integer }
        index: { type: integer, description: "Workbook-wide stable index." }
        anchor: { type: string, enum: [two_cell, one_cell, absolute] }
        from: { type: [string, "null"] }
        to: { type: [string, "null"] }
        ext_cx_emu: { type: [integer, "null"] }
        ext_cy_emu: { type: [integer, "null"] }
        col_off_emu: { type: [integer, "null"] }
        row_off_emu: { type: [integer, "null"] }
        mime: { type: string }
        sha256: { type: string, description: "Hex SHA-256 of raw decompressed bytes; stable dedup key." }
        byte_len: { type: integer }
        alt: { type: [string, "null"] }
        data_base64: { type: [string, "null"], description: "Populated iff byte_len <= 1,500,000." }

    ImageSummary:
      type: object
      description: Workbook-wide image roll-up. Emitted once, always.
      required: [type, total_images, total_bytes, inlined]
      properties:
        type: { type: string, enum: [image_summary] }
        total_images: { type: integer }
        total_bytes: { type: integer }
        inlined: { type: integer }

    Audit:
      type: object
      description: Post-recalc trust signals. Discriminated by `finding`.
      required: [type, finding]
      properties:
        type: { type: string, enum: [audit] }
        finding:
          type: string
          enum: [verification, plug, plug_summary, broken_ref, cycles, staleness]
      oneOf:
        - $ref: "#/components/schemas/AuditVerification"
        - $ref: "#/components/schemas/AuditPlug"
        - $ref: "#/components/schemas/AuditPlugSummary"
        - $ref: "#/components/schemas/AuditBrokenRef"
        - $ref: "#/components/schemas/AuditCycles"
        - $ref: "#/components/schemas/AuditStaleness"

    AuditVerification:
      type: object
      description: Emitted exactly once — how many re-evaluated formulas matched Excel's cached values.
      required: [finding, formula_cells, compared, mismatches, coverage_pct, errors, unsupported, unsupported_breakdown, mismatch_samples]
      properties:
        finding: { type: string, enum: [verification] }
        formula_cells: { type: integer }
        compared: { type: integer }
        mismatches: { type: integer }
        coverage_pct: { type: number }
        errors: { type: integer }
        unsupported: { type: integer }
        unsupported_breakdown:
          type: array
          description: >-
            Reason-bucketed breakdown of `unsupported`, sourced from the compile-failure
            reason for each skipped formula. Sorted by count descending, capped at 20
            entries with the remainder rolled into a single `"other"` bucket. Empty array
            when `unsupported` is 0. See the Supported functions section for the full list
            of functions Basis can evaluate.
          maxItems: 21
          items:
            type: object
            required: [reason, count]
            properties:
              reason: { type: string }
              count: { type: integer }
        mismatch_samples:
          type: array
          maxItems: 256
          items:
            type: object
            required: [sheet, sheet_name, ref, cached, computed]
            properties:
              sheet: { type: integer }
              sheet_name: { type: string }
              ref: { type: string }
              cached: { $ref: "#/components/schemas/StreamNumberValue" }
              computed: { $ref: "#/components/schemas/StreamNumberValue" }

    AuditPlug:
      type: object
      description: A cell that breaks a fill-down/fill-right formula run — typically a hand-punched constant. Up to 200 per response.
      required: [finding, sheet, sheet_name, ref, kind, value, expected, axis]
      properties:
        finding: { type: string, enum: [plug] }
        sheet: { type: integer }
        sheet_name: { type: string }
        ref: { type: string }
        kind: { type: string, description: "constant_in_formula_run | formula_deviation" }
        value: { type: string }
        expected: { type: string }
        axis: { type: string, enum: [row, column] }

    AuditPlugSummary:
      type: object
      description: Emitted exactly once, alongside zero or more `plug` findings.
      required: [finding, emitted, total, cap, truncated]
      properties:
        finding: { type: string, enum: [plug_summary] }
        emitted: { type: integer }
        total: { type: integer }
        cap: { type: integer }
        truncated: { type: boolean }

    AuditBrokenRef:
      type: object
      description: A cell whose formula contains #REF!. Up to 100 per response.
      required: [finding, sheet, sheet_name, ref, raw]
      properties:
        finding: { type: string, enum: [broken_ref] }
        sheet: { type: integer }
        sheet_name: { type: string }
        ref: { type: string }
        raw: { type: string }

    AuditCycles:
      type: object
      description: Emitted exactly once.
      required: [finding, count, iterative_calc, sanctioned, members]
      properties:
        finding: { type: string, enum: [cycles] }
        count: { type: integer }
        iterative_calc: { type: boolean }
        sanctioned: { type: boolean }
        members:
          type: array
          maxItems: 40
          items:
            type: object
            required: [sheet_name, ref]
            properties:
              sheet_name: { type: string }
              ref: { type: string }

    AuditStaleness:
      type: object
      description: Emitted exactly once. Summarizes volatile cells, external refs, hidden structure.
      required: [finding, volatile_cells, external_refs, hidden_sheets, hidden_rows, hidden_cols]
      properties:
        finding: { type: string, enum: [staleness] }
        volatile_cells: { type: integer }
        external_refs: { type: integer }
        hidden_sheets: { type: array, items: { type: string } }
        hidden_rows: { type: integer }
        hidden_cols: { type: integer }
