# =============================================================================
# Plantactic Public API (ptx-api) — v1
#
# WHAT THIS DOCUMENT IS, AND WHY IT IS STILL HAND-AUTHORED
# -----------------------------------------------------------------------------
# The initiative spec (docs/superpowers/specs/2026-08-17-plantactic-initiative-design.md
# §9.5) states the rule plainly: OpenAPI is the source of truth, it is generated
# from the code via springdoc, and **reference documentation is never
# hand-written**. This file does not follow that rule, and saying so plainly is
# the point of this paragraph.
#
# It was written BEFORE the service, as the contract ptx-api had to implement,
# on the understanding that springdoc would emit the real spec on the day the
# service shipped and this document would be deleted. The service shipped. The
# second half did not happen: springdoc is not a dependency of
# services/ptx-api/pom.xml, nothing generates a spec from the code, and nothing
# in CI diffs this file against the running service.
#
# So this is a hand-authored document describing a service that exists, with no
# mechanical check that the two agree. That is exactly the arrangement §9.5
# forbids, and it produced exactly what §9.5 predicts: by August 2026 this file
# described OAuth against a host that was never built, five scopes that were
# never implemented, rate limiting that does not exist, three input models where
# the code has two, and a grid response with nine fields the service does not
# send. Every one of those was found by a human reading the Java, which is the
# review that does not scale and the reason the rule exists.
#
# The retirement condition is unchanged and now overdue:
#
#   Add springdoc to ptx-api, have CI diff its output against this document,
#   and DELETE this document. Until then, anything here is a claim about the
#   code that only a reader can check — so when you change ptx-api, change this
#   too, and cite the class you read.
#
# THE SERVICE IS LIVE
# -----------------------------------------------------------------------------
# See the `servers` block. There is a real host now and it answers. Read the
# comment there anyway — the host that is right is not the host this file used
# to name, and the one it used to name still belongs to something else.
#
# WHERE THE EXAMPLES COME FROM
# -----------------------------------------------------------------------------
# Response examples are NOT invented. Grid figures are lifted verbatim from the
# committed golden master:
#
#   services/company/src/test/resources/golden/grid-monthly.json
#
# which is real V3 engine output over "Lexop" — a non-active test company with
# generated data — and is produced by the same engine ptx-api runs
# (libs/ptx-engine, via GridComputation). `apps/developer/src/data/sample-grid.json`
# holds the same slice. No real customer's figures appear anywhere in this file.
#
# Grid examples below are ABRIDGED to a handful of periods and one or two
# sections, because the service returns the dataset's whole axis and every
# section it can compute — a faithful example would be thousands of lines. The
# numbers are real; the length is not.
#
# The wire shapes are transcribed from the code that produces them:
#
#   services/ptx-api/src/main/java/.../metrics/GridComputation.java   (the grid)
#   services/ptx-api/src/main/java/.../metrics/MetricCatalog.java     (the catalog)
#   services/ptx-api/src/main/java/.../dataset/DatasetDtos.java       (datasets, versions)
#   libs/ptx-engine/src/main/java/.../engine/MetricRow.java           (a grid row)
#
# Where a field's presence or absence is decided by serialization rather than by
# a record component, the description says so — ptx-api uses a plain mapper with
# no null-omission policy, so a null component IS on the wire as null.
# =============================================================================

openapi: 3.1.0

info:
  title: Plantactic API
  version: 1.0.0
  summary: 706 SaaS metric definitions, computed by the same engine that runs the Plantactic app.
  description: |
    Send contracts. Get back ARR waterfalls, cohort retention, unit economics
    and burn multiple — computed by the V3 engine, not a second implementation
    that drifts from it.

    Contracts are the only input shape this service ingests today. A dataset can
    be *labelled* `revenue_events`, and that label is recorded, but there is no
    revenue-event upload path — see `InputModel`.

    > **Status: live.** `ptx-api` is deployed and answering at
    > `https://ptx-api.plantactic.com` — see **Servers** below, and note that it
    > is NOT `api.plantactic.com`. This document is hand-authored rather than
    > generated from the code, so treat it as carefully-checked prose about a
    > running service rather than as machine-verified truth.

    ## Authentication

    Every call carries a bearer token. There are three prefixes and they are
    never interchangeable:

    | Prefix | Kind | Operates on | Bills | Issued |
    |---|---|---|---|---|
    | `ptx_test_…` | API key | An isolated test account, empty until you upload | Never | On request |
    | `ptx_live_…` | API key | Your own datasets | Metered in compute units | After email verification |
    | `ptx_oauth_…` | OAuth access token | Whatever the granting account owns | As that account | By the flow below, valid one hour |

    The prefixes are visually distinguishable and greppable in logs, revocable
    independently, and every dataset, version and export response carries
    `livemode` so nothing ships against the sandbox by accident. (The grid
    response does not — see `GridResponse`.)

    ```http
    Authorization: Bearer ptx_test_4e91c8...
    ```

    A **refresh** token (`ptx_oauth_rt_…`) is a different object and is refused
    by shape if you present it here. It goes to `POST /oauth/token` and nowhere
    else.

    ## OAuth

    This service **is** its own authorization server; there is no separate auth
    host. It implements `authorization_code` with PKCE (S256 only) and
    `refresh_token`, and no other grant — in particular there is **no**
    `client_credentials`. Clients register themselves at `POST /oauth/register`
    (RFC 7591) and discover everything else from
    `GET /.well-known/oauth-authorization-server`.

    There are exactly two scopes, `plantactic.read` and `plantactic.write`, and
    they are coarse on purpose: `/v1` enforces partner scoping rather than
    per-endpoint permissions, so a finer scope would be a promise nothing
    checks. Requesting neither, or requesting something unrecognised, grants
    both.

    ## Idempotency

    `Idempotency-Key` is **required** on the four mutating calls — creating a
    dataset, uploading a version, minting an upload URL, requesting an export. A
    retried upload must not create a second version. Replaying a key within 24
    hours returns the original response. Omitting it is a `400`.

    `POST /v1/datasets/{datasetId}/metrics` is a query, not a mutation, and
    takes no idempotency key. It is a POST only because the query body is too
    large and too structured for a URL.

    ## Errors

    Every error is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem
    document, served as `application/problem+json`.

    Ingest validation is the case worth reading before you write your client:
    a `422` returns **every** bad row at once in an `errors` array, and
    **nothing is written**. There is no partial version. This preserves the
    `ErrorSink` semantics the internal importer has always had — you fix the
    whole file once rather than discovering row 4,001 after fixing row 12.

    ## Reading the numbers

    The grid response was designed for a machine, not a screen:

    - `values` is **dense** and index-aligned to `periods`. Position *i* in
      every row refers to `periods[i]`.
    - **`null` means not-computable. It never means zero.** A metric with no
      denominator, a trailing window with insufficient history, and a burn
      multiple over a period with no net burn are all `null`. Zero is a real
      measured zero.
    - There are no formatted strings and no locale on the wire. Each row
      carries a `formatType`; your client decides presentation.
    - Rows are keyed by a stable `rowId`, columns by a period label. Never by
      position in a section.
    - Optional row fields arrive as **`null`**, not absent. `preset`,
      `secondValues` and `formatType2` are on every row whether or not they
      have a value, because this service serializes rows with no null-omission
      policy. Treat `null` and absent as the same thing and you will be right
      either way.

    ## Operating modes

    A dataset is `persistent` or `ephemeral`, and today **the choice is
    recorded and changes nothing.**

    `persistent` is the default and is what every dataset actually does: data is
    sent once per version, retained in the control plane, and served to later
    queries from there. `ephemeral` is accepted, stored on the dataset and
    echoed back, and then behaves identically — the same ingest path writes the
    same rows to the same table, and nothing deletes them afterwards.
    `expiresAt` is the same: recorded, returned, and enforced by nothing. No
    sweeper reads either field.

    So do not choose `ephemeral` to satisfy a retention constraint. It is a
    declaration of intent that the service has not yet learned to honour, and
    picking it will not stop us storing your data. `persistent` is the honest
    value and the one to send.

    The distinction is kept in the model because it is fixed at creation and
    could not be added retroactively without a migration.

    ## Metering

    Metering is on **compute units**, not on calls. One query over 200 contracts
    and one over 36M facts are both "one request" and differ by orders of
    magnitude in cost. A compute unit is contracts times axis months, times combinations
    evaluated (one combination today).

    **Usage is recorded, not enforced.** There is no rate limiting anywhere in
    this service: no request is refused for being too large, no budget is
    checked before a query runs, and nothing here ever answers `429`. The meter
    writes a usage row *after* the work is done — `Metering.record` is called
    with the grid already computed. If admission-time pricing arrives it will be
    a change to this document, not a clarification of it.

    `POST /v1/datasets/{datasetId}/metrics` carries
    `X-Plantactic-Compute-Units`. It is the only endpoint that does; an export
    is metered identically but the count is charged in the background, where
    there is no response left to put it on.
  contact:
    name: Plantactic Developer Support
    url: https://developer.plantactic.com
    email: developers@plantactic.com

servers:
  # ---------------------------------------------------------------------------
  # THE HOST IS `ptx-api.plantactic.com`. It is NOT `api.plantactic.com`.
  # Read this before editing.
  #
  # ptx-api has shipped and answers on its own hostname. That is the one below,
  # and it is the same host the quickstart, the guides and the MCP connector use
  # (apps/developer/src/pages/*, services/mcp/src/server.ts) — so a reader who
  # copies a curl out of this page reaches the same service the tutorial does.
  #
  # `api.plantactic.com` is the host ptx-api will EVENTUALLY own, and it still
  # serves company-service — the backend behind the Angular app. The repoint is
  # step C of the hostname migration (spec §13) and HAS NOT HAPPENED. Naming it
  # here would point every documented request at the production application
  # backend, which answers a bare Spring 403 to ptx-shaped calls and is a real
  # incident rather than a broken demo. It goes in this field on the day step C
  # completes, and not before.
  #
  # The reference page's try-it client is still disabled — hideTestRequestButton
  # and hideClientButton in apps/developer/src/components/ScalarReference.astro
  # are both still true. Turning them on is a deliberate separate change now
  # that there is something to call; it is not implied by this URL.
  # ---------------------------------------------------------------------------
  - url: https://ptx-api.plantactic.com
    description: >-
      Production. The only host that serves this API. `api.plantactic.com` is a
      different service — company-service, behind the Angular app — and answers
      these paths with a Spring error, not an RFC 9457 problem document. It
      becomes this API's host only when the hostname migration (initiative spec
      §13, step C) repoints it, which has not happened.

# Alternatives, not both. `bearerAuth` is what /v1 actually checks and accepts
# either credential kind; `oauth2` is listed so a client that can drive the flow
# offers it. The scopes are NOT enforced per endpoint -- /v1 checks partner
# scoping and nothing finer -- so they are stated once here rather than repeated
# on operations as a permission model this service does not implement.
security:
  - bearerAuth: []
  - oauth2: [plantactic.read, plantactic.write]

tags:
  - name: Datasets
    description: |
      A dataset is one of *your* customers, registered under *your* identifier.
      You send us `externalRef` — an account id, a tenant slug, whatever you
      already key on — and it is unique within your partner account, so you
      never have to invent a name for a customer you already have one for. This
      is what makes the B2B2B case work: a partner platform holding thousands of
      datasets keeps its own naming.

      You do still hold an id of ours. `datasetId` is a UUID we mint, it is
      returned on creation, and it is what every path below is addressed by —
      there is no lookup by `externalRef`. So store the pair. An earlier draft of
      this document claimed you would never learn an internal id of ours; that
      was the design, and it is not what shipped.
  - name: Versions
    description: |
      Data is uploaded as an immutable version. Exactly one version per dataset
      is `active` at a time, and the swap is atomic — a query never observes a
      half-written upload. Large uploads return `202` and a version resource you
      poll to a terminal state.
  - name: Metrics
    description: |
      The grid query and the capability catalog. The grid is the same
      period-aligned shape the Plantactic app renders; the catalog tells you,
      for the data you actually uploaded, which metrics can be computed and
      which can be cohorted — two different questions.
  - name: Uploads
    description: |
      Somewhere to put a workbook that is too large to be a request body. Mint a
      short-lived URL, `PUT` the file straight to storage, then name the upload
      when you create the version. The file never passes through this API.
  - name: Exports
    description: |
      Excel, JSON or PDF. Exports are asynchronous: create a job, poll it, then
      fetch the artifact from a short-TTL signed URL minted for your
      authenticated request. `GET /v1/exports/{id}/download` is the durable link
      to the artifact — it authenticates, then redirects to a URL minted on the
      spot.
  - name: OAuth
    description: |
      This service is its own authorization server. The endpoints below are
      OAuth 2.1 with PKCE, plus RFC 7591 registration, RFC 7009 revocation and
      RFC 7662 introspection, and they are the only way to obtain a
      `ptx_oauth_` token.

      They are **not** `/v1` and are **not** authenticated by the bearer scheme
      the rest of this document uses — a client calls them before it holds
      anything. Each one says below what does guard it, if anything.

      `/token`, `/revoke` and `/introspect` speak
      `application/x-www-form-urlencoded` in and RFC 6749 §5.2 error objects
      out (`{"error":"invalid_grant"}`), not this API's problem documents. That
      is the RFC's contract and generic OAuth clients parse exactly that shape.
      `/authorize`, `/register` and `/consent` are the exceptions, each for its
      own reason, noted on the endpoint.
  - name: Discovery
    description: |
      The two well-known documents a client reads before it has a credential.
      Public, unauthenticated, cached for an hour, and derived from one
      configured issuer so that they, the `WWW-Authenticate` header on a `401`,
      and the endpoints they name cannot disagree.

paths:
  /v1/datasets:
    get:
      tags: [Datasets]
      operationId: listDatasets
      summary: List your datasets
      description: |
        Every dataset your credential can see, newest first. There is no
        pagination and no filter: the list is scoped to your partner account and
        to your credential's `livemode`, which is the whole of the selection.

        **A test key and a live key see disjoint lists**, not the same list
        twice. That is the same rule the individual fetch follows, and it is why
        a dataset created with `ptx_test_` is invisible — not forbidden,
        invisible — to a `ptx_live_` key.
      responses:
        '200':
          description: Your datasets, newest first. An empty account gets `[]`.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Dataset' }
              example:
                - datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                  externalRef: acct_88213
                  name: Northwind Software
                  inputModel: contracts
                  mode: persistent
                  activeVersionId: 3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b
                  expiresAt: null
                  livemode: false
                  createdAt: '2026-08-25T14:02:11Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Datasets]
      operationId: createDataset
      summary: Create a dataset
      description: |
        Registers one of your customers as a dataset. `externalRef` is your own
        identifier for that customer — an account id, a tenant slug, whatever
        you already key on. It is unique within your partner account, so
        re-creating with the same `externalRef` is a conflict rather than a
        silent duplicate.

        The `inputModel` is fixed at creation and defaults to `contracts` when
        omitted. Changing it means creating a new dataset. Read its schema
        before you rely on it: today it is recorded and echoed back, and the
        ingest and query paths behave identically whichever value it holds.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DatasetCreateRequest'
            examples:
              contracts:
                summary: Contract-based SaaS customer
                value:
                  externalRef: acct_88213
                  name: Northwind Software
                  inputModel: contracts
                  mode: persistent
              ephemeral:
                summary: A one-shot valuation — note that neither field is enforced
                description: >-
                  `mode` and `expiresAt` are recorded and echoed back, and today
                  nothing acts on either: this dataset is retained exactly like
                  the one above. See **Operating modes**.
                value:
                  externalRef: valuation-2026-08-25
                  name: Prospect, diligence run
                  inputModel: contracts
                  mode: ephemeral
                  expiresAt: '2026-08-25T18:00:00Z'
      responses:
        '201':
          description: |
            Dataset created. No `X-Plantactic-Compute-Units` — creating a
            dataset computes nothing, and only the metrics endpoint sets that
            header.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Dataset'
              example:
                datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                externalRef: acct_88213
                name: Northwind Software
                inputModel: contracts
                mode: persistent
                activeVersionId: null
                expiresAt: null
                livemode: false
                createdAt: '2026-08-25T14:02:11Z'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /v1/datasets/{datasetId}:
    get:
      tags: [Datasets]
      operationId: getDataset
      summary: Fetch a dataset
      description: |
        One dataset, by the id `POST /v1/datasets` returned. `activeVersionId`
        is resolved on read rather than stored, so it always names the version
        that is serving queries right now.

        There is no fetch-by-`externalRef`. Your own identifier is what you send
        us; the `datasetId` is what you address.
      parameters:
        - $ref: '#/components/parameters/DatasetId'
      responses:
        '200':
          description: The dataset.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Dataset'
              example:
                datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                externalRef: acct_88213
                name: Northwind Software
                inputModel: contracts
                mode: persistent
                activeVersionId: 3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b
                expiresAt: null
                livemode: false
                createdAt: '2026-08-25T14:02:11Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/datasets/{datasetId}/versions:
    post:
      tags: [Versions]
      operationId: createDatasetVersion
      summary: Upload a version
      description: |
        Uploads the data the engine will compute over. **There is one payload
        shape: `contracts`** — an array of contract rows, or an `uploadId`
        naming a workbook of them. The dataset's `inputModel` does not change
        what this endpoint accepts.

        **Two outcomes, and your client must handle both.**

        A small upload validates and activates inline: `201`, with the version
        already `active`.

        A large upload returns `202` with the version in `building`. Poll
        `GET /v1/datasets/{datasetId}/versions/{versionId}` until `status`
        reaches a terminal value — `active` or `invalid`. Those are the only two
        terminal states on this path (`superseded` is applied later, when a
        subsequent version takes over), and they exist in the contract precisely
        so your polling loop has a branch that ends. Honour `Retry-After`.

        **Validation is all-or-nothing.** A `422` reports every bad row in one
        array and writes nothing — no partial version is ever created, and the
        previously active version keeps serving throughout.

        **A workbook instead of a body.** Send `uploadId` in place of
        `contracts` to build the version from an `.xlsx` you have already `PUT`
        to a URL from `POST /v1/uploads`. That path is always `202`: the file has
        to be fetched and parsed, and doing it on the request thread is exactly
        what the upload exists to avoid.

        It also moves where errors appear, and this is the one place the two
        paths genuinely differ. Inline rows are validated before the response, so
        a bad file is a `422` you read immediately. A workbook is validated
        after, so its errors arrive as `problem` on a version that reached
        `invalid` — the same document, the same complete `errors` array, read by
        polling instead of by return. Row numbers refer to rows in your
        spreadsheet, counting the header. What does not change: nothing is
        written, and your previously active version keeps serving throughout.
      parameters:
        - $ref: '#/components/parameters/DatasetId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VersionCreateRequest'
            examples:
              contracts:
                summary: Contracts, inline
                value:
                  contracts:
                    - contractId: C-1041
                      customer: Ravensworth Health
                      product: Platform
                      productCategory: Core
                      revenueModel: SUBSCRIPTION
                      signedDate: '2028-02-14'
                      startDate: '2028-04-01'
                      endDate: '2029-03-31'
                      term: 12
                      tcv: 38001.00
                      licenses: 45
                      pricePerLicense: 844.47
              workbook:
                summary: A workbook already PUT to an upload URL
                value:
                  uploadId: 3fd1a0e6-9c47-4b0a-9c2a-0f2d7b6e5a41
      responses:
        '201':
          description: Version validated and activated inline. Small uploads only.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DatasetVersion'
              example:
                versionId: 3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b
                datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                status: active
                rowCount: 380
                createdAt: '2026-08-25T14:06:40Z'
                activatedAt: '2026-08-25T14:06:41Z'
                livemode: false
                problem: null
        '202':
          description: |
            Accepted for asynchronous ingest, returned when the batch is **2,000
            rows or more**. Below that the upload is built inline and answers
            `201` with the version already `active` — one call, nothing to poll.

            The version resource is returned in `building`; poll it to a terminal
            state. `rowCount` is `null` until the rows are there.

            **Your previously active version keeps serving the whole time.** The
            swap is atomic and happens when the new version is ready, so queries
            answer from the old data until the moment they answer from the new —
            never from neither. If the build fails, the old version simply stays
            active and you upload again.

            A replayed `Idempotency-Key` returns this same `202`, `Location` and
            `Retry-After`, pointing at the same job. Note that a `202` consumes
            the key even if the build later fails: the request succeeded, the job
            did not. Retry a failed build with a **new** key.
          headers:
            Location:
              description: The version resource to poll.
              schema: { type: string, format: uri }
            Retry-After:
              description: Seconds to wait before the first poll.
              schema: { type: integer }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DatasetVersion'
              example:
                versionId: 3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b
                datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                status: building
                rowCount: null
                createdAt: '2026-08-25T14:06:40Z'
                activatedAt: null
                livemode: false
                problem: null
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: |
            No such dataset within your partner account — or, on the workbook
            path, no such upload: an id you never minted, one nothing was ever
            `PUT` to, or one belonging to another account. All four are the same
            answer, and deliberately so.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }
        '409':
          description: |
            An upload with this `Idempotency-Key` is still being processed.
            Retry in a moment with the SAME key — a new key would upload twice.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '503':
          description: |
            The ingest queue is full. Nothing was written — retry shortly.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '413':
          description: |
            Payload exceeds the ingest limit. The limit is **10 MB** per upload
            today, roughly 30–50k contract rows; split a larger batch into
            several versions.

            A workbook named by `uploadId` has its own, higher limit — **20 MB**,
            which is the point of uploading rather than sending it. That one is
            refused here, before a version exists: the object's size is known
            without reading it.

            There is also a ceiling of **50,000 rows**, because xlsx is
            compressed and bytes alone do not bound how much data a file holds.
            That one cannot be answered here — counting rows means reading the
            file — so it arrives like any other content error: the version
            reaches `invalid` and its `problem` carries a `too_many_rows` entry.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '422':
          description: |
            Validation failed. **Every** bad row is reported, and nothing was
            written — there is no partial version, and the previously active
            version is untouched.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: https://developer.plantactic.com/errors/ingest-validation-failed
                title: Unprocessable Entity
                status: 422
                detail: 3 row(s) were rejected. Nothing was written.
                errors:
                  - row: 12
                    field: endDate
                    code: end_before_start
                    message: endDate 2027-03-31 is before startDate 2028-04-01.
                  - row: 148
                    field: tcv
                    code: not_a_number
                    message: '''38,001.00'' is not a number.'
                  - row: 4001
                    field: customer
                    code: required
                    message: customer is required.

  /v1/datasets/{datasetId}/versions/{versionId}:
    get:
      tags: [Versions]
      operationId: getDatasetVersion
      summary: Poll a version
      description: |
        The terminal states are `active` and `invalid`. Stop polling when you
        reach either. `building` is the only non-terminal state on this path;
        `superseded` is applied later, out of band, when a newer version takes
        over — a version you were polling can never move from `building`
        straight to `superseded`.

        An `invalid` version carries the same complete `errors` array a
        synchronous `422` would have returned — nested inside `problem`, which
        is the whole problem document rather than a summary of it.

        `activatedAt` is set **only on activation**. An `invalid` version never
        reaches it, so the field stays `null` forever there: it is not "when
        this version finished", it is "when this version started serving".
      parameters:
        - $ref: '#/components/parameters/DatasetId'
        - $ref: '#/components/parameters/VersionId'
      responses:
        '200':
          description: The version's current state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DatasetVersion'
              examples:
                building:
                  summary: Still ingesting — keep polling
                  value:
                    versionId: 3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b
                    datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                    status: building
                    rowCount: null
                    createdAt: '2026-08-25T14:06:40Z'
                    activatedAt: null
                    livemode: false
                    problem: null
                active:
                  summary: Terminal — the version is now serving queries
                  value:
                    versionId: 3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b
                    datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                    status: active
                    rowCount: 380
                    createdAt: '2026-08-25T14:06:40Z'
                    activatedAt: '2026-08-25T14:09:02Z'
                    livemode: false
                    problem: null
                invalid:
                  summary: Terminal — rejected, nothing written, `activatedAt` never set
                  value:
                    versionId: 3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b
                    datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                    status: invalid
                    rowCount: null
                    createdAt: '2026-08-25T14:06:40Z'
                    activatedAt: null
                    livemode: false
                    problem:
                      type: https://developer.plantactic.com/errors/ingest-validation-failed
                      title: Unprocessable Entity
                      status: 422
                      detail: 1 row(s) were rejected. Nothing was written.
                      errors:
                        - row: 12
                          field: endDate
                          code: end_before_start
                          message: endDate 2027-03-31 is before startDate 2028-04-01.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/datasets/{datasetId}/metrics:
    post:
      tags: [Metrics]
      operationId: queryMetrics
      summary: Query the metric grid
      description: |
        Returns a period-aligned grid over the dataset's active version.

        **The body has exactly one field, `horizon`, and it is optional.** The
        grid always covers the dataset's whole axis, monthly, across every
        section the engine can compute — there is no windowing, no period type,
        no section selection and no filtering on this endpoint. Anything else
        you send is read by nothing and changes nothing.

        `horizon` is the calculation's "now": the month open-ended contracts are
        projected through, and the boundary the engine treats as present. Send
        it. Omitted, it defaults to **the current month by the server's clock**,
        which means the same request answers differently after a month rolls
        over — the one thing a reproducible query cannot afford.

        The body may be omitted entirely. This is a query and not a mutation, so
        it takes no `Idempotency-Key`; it is a POST because the request shape is
        expected to grow past what belongs in a URL.

        Reading the response:

        - `periods` is the axis. `values[i]` in every row is the value at
          `periods[i]` — dense, never sparse, never keyed positionally.
        - `null` is not-computable. **Never zero.** The burn multiple in the
          example below is `null` for the first two months because a trailing
          window has no history there, and the row's later `0.83` is a measured
          value. Collapsing the two loses the distinction the engine works hard
          to preserve.
        - `formatType` is presentation metadata, not a formatted string. The
          numbers on the wire are raw doubles at full precision.
        - `preset` is an optional row hint (`INDENT`, `DANGER`, `BOLD`,
          `TOTAL`). It is present on every row and **`null`** when the row has
          none — as are `secondValues` and `formatType2`.

        Metric *definitions* — formulas and benchmark bands for all 706 — live
        in the metric knowledge base at
        [plantactic.com/metrics](https://plantactic.com/metrics) and are not
        restated here.
      parameters:
        - $ref: '#/components/parameters/DatasetId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GridRequest'
            examples:
              pinned:
                summary: A fixed horizon — the reproducible form, and the one to use
                value:
                  horizon: '2026-12'
              default:
                summary: No body at all — horizon becomes the current month
                value: {}
      responses:
        '200':
          description: The computed grid.
          headers:
            X-Plantactic-Compute-Units:
              $ref: '#/components/headers/ComputeUnits'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GridResponse'
              examples:
                waterfall:
                  summary: Live ARR waterfall — verbatim engine output, abridged
                  description: |
                    Values are lifted unaltered from the committed golden master
                    `services/company/src/test/resources/golden/grid-monthly.json`
                    (Lexop, a non-active test company with generated data). The
                    full precision is what the wire actually carries.

                    **Abridged.** A real response carries all 50 periods and
                    every computable section; this shows six periods of one
                    section so the shape is readable. There is no request that
                    would produce this slice — the endpoint has no windowing.
                    Every row on the wire also carries `preset`, `secondValues`
                    and `formatType2`, `null` where unset; they are elided here
                    and shown in full in the next example.
                  value:
                    horizon: '2026-12'
                    axisStart: '2024-11'
                    axisEnd: '2028-12'
                    months: 50
                    periods: ['2028-04', '2028-05', '2028-06', '2028-07', '2028-08', '2028-09']
                    sections:
                      - sectionId: arr_waterfall
                        title: Live ARR
                        rows:
                          - rowId: arr_waterfall.starting_live_arr
                            metric: STARTING_LIVE_ARR
                            displayName: Starting ARR
                            formatType: ACCURATE_CURRENCY_VALUE
                            values:
                              - 4421586.751680993
                              - 4269352.7516809935
                              - 4175862.00882385
                              - 4121632.2088238494
                              - 3999393.173529731
                              - 3759930.2371660946
                          - rowId: arr_waterfall.new_live_arr
                            metric: NEW_LIVE_ARR
                            displayName: New Live ARR
                            formatType: ACCURATE_CURRENCY_VALUE
                            preset: INDENT
                            values: [38001.0, 85000.79999999999, 151999.2, 0.0, 0.0, 0.0]
                          - rowId: arr_waterfall.upsell_live_arr
                            metric: UPSELL_LIVE_ARR
                            displayName: Expansion Live ARR
                            formatType: ACCURATE_CURRENCY_VALUE
                            preset: INDENT
                            values:
                              - 8765.5
                              - 21761.85714285714
                              - 21226.999999999993
                              - 16764.000000000007
                              - 5481.000000000003
                              - 10833.999999999996
                          - rowId: arr_waterfall.downsell_live_arr
                            metric: DOWNSELL_LIVE_ARR
                            displayName: Downsell Live ARR
                            formatType: ACCURATE_CURRENCY_VALUE
                            preset: DANGER
                            values: [0.499999999998181, 0.0, 0.0, 0.7999999999992724, 0.0, 0.0]
                          - rowId: arr_waterfall.churned_live_arr
                            metric: CHURNED_LIVE_ARR
                            displayName: Churned Live ARR
                            formatType: ACCURATE_CURRENCY_VALUE
                            preset: DANGER
                            values:
                              - 199000.0
                              - 200253.4
                              - 227456.0
                              - 139002.23529411765
                              - 244943.93636363637
                              - 284407.3
                          - rowId: arr_waterfall.net_new_live_arr
                            metric: NET_NEW_LIVE_ARR
                            displayName: Net New Live ARR
                            formatType: ACCURATE_CURRENCY_VALUE
                            preset: BOLD
                            values:
                              - -152234.0
                              - -93490.74285714285
                              - -54229.80000000002
                              - -122239.03529411762
                              - -239462.93636363637
                              - -273573.30000000005
                          - rowId: arr_waterfall.closing_live_arr
                            metric: CLOSING_LIVE_ARR
                            displayName: Closing ARR
                            formatType: ACCURATE_CURRENCY_VALUE
                            preset: TOTAL
                            values:
                              - 4269352.7516809935
                              - 4175862.00882385
                              - 4121632.2088238494
                              - 3999393.173529731
                              - 3759930.2371660946
                              - 3486356.9371660952
                          - rowId: arr_waterfall.live_arr_growth_rate
                            metric: LIVE_ARR_GROWTH_RATE
                            displayName: Live ARR Growth Rate
                            formatType: PERCENTAGE_VALUE
                            values:
                              - -0.034429721398573254
                              - -0.021898106878222356
                              - -0.012986492342278
                              - -0.0296579192661639
                              - -0.05987481749694901
                              - -0.07276020637185943
                nulls:
                  summary: What `null` means — the burn multiple at the axis start
                  description: |
                    The same golden master, first six periods, and one row shown
                    with **every** field it actually carries. `BURN_MULTIPLE` is
                    `null` at `2024-11` and `2024-12` because the trailing window
                    has no history to compute over. It is **not** zero, and a
                    client that renders it as `0` reports a company burning
                    nothing.

                    Note `preset`, `secondValues` and `formatType2`: present and
                    `null`, not absent. This is what a row looks like.
                  value:
                    horizon: '2026-12'
                    axisStart: '2024-11'
                    axisEnd: '2028-12'
                    months: 50
                    periods: ['2024-11', '2024-12', '2025-01', '2025-02', '2025-03', '2025-04']
                    sections:
                      - sectionId: financial
                        title: Financials
                        rows:
                          - rowId: financial.burn_multiple
                            metric: BURN_MULTIPLE
                            displayName: Burn Multiple
                            formatType: ACCURATE_DOUBLE_VALUE
                            preset: null
                            values:
                              - null
                              - null
                              - 2.3488372093023258
                              - 0.8388429752066116
                              - 0.5650969529085873
                              - 0.6677532339392762
                            secondValues: null
                            formatType2: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: |
            **One answer for two different situations, and the detail says so.**
            There is no separate `409` here. You get this `404` when the dataset
            does not exist, when it belongs to another partner, when the id is
            not a UUID at all — and *also* when the dataset is perfectly real but
            has no `active` version, because nothing has finished ingesting yet
            or the last upload was rejected.

            The first three are indistinguishable on purpose: a `404` that
            confirmed existence would be an enumeration oracle. The fourth is
            merged in because the query resolves the dataset and its active
            version in one step, and it is the case you can actually act on —
            poll your latest version.

            > `No dataset {id}, or it has no active version.`
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }
        '422':
          description: |
            `horizon` was sent and is not an ISO year-month. That is the only
            `422` this endpoint produces — no other field is read, so no other
            field can be wrong.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }

  /v1/datasets/{datasetId}/metrics/catalog:
    get:
      tags: [Metrics]
      operationId: getMetricCatalog
      summary: What this dataset can answer
      description: |
        **Returns both axes, because computable and cohortable are different
        questions and a client that conflates them will ask for something the
        engine declines to produce.**

        *Computable* is **measured, not predicted**: the service computes the
        grid for your active version and reports the sections it produced and
        how many rows each holds. So it reflects the data you actually uploaded
        — a dataset whose contracts carry no signed dates yields fewer sections
        than one whose contracts do.

        It does **not** vary by `inputModel`. `inputModel` is echoed back here
        and is not consulted; the same computation runs whatever it says.

        *Cohortable* is independent of that, and much narrower: exactly **seven**
        measures, permanently. They are the per-customer-per-month quantities
        the engine carries on its fact rows, plus their annualised aliases —
        `MRR`, `CMRR`, `ARR`, `CARR`, `USERS`, `CONTRACTED_USERS`, `LOGOS`. The
        exclusions are settled, not pending work:

        - **Bookings** (TCV, ACV) post entirely in the signed month, so every
          later offset is zero by construction. A cohort curve of a one-shot
          event is a straight line to zero.
        - **Financial metrics** have no per-customer decomposition. A P&L cannot
          be attributed to a vintage.
        - **Ratios and composites** (burn multiple, magic number, Rule of 40,
          CAC payback) are defined over a company-period, not a customer-month.

        So a metric can be fully computable and still refuse to be cohorted.
        `cohortable.unavailable` tells you which, and why.

        Two things to know before you build against it. It sits **inside**
        `cohortable`, not beside it — it is a statement about that one axis. And
        its `reason` is a **sentence**, not a slug: free text meant for a human,
        which will change wording without notice. Branch on `metric`, render
        `reason`.

        The list is the same **six entries every time**, for every dataset. It
        is the settled set of structural exclusions, not a finding about your
        data.
      parameters:
        - $ref: '#/components/parameters/DatasetId'
      responses:
        '200':
          description: The capability catalog for this dataset's active version.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MetricCatalog'
              example:
                datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                versionId: 3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b
                inputModel: contracts
                computable:
                  sections:
                    - sectionId: arr_waterfall
                      title: Live ARR
                      rowCount: 15
                    - sectionId: retention
                      title: Retention
                      rowCount: 64
                    - sectionId: bookings_tcv
                      title: TCV
                      rowCount: 12
                    - sectionId: financial
                      title: Financials
                      rowCount: 41
                  metricCount: 132
                cohortable:
                  measures: [MRR, CMRR, ARR, CARR, USERS, CONTRACTED_USERS, LOGOS]
                  unavailable:
                    - metric: BOOKINGS_TCV
                      reason: >-
                        bookings post entirely in the signed month, so every
                        later cohort offset is zero by construction
                    - metric: BOOKINGS_ACV
                      reason: >-
                        bookings post entirely in the signed month, so every
                        later cohort offset is zero by construction
                    - metric: BURN_MULTIPLE
                      reason: >-
                        financial metrics have no per-customer decomposition; a
                        P&L cannot be attributed to a vintage
                    - metric: MAGIC_NUMBER
                      reason: a ratio defined over a company-period, not a customer-month
                    - metric: RULE_OF_40
                      reason: a ratio defined over a company-period, not a customer-month
                    - metric: CAC_PAYBACK
                      reason: a ratio defined over a company-period, not a customer-month
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: |
            The same merged answer the metrics query gives, for the same reason:
            no such dataset, not yours, not a UUID, **or** no `active` version to
            describe. There is no distinct `409`.

            > `No dataset {id}, or it has no active version.`
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }

  /v1/uploads:
    post:
      tags: [Uploads]
      operationId: createUpload
      summary: Mint an upload URL
      description: |
        Returns a short-lived URL to `PUT` one `.xlsx` workbook to, and the
        `uploadId` that names it afterwards.

        **The file never passes through this API.** You `PUT` it straight to
        storage — no `Authorization` header, the signature in the query string is
        the whole credential — and then send `{ "uploadId": "…" }` to
        `POST /v1/datasets/{datasetId}/versions` instead of a `contracts` array.
        A large workbook sent as JSON is several times its own size on the wire,
        has to be buffered whole to be fingerprinted for idempotency, and holds a
        request open for the length of the transfer. This is one short call
        through us instead of one long one.

        The URL is valid for **ten minutes** and permits exactly one object. It
        is a write credential, which is why it is that short: mint it when you
        are ready to send, not ahead of time.

        Nothing is recorded. There is no `GET /v1/uploads/{id}` — an upload is
        not a resource with a state, it is a place to put bytes — so this
        response carries no `Location`. The version you create from it is the
        resource, and that one you can poll.

        Uploaded objects are deleted after **seven days**, the same lifecycle
        every artifact here follows. Reference the upload before then.

        A replayed `Idempotency-Key` returns the ORIGINAL `uploadId` and URL
        rather than a second credential, which is what makes a retried mint safe.
        Note the consequence: if that URL has since expired, use a **new** key to
        mint a fresh one.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '201':
          description: The upload URL and the id that names it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Upload'
              example:
                uploadId: 3fd1a0e6-9c47-4b0a-9c2a-0f2d7b6e5a41
                url: https://plantactic-ptx-exports.s3.us-east-2.amazonaws.com/uploads/…?X-Amz-Signature=…
                expiresAt: '2026-08-25T14:16:03Z'
                maxBytes: 20971520
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/exports:
    post:
      tags: [Exports]
      operationId: createExport
      summary: Request an export
      description: |
        Runs a grid query and renders it to a file. Three formats: `xlsx`,
        `json`, `pdf`.

        Exports are always asynchronous — `202`, then poll
        `GET /v1/exports/{exportId}`. On `succeeded` the job carries a
        **short-TTL signed URL**, valid for ten minutes.

        **Which formats a deployment serves is decided at wiring time, not
        hardcoded.** `json` and `xlsx` are always available; `pdf` needs a
        configured route to the renderer (`PTX_PDF_EXPORT_ENDPOINT`), which the
        deployed configuration wires. Where that route is absent the request answers `422`
        naming the format — it is not charged, and its `Idempotency-Key` is
        released, so retrying once the renderer is reachable is not refused as
        a reused key.

        An export **runs a grid query and is metered like one** — the same
        compute units `POST /v1/datasets/{id}/metrics` records. Asking for a
        file is not a cheaper way to ask the question. The count is charged in
        the background, so no `X-Plantactic-Compute-Units` comes back here.

        **`query.horizon` is required**, unlike on the metrics endpoint, where
        it defaults. Omit it and you get a `422` before anything is queued.
        Nothing else inside `query` is read.

        **This call does not check that your dataset exists.** See the `202`.

        That URL is minted for your authenticated request and was never sent
        anywhere. It is deliberately not the same object as a public link to a
        company's financials, which this platform does not issue — the
        distinction is the authenticated call that mints it.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExportCreateRequest'
            example:
              datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
              format: xlsx
              query:
                horizon: '2026-12'
      responses:
        '202':
          description: |
            Export accepted. Poll the job.

            **Accepted is not "found".** The only thing checked about
            `datasetId` here is that it parses as a UUID. Whether the dataset
            exists, and whether it is yours, is not asked until the job runs —
            so a well-formed id for a dataset that never existed, or that
            belongs to another partner, gets this same `202`. The failure
            surfaces on the poll, as a `failed` job whose stored `problem` is:

            > `no-active-version` — *That dataset has no active version to export.*

            That one sentence therefore covers three situations: no such
            dataset, not your dataset, and a real dataset with nothing active.
            It is deliberately not an existence oracle, but it is also not a
            precise diagnosis — if you are debugging one, check
            `GET /v1/datasets/{datasetId}` first.
          headers:
            Location:
              description: The export job to poll.
              schema: { type: string, format: uri }
            Retry-After:
              description: Seconds to wait before the first poll.
              schema: { type: integer }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExportJob'
              example:
                exportId: 7c4d2e18-5b60-4a39-9d81-0f3a6c25be84
                datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                format: xlsx
                status: queued
                createdAt: '2026-08-25T14:20:03Z'
                completedAt: null
                byteSize: null
                url: null
                expiresAt: null
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: |
            `datasetId` is not a UUID. That is the ONLY thing this endpoint
            answers `404` for — a well-formed id it cannot resolve is accepted
            with a `202` and fails later. See that response.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }
        '422':
          description: |
            No body, an unknown `format`, a contracted format this build cannot
            serve yet (`pdf`, where the renderer is not wired up — the message
            names what *is* available), or a missing or unparseable
            `query.horizon`.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }
        '503':
          description: |
            The export queue is full. Nothing will be rendered — request it again
            shortly. The twin of the ingest queue's `503`, and answered the same
            way: the job row is ended here rather than left for the sweeper.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }

  /v1/exports/{exportId}:
    get:
      tags: [Exports]
      operationId: getExport
      summary: Poll an export
      description: |
        Terminal states are `succeeded` and `failed`. Stop polling at either.

        On `succeeded`, `url` is a signed URL and `expiresAt` is when it stops
        working — usually a few minutes out. Fetch it promptly; if it expires,
        poll this endpoint again and a fresh URL is minted. Do not store the URL.

        On `failed`, `problem` carries the same RFC 9457 document the
        synchronous path would have returned.
      parameters:
        - name: exportId
          in: path
          required: true
          schema: { type: string, examples: [7c4d2e18-5b60-4a39-9d81-0f3a6c25be84] }
      responses:
        '200':
          description: The export job's current state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExportJob'
              examples:
                running:
                  summary: Still rendering — keep polling
                  value:
                    exportId: 7c4d2e18-5b60-4a39-9d81-0f3a6c25be84
                    datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                    format: xlsx
                    status: running
                    createdAt: '2026-08-25T14:20:03Z'
                    completedAt: null
                    byteSize: null
                    url: null
                    expiresAt: null
                succeeded:
                  summary: Terminal — fetch the artifact before it expires
                  value:
                    exportId: 7c4d2e18-5b60-4a39-9d81-0f3a6c25be84
                    datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                    format: xlsx
                    status: succeeded
                    createdAt: '2026-08-25T14:20:03Z'
                    completedAt: '2026-08-25T14:20:29Z'
                    url: https://plantactic-ptx-exports.s3.us-east-2.amazonaws.com/exports/7c4d2e18-5b60-4a39-9d81-0f3a6c25be84.xlsx?sig=…
                    expiresAt: '2026-08-25T14:30:29Z'
                    byteSize: 184320
                failed:
                  summary: Terminal — the problem document explains why
                  description: |
                    The commonest failure, and the one whose wording is worth
                    reading twice: it is also what an export of a dataset that
                    does not exist, or is not yours, comes back as. The create
                    call never looked.
                  value:
                    exportId: 7c4d2e18-5b60-4a39-9d81-0f3a6c25be84
                    datasetId: 9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77
                    format: xlsx
                    status: failed
                    createdAt: '2026-08-25T14:20:03Z'
                    completedAt: '2026-08-25T14:21:11Z'
                    byteSize: null
                    url: null
                    expiresAt: null
                    problem:
                      type: https://developer.plantactic.com/errors/no-active-version
                      title: Conflict
                      status: 409
                      detail: That dataset has no active version to export.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/exports/{exportId}/download:
    get:
      tags: [Exports]
      operationId: downloadExport
      summary: Download an export
      description: |
        A **durable** link to the artifact. This URL does not expire, is safe to
        store, and grants nothing on its own: it authenticates your API key and
        then redirects to a signed URL minted at that moment, valid for ten
        minutes.

        The split is the point. A signed URL is a credential in a query string,
        so it has to stay ephemeral; a link you keep in a runbook, a scheduled
        job or an email has to not. Polling the job gives you the first; this
        gives you the second.

        **Read the `url` in the body — do not rely on following the redirect.**
        Most HTTP clients re-send the `Authorization` header when they follow a
        302, and object storage rejects a request carrying both a bearer token
        and a signature with a `400` ("only one auth mechanism"). Turn redirects
        off, or take the URL from the body and fetch it yourself. The `302` and
        `Location` are there for clients that drop credentials across hops
        correctly; the body always works.

        Artifacts are kept for **seven days** after they are rendered. Past that
        the answer is `410`, not a redirect to a URL that would 404 — the signed
        URL is arithmetic and would be minted quite happily for an object that no
        longer exists.
      parameters:
        - name: exportId
          in: path
          required: true
          schema: { type: string, examples: [7c4d2e18-5b60-4a39-9d81-0f3a6c25be84] }
      responses:
        '302':
          description: |
            Redirecting to a freshly minted signed URL. The body carries the same
            URL and its expiry, and is the safer of the two to use.
          headers:
            Location:
              description: The signed URL. Valid for ten minutes.
              schema: { type: string, format: uri }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExportDownload'
              example:
                exportId: 7c4d2e18-5b60-4a39-9d81-0f3a6c25be84
                url: https://plantactic-ptx-exports.s3.us-east-2.amazonaws.com/exports/7c4d2e18-5b60-4a39-9d81-0f3a6c25be84.xlsx?sig=…
                expiresAt: '2026-08-25T14:30:29Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: |
            No such export **within your partner account**. Another partner's
            export is indistinguishable from one that never existed, so a link
            that reaches the wrong account confirms nothing.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }
        '409':
          description: |
            The export has no artifact to serve. Either it is still `queued` or
            `running` — poll `GET /v1/exports/{exportId}` — or it `failed`, in
            which case `problem` carries the document the job stored.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }
              examples:
                notReady:
                  summary: Still rendering
                  value:
                    type: https://developer.plantactic.com/errors/export-not-ready
                    title: Conflict
                    status: 409
                    detail: That export is running. Poll /v1/exports/7c4d2e18-5b60-4a39-9d81-0f3a6c25be84 until it is succeeded.
                failed:
                  summary: Terminal, with nothing to download
                  value:
                    type: https://developer.plantactic.com/errors/export-failed
                    title: Conflict
                    status: 409
                    detail: That export failed and has no artifact. Request another.
                    problem:
                      type: https://developer.plantactic.com/errors/no-active-version
                      title: Conflict
                      status: 409
                      detail: That dataset has no active version to export.
        '410':
          description: |
            The artifact has been deleted. Exports are kept for seven days and
            are fully reproducible from the dataset that produced them — request
            another export of the same query.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }
              example:
                type: https://developer.plantactic.com/errors/export-expired
                title: Gone
                status: 410
                detail: >-
                  That export was rendered on 2026-08-18T14:20:29Z and artifacts
                  are kept for 7 days. Request another export of the same query.

  # ---------------------------------------------------------------------------
  # THE OAUTH ENDPOINTS.
  #
  # Every one of them carries `security: []`. The document-level `security` is
  # `bearerAuth`, and inheriting it here would say that a client must already
  # hold a token to obtain one — false, and the kind of false a generated client
  # acts on. They live outside `/v1`, which is the only prefix ApiKeyAuthFilter
  # guards, so `security: []` is a description of the code rather than a
  # relaxation of it. Where something else does guard an endpoint — the SPA's
  # session JWT on /consent, a shared secret on /introspect — it is documented
  # in that endpoint's own description, because neither is this document's
  # bearer scheme.
  # ---------------------------------------------------------------------------
  /oauth/register:
    post:
      tags: [OAuth]
      operationId: registerOAuthClient
      summary: Register a client (RFC 7591)
      security: []
      description: |
        Dynamic client registration. Unauthenticated, as RFC 7591 intends and as
        Claude requires — a connector registers itself the first time someone
        adds it, with nobody issuing credentials by hand.

        **No `client_secret` is returned, ever.** The clients this server serves
        are public and hold nothing to authenticate with; PKCE is what proves
        possession of a code. `token_endpoint_auth_method: none` in the response
        says so in the language a client parses.

        `redirect_uris` is required, is capped at **ten**, and every entry must
        be an `https` URL on `claude.ai` or `claude.com` — this authorization
        server registers Claude connectors and nothing else. One bad URI refuses
        the whole request rather than registering the rest, because a client
        that silently did not get what it asked for is worse than one that was
        told no.

        The `client_id` is random and prefixed `ptxc_`. It is not derived from
        your `client_name`, so two registrations can never collide.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClientRegistrationRequest'
            example:
              client_name: Claude
              redirect_uris: ['https://claude.ai/api/mcp/auth_callback']
      responses:
        '201':
          description: Registered. The metadata as recorded, echoed back.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClientRegistrationResponse'
              example:
                client_id: ptxc_4f9c1e7a2b6d4e8f9a0c3b5d7e1f2a48
                client_id_issued_at: 1787059331
                client_name: Claude
                redirect_uris: ['https://claude.ai/api/mcp/auth_callback']
                grant_types: [authorization_code, refresh_token]
                response_types: [code]
                token_endpoint_auth_method: none
        '400':
          description: |
            An RFC 6749 §5.2 error object — **not** a problem document. Today
            the only `error` this produces is `invalid_redirect_uri`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthError' }
              example:
                error: invalid_redirect_uri
                error_description: >-
                  redirect_uris must be https URLs on claude.ai or claude.com.
                  This authorization server registers Claude connectors only.

  /oauth/authorize:
    get:
      tags: [OAuth]
      operationId: authorize
      summary: Start an authorization (OAuth 2.1 + PKCE)
      security: []
      description: |
        The browser's entry point. **It renders nothing.** This service owns no
        login page and no consent screen — the Plantactic app already has both,
        and a second implementation would be a worse copy of a flow that was
        recently made correct. This endpoint validates the request, records it,
        and redirects the browser to the app carrying an opaque `request_id` and
        nothing else. The `redirect_uri`, the `code_challenge` and your `state`
        stay server-side, where the person cannot edit values the token exchange
        is bound to.

        **PKCE is mandatory and `S256` is the only method.** A missing
        `code_challenge_method` is refused rather than defaulted: RFC 7636
        defaults an absent method to `plain`, so accepting the absence would
        quietly accept the thing being refused. `plain` makes the challenge
        equal to the verifier, which defeats the only protection a public client
        has.

        **Where an error goes depends on what has been proven**, and the split is
        deliberate. Until `client_id` resolves *and* `redirect_uri` exactly
        matches one registered for it, there is nowhere safe to send anything, so
        the refusal is a `400` rendered in the browser. After both check out, the
        destination is trustworthy and errors go back to it as
        `?error=…&error_description=…&state=…`, where the client's own handling
        can see them.

        `redirect_uri` is compared **exactly**, query string included.
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string }, description: As issued by `POST /oauth/register`. }
        - { name: redirect_uri, in: query, required: true, schema: { type: string, format: uri }, description: Must exactly match one registered for the client. }
        - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] }, description: '`code`. Nothing else is supported.' }
        - { name: code_challenge, in: query, required: true, schema: { type: string }, description: 'BASE64URL(SHA256(code_verifier)), unpadded.' }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] }, description: '`S256`. Required — an absent method is not defaulted.' }
        - { name: state, in: query, required: false, schema: { type: string }, description: Returned on both the success and the error redirect. }
        - name: scope
          in: query
          required: false
          schema: { type: string }
          description: |
            Space-separated, from `plantactic.read` and `plantactic.write`.
            Unrecognised values are dropped; if nothing recognisable remains, or
            the parameter is absent, **both** scopes are granted.
        - name: resource
          in: query
          required: false
          schema: { type: string, format: uri }
          description: |
            RFC 8707. Recorded on the authorization request because MCP clients
            send it, and accepted so that a client following a specification
            correctly is not broken by a parameter we have no use for. It is
            **not** enforced: tokens issued here are opaque strings and are not
            bound to an audience.
      responses:
        '302':
          description: |
            To the consent screen when the request is valid, or back to the
            already-verified client with `error` and `error_description` when it
            is not.
          headers:
            Location:
              description: The app's consent page, or the client's `redirect_uri`.
              schema: { type: string, format: uri }
        '400':
          description: |
            Unknown `client_id`, or a `redirect_uri` that does not exactly match
            one registered for it. Answered **here**, in the browser, rather than
            redirected — see the description. This one is a problem document,
            because its reader is a person looking at a page.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }

  /oauth/consent/{requestId}:
    get:
      tags: [OAuth]
      operationId: getConsentRequest
      summary: What the consent screen needs
      security: []
      description: |
        Called by the Plantactic app, not by an OAuth client, so it speaks
        camelCase JSON and answers problem documents like the rest of this API.

        **Guarded by the signed-in person's session token**, not by this
        document's bearer scheme — an app session JWT in `Authorization`, checked
        before this handler runs. It is `security: []` here because there is no
        API key or OAuth token that opens it.

        `redirectHost` is the host, not the whole URI. It is the part a person
        can meaningfully check — "this is going to claude.ai" — and a full URL on
        a consent screen is a string nobody reads.
      parameters:
        - name: requestId
          in: path
          required: true
          description: The opaque handle `/oauth/authorize` put in the redirect.
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The pending authorization request.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConsentRequestView' }
        '401':
          description: No app session token, or one that does not verify.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }
        '404':
          description: |
            No such authorization request. Requests live **ten minutes** — long
            enough to find a password, short enough that a link left in a tab
            overnight is dead — so this is what an expired one looks like.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }

  /oauth/consent:
    post:
      tags: [OAuth]
      operationId: decideConsent
      summary: Approve or deny
      security: []
      description: |
        The person's decision. **The user is taken from the verified session
        token, never from the body** — a `userId` a caller could send would be
        an authorization decision made by the caller.

        Two shapes come back, both `200`. `{"status":"redirect"}` when it is
        settled — carrying the code, or `error=access_denied` on a refusal — and
        `{"status":"choose_partner"}` when the person holds more than one
        Plantactic account and has to say which one the connector will see. The
        app shows a picker and calls again with `partnerId`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsentDecision' }
            example:
              requestId: 5c9d3a71-0e42-4b86-9f10-7a2c8d4e6b03
              approved: true
      responses:
        '200':
          description: Redirect the browser, or show a partner picker.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConsentOutcome' }
        '401':
          description: No app session token, or one that does not verify.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }
        '422':
          description: |
            `requestId` is missing, or `partnerId` was sent and is not a UUID.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }

  /oauth/token:
    post:
      tags: [OAuth]
      operationId: token
      summary: Exchange a code, or refresh
      security: []
      description: |
        `application/x-www-form-urlencoded` in, JSON out, RFC 6749 §5.2 error
        objects on failure. Not problem documents — a generic OAuth client
        parses exactly that shape and reports "unknown error" for anything else.

        **Two grants, and only two: `authorization_code` and `refresh_token`.**
        Anything else, including `client_credentials`, is
        `unsupported_grant_type`. There is no machine-to-machine grant on this
        server; a service that wants to call `/v1` without a person in the loop
        uses an API key.

        Refresh tokens are **rotated on every use**, so a stolen one is
        detectable rather than merely long-lived. An access token lives an hour;
        a refresh token thirty days, which is the connection's real lifetime.

        Every response carries `Cache-Control: no-store`. RFC 6749 §5.1 requires
        it and the reason is concrete: the body is a live credential, and a
        shared proxy must not keep it.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/TokenRequest' }
      responses:
        '200':
          description: A fresh access token, and a fresh refresh token.
          headers:
            Cache-Control:
              description: Always `no-store`.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
              example:
                access_token: ptx_oauth_9pQ2rX7fLm4vT0aZ...
                token_type: Bearer
                expires_in: 3600
                refresh_token: ptx_oauth_rt_KdN8sB1yE6hW3cU...
                scope: plantactic.read plantactic.write
        '400':
          description: |
            RFC 6749 §5.2. `unsupported_grant_type` for anything that is not one
            of the two; `invalid_grant` for a code that is unknown, expired
            (they live **sixty seconds**), already spent, or presented with a
            `code_verifier` that does not match the recorded challenge.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthError' }
              example:
                error: unsupported_grant_type
                error_description: grant_type must be authorization_code or refresh_token.

  /oauth/revoke:
    post:
      tags: [OAuth]
      operationId: revokeToken
      summary: Revoke a grant (RFC 7009)
      security: []
      description: |
        Revokes the **whole grant** the token belongs to, for a token of either
        kind. RFC 7009 permits this and it is what a person means by
        "disconnect": revoking an access token while leaving its refresh token
        able to mint another would be a disconnection that reconnects itself
        within the hour.

        **Always `200`,** including for a token that was never issued. RFC 7009
        §2.2 is explicit, and the reason is worth stating — answering differently
        for an unknown token would make this a free oracle for testing guesses.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                token:
                  type: string
                  description: An access token or a refresh token. Either revokes both.
      responses:
        '200':
          description: Revoked, or there was nothing to revoke. No body either way.

  /oauth/introspect:
    post:
      tags: [OAuth]
      operationId: introspectToken
      summary: Is this token live? (RFC 7662)
      security: []
      description: |
        **Disabled by default.** The endpoint requires a shared secret
        (`app.oauth.introspection-secret`), presented as
        `Authorization: Bearer …` and compared in constant time, and that secret
        is blank unless deployed with one — so an unconfigured environment
        answers `401` to everybody. Fail-closed on purpose: an open introspection
        endpoint is a token-validity oracle, where you present a guess and learn
        both whether it works and whose it is. RFC 7662 §2.1 requires the
        endpoint be protected, and the RFC's other option — the client's own
        credentials — is unavailable here, because this server issues no client
        secrets.

        It exists for the MCP resource server, which has to decide whether to
        serve a request before it makes one.

        **It introspects API keys as well as OAuth tokens**, deliberately: both
        are bearer credentials resolving to the same `(partner, livemode)` pair,
        so a caller that must handle both gets one code path. An API key comes
        back without `client_id` and without `exp` — it belongs to no client and
        does not expire, and inventing values would make a caller branch on a
        lie.

        Unknown, expired and revoked are one answer — `200` with
        `{"active": false}` and never a reason. See RFC 7662 §2.2.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                token:
                  type: string
                  description: 'The credential to ask about. Blank or absent is `{"active": false}`.'
      responses:
        '200':
          description: The token's state. An inactive one says nothing more than that.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IntrospectionResponse' }
              examples:
                active:
                  summary: A live OAuth access token
                  value:
                    active: true
                    token_type: Bearer
                    scope: plantactic.read plantactic.write
                    client_id: ptxc_4f9c1e7a2b6d4e8f9a0c3b5d7e1f2a48
                    exp: 1787062931
                    partnerId: 2f6b8c04-91d3-4e57-b0a8-c31e7d5f9a26
                    livemode: true
                inactive:
                  summary: Unknown, expired or revoked — indistinguishable
                  value:
                    active: false
        '401':
          description: |
            The introspection secret is unset, or the one presented is wrong.
            Which of the two is not said — the endpoint's configuration state is
            not the caller's business.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthError' }
              example:
                error: invalid_client

  /.well-known/oauth-authorization-server:
    get:
      tags: [Discovery]
      operationId: authorizationServerMetadata
      summary: Authorization server metadata (RFC 8414)
      security: []
      description: |
        How a client finds this server without being told. Public,
        unauthenticated — it is fetched *before* any credential exists, which is
        the point — and it says nothing a caller could not learn by trying every
        endpoint.

        Everything in it is derived from one configured issuer rather than
        written out, so the three places that must agree — this document, the
        protected-resource document, and the `WWW-Authenticate` header on a `401`
        — cannot drift. RFC 8414 requires `issuer` to match the URL the document
        was fetched from, which is why it is configuration and not a constant.

        `code_challenge_methods_supported` lists `S256` and nothing else. That
        absence is the machine-readable half of the rule `/oauth/authorize`
        enforces: advertising a method we reject, or rejecting one we advertise,
        breaks clients in opposite directions.

        Cached for an hour.
      responses:
        '200':
          description: The metadata document.
          content:
            application/json:
              schema:
                type: object
              example:
                issuer: https://ptx-api.plantactic.com
                authorization_endpoint: https://ptx-api.plantactic.com/oauth/authorize
                token_endpoint: https://ptx-api.plantactic.com/oauth/token
                registration_endpoint: https://ptx-api.plantactic.com/oauth/register
                revocation_endpoint: https://ptx-api.plantactic.com/oauth/revoke
                introspection_endpoint: https://ptx-api.plantactic.com/oauth/introspect
                scopes_supported: [plantactic.read, plantactic.write]
                response_types_supported: [code]
                grant_types_supported: [authorization_code, refresh_token]
                code_challenge_methods_supported: [S256]
                token_endpoint_auth_methods_supported: [none]
                revocation_endpoint_auth_methods_supported: [none]
                service_documentation: https://developer.plantactic.com

  /.well-known/oauth-protected-resource:
    get:
      tags: [Discovery]
      operationId: protectedResourceMetadata
      summary: Protected resource metadata (RFC 9728)
      security: []
      description: |
        What turns a `401` into a working connection: the `WWW-Authenticate`
        header names this document, this document names the authorization
        server, and the client walks from one to the other with no configuration
        from the person adding the connector. The MCP specification expects a
        resource server to serve it.

        The resource and the authorization server are the same host here,
        because this service is both. Cached for an hour.
      responses:
        '200':
          description: The metadata document.
          content:
            application/json:
              schema:
                type: object
              example:
                resource: https://ptx-api.plantactic.com
                authorization_servers: [https://ptx-api.plantactic.com]
                scopes_supported: [plantactic.read, plantactic.write]
                bearer_methods_supported: [header]
                resource_documentation: https://developer.plantactic.com

components:
  securitySchemes:
    # `bearerAuth` covers BOTH credential kinds, because the service does. The
    # filter dispatches on the prefix to decide which table to look in, and then
    # builds the same principal either way -- so partner scoping, metering and
    # livemode cannot behave differently for an OAuth token than for a key. Two
    # schemes here would describe two code paths downstream that do not exist.
    # `oauth2` below is declared as well, for the clients that can drive the
    # flow themselves; it issues credentials that this one then accepts.
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Any of this service's three bearer credentials, in `Authorization`.

        An **API key**, prefixed `ptx_test_` or `ptx_live_`. The two are never
        interchangeable: a test key reaches only test-mode datasets, and a live
        key only live ones. Neither can see the other's, and there is no
        promotion path between them.

        Or an **OAuth access token**, prefixed `ptx_oauth_`, obtained through
        the flow under `oauth2`. It resolves to a partner and a livemode exactly
        as a key does, and nothing downstream is told which kind authenticated.

        A **refresh token** (`ptx_oauth_rt_`) is refused here **by shape**,
        before any database work — belt and braces on top of the lookup's own
        `kind = 'access'` filter, because one mechanism would be enough right up
        until someone edits the query.

        Tokens are opaque random strings, not JWTs, and are **not**
        audience-bound: a `resource` parameter is recorded at `/oauth/authorize`
        but nothing is issued against it. Only credentials this server minted
        are ever accepted; no foreign token is accepted or transited.

        A `401` carries `WWW-Authenticate` naming the RFC 9728 metadata
        document, which is how an OAuth client discovers where to authenticate.

    oauth2:
      type: oauth2
      description: |
        This service is its own authorization server — there is no separate auth
        host. Clients register themselves at `POST /oauth/register` (RFC 7591)
        and receive **no client secret**: they are public, and PKCE (`S256`
        only) is what proves possession.

        There is **no `client_credentials` grant.** A caller with no person in
        the loop uses an API key instead.
      flows:
        authorizationCode:
          authorizationUrl: https://ptx-api.plantactic.com/oauth/authorize
          tokenUrl: https://ptx-api.plantactic.com/oauth/token
          refreshUrl: https://ptx-api.plantactic.com/oauth/token
          scopes:
            plantactic.read: Read datasets, versions, metrics and exports.
            plantactic.write: Create datasets, upload versions and request exports.

  parameters:
    # Path ids are BARE UUIDs -- no `ds_`/`dsv_` prefix. The service parses every
    # one with UUID.fromString and answers 404 for anything that does not parse,
    # so a prefixed id is not merely unidiomatic here, it is a guaranteed 404.
    DatasetId:
      name: datasetId
      in: path
      required: true
      description: |
        The dataset's identifier, as returned by `POST /v1/datasets`. A UUID.
        Anything that is not one is a `404` — it identifies nothing, which is
        what `404` means.
      schema:
        type: string
        format: uuid
        examples: [9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77]
    VersionId:
      name: versionId
      in: path
      required: true
      description: The version's identifier. A UUID; anything else is a `404`.
      schema:
        type: string
        format: uuid
        examples: [3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b]
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        A client-generated unique key — a UUID is fine. **Required on every
        mutating call.** Replaying a key within 24 hours returns the original
        response rather than performing the operation a second time, so a
        retried upload cannot create a duplicate version. A replayed response
        carries `Idempotent-Replay: true`, and is byte-identical to the first.

        Three cases are worth knowing before you build a retry:

        - **Same key, same body** → the original response, replayed.
        - **Same key, a different body** → `422`. This is a client bug, and
          quietly returning the first response would hide it. Use a new key for
          a new request.
        - **Same key while the first request is still running** → `409`. Retry
          in a moment; do not change the key, or you will get two of whatever
          you asked for.

        A request that **failed** releases its key, so fixing a rejected upload
        and retrying with the same key is exactly right — only operations that
        actually happened are un-repeatable.

        Scope is per partner, per key, per **concrete** path: the same key on
        two different datasets is two different records.

        Omitting it on a mutating call is a `400`, not a silent pass.
      schema:
        type: string
        examples: ['b0f3f1a2-0f1c-4a7e-9a51-2f7a4c8d9e10']

  headers:
    ComputeUnits:
      description: |
        Compute units consumed by this request — the number of contracts in the
        active version, times the number of months the grid spans, times the
        combinations evaluated (one today). A rectangle, not a sum of each
        contract's own term: a contract covering three months of a sixty-month
        axis is charged for sixty.

        It is the **metered** figure, the same number written to the usage
        record, rather than a second guess at it: a header and an invoice that
        disagree are worse than no header.

        It is **not** a rate limit. Nothing checks it before a query runs and
        nothing refuses a request for exceeding anything.
      schema: { type: integer }

  responses:
    BadRequest:
      description: The request was malformed.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }
    Unauthorized:
      description: |
        Missing, malformed, expired or revoked credential. For OAuth callers the
        response carries `WWW-Authenticate` with `resource_metadata`, per RFC 9728.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }
    NotFound:
      description: |
        No such resource **within your partner account**. A dataset belonging to
        another partner is indistinguishable from one that does not exist —
        isolation is enforced in the application layer and again by
        database-level row security, and neither leaks existence.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }
    Conflict:
      description: |
        The resource already exists, its state forbids the operation, or a
        request with the same `Idempotency-Key` is still being processed.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }
    UnprocessableEntity:
      description: |
        Well-formed but semantically invalid. Where the failure is per-row, every
        failing row is listed in `errors` and nothing is written.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }
    # There is deliberately no `TooManyRequests` here. This service does no rate
    # limiting: no endpoint answers 429, no budget is checked, and nothing is
    # priced at admission. Metering records what a query cost AFTER it has run --
    # see the ComputeUnits header and the Metering section in `info`. A 429
    # response object sitting unused would read as "this exists but is not
    # documented", which is the opposite of true.

  schemas:
    ProblemDetails:
      type: object
      title: Problem Details (RFC 9457)
      description: |
        The single error shape for every `/v1` endpoint, served as
        `application/problem+json`. `errors` is an extension member, which RFC
        9457 permits.

        Four members are always present — `type`, `title`, `status`, `detail`.
        There is no `requestId`; quote the `detail` and the time when you
        contact support.

        `instance` is present on problems raised by an endpoint — a `404` for a
        missing dataset, a `422` for a bad body — and carries the request path.
        It is **absent** on problems raised before the request reaches an
        endpoint: the `401` from authentication and the `422` from an
        `Idempotency-Key` reused with a different body are both written by a
        filter, which does not set it. Read it as optional rather than
        guaranteed.

        The `/oauth/*` endpoints are the exception and do **not** use this
        shape. See that tag.
      required: [type, title, status, detail]
      properties:
        type:
          type: string
          format: uri
          description: A stable URI identifying the problem kind. Dereferences to documentation.
          examples: ['https://developer.plantactic.com/errors/ingest-validation-failed']
        instance:
          type: string
          format: uri-reference
          description: |
            The request path, on problems raised by an endpoint. Absent on
            problems raised by a filter before routing — authentication and
            idempotency conflicts.
          examples: ['/v1/datasets/9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77/metrics']
        title:
          type: string
          description: A short, human-readable summary. Stable for a given `type`.
        status:
          type: integer
          description: The HTTP status code, repeated in the body.
          examples: [422]
        detail:
          type: string
          description: An explanation specific to this occurrence. Always present.
        errors:
          type: array
          description: |
            Present when the failure is per-row. **Complete** — every failing row
            appears here, not just the first. Nothing was written.
          items: { $ref: '#/components/schemas/IngestError' }

    IngestError:
      type: object
      description: One rejected row.
      required: [row, code, message]
      properties:
        row:
          type: integer
          description: 1-based index within the submitted array.
          examples: [148]
        field:
          type: string
          description: The offending field, when the failure is attributable to one.
          examples: [tcv]
        code:
          type: string
          description: A stable machine-readable reason.
          examples: [not_a_number]
        message:
          type: string
          description: Human-readable explanation.

    InputModel:
      type: string
      description: |
        What the dataset's atomic input row is. Fixed at creation, and
        **`contracts` when omitted**.

        - `contracts` — one row per contract.
        - `revenue_events` — transactions or usage.

        **A label, not a switch — today.** Both values are accepted, recorded
        and echoed back on every dataset response, and neither changes any
        behaviour: `POST .../versions` takes contract rows whichever one you
        chose, and the capability catalog runs the same computation either way.
        It is here because a dataset's input model is fixed at creation and
        cannot be added retroactively, so recording it now is what makes a
        `revenue_events` ingest path possible later without a migration.

        Anything else — including `customer_month`, which earlier drafts of this
        document listed — is a `422`:

        > `inputModel must be one of [...]`, listing the two accepted values
      enum: [contracts, revenue_events]
      default: contracts

    DatasetMode:
      type: string
      description: |
        Intended retention posture. Validated against these two values, stored,
        and echoed back — and **enforced by nothing**. Both behave as
        `persistent`: the rows are written and kept, and no sweeper deletes an
        `ephemeral` dataset's data. See **Operating modes**.

        Not to be confused with `livemode`, which says whether a *credential* is
        test or live and does change behaviour. The two are independent — a test
        key can create a persistent dataset.
      enum: [persistent, ephemeral]
      default: persistent

    Dataset:
      type: object
      properties:
        datasetId: { type: string, format: uuid, examples: [9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77] }
        externalRef:
          type: string
          description: Your identifier for this customer. Unique within your partner account.
          examples: [acct_88213]
        name: { type: string, examples: [Northwind Software] }
        inputModel: { $ref: '#/components/schemas/InputModel' }
        mode: { $ref: '#/components/schemas/DatasetMode' }
        activeVersionId:
          type: [string, 'null']
          description: The version currently serving queries. `null` until the first upload activates.
        expiresAt:
          type: [string, 'null']
          format: date-time
          description: >-
            What you asked for at creation, echoed back. `null` means none was
            asked for. No expiry is currently enforced — see `DatasetMode`.
        livemode:
          type: boolean
          description: |
            `false` when created with a `ptx_test_` key. Present on every
            dataset, version and export response so nothing ships against the
            sandbox by accident — but **not** on the grid, which carries no
            identifying fields at all. See `GridResponse`.
        createdAt: { type: string, format: date-time }

    DatasetCreateRequest:
      type: object
      # `externalRef` alone. `inputModel` and `mode` both DEFAULT when absent --
      # to `contracts` and `persistent` -- rather than being rejected, so listing
      # them as required would describe a refusal that does not happen.
      required: [externalRef]
      properties:
        externalRef:
          type: string
          maxLength: 255
          description: |
            **Your** identifier for the customer this dataset represents. We
            never hand you an identifier of ours to map — you address the dataset
            by this, or by the `datasetId` we return, whichever suits you.
        name:
          type: string
          description: A human-readable label, for your own dashboards and ours.
        inputModel: { $ref: '#/components/schemas/InputModel' }
        mode: { $ref: '#/components/schemas/DatasetMode' }
        expiresAt:
          type: string
          format: date-time
          description: >-
            Optional, and **not acted on**. Recorded and returned; nothing
            deletes a dataset when it passes. See `DatasetMode`.

    VersionStatus:
      type: string
      description: |
        `building` is the only non-terminal state. `active` and `invalid` are
        terminal for a polling client. `superseded` is applied out of band when a
        later version takes over — you will never poll into it.
      enum: [building, active, invalid, superseded]

    DatasetVersion:
      type: object
      properties:
        versionId: { type: string, format: uuid, examples: [3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b] }
        datasetId: { type: string, format: uuid, examples: [9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77] }
        status: { $ref: '#/components/schemas/VersionStatus' }
        rowCount:
          type: [integer, 'null']
          description: Rows accepted. `null` while `building`, and on `invalid` — nothing was written.
        createdAt: { type: string, format: date-time }
        activatedAt:
          type: [string, 'null']
          format: date-time
          description: |
            When this version **started serving queries**. Not "when it
            finished" — the two differ, and the difference matters:

            - `building` → `null`, as you would expect.
            - `active` → the instant of the atomic swap.
            - `invalid` → **`null`, permanently.** Invalidation does not set it.
              A version that never activated has no activation time, and putting
              a rejection time here would make the field mean two things.

            So this is not a way to tell when a build ended. To know that a
            terminal `invalid` version is settled, read `status`.
        livemode:
          type: boolean
          description: >-
            The credential's mode, carried here as it is on every dataset and
            export response. Taken from the credential that asked, not stored on
            the version.
        problem:
          description: |
            Present only on `invalid`, and only when the failure has a reason the
            partner has not already been told. It is the RFC 9457 document a
            synchronous `422` would have returned — complete `errors` array
            included — which is how the upload path delivers validation results
            that could not be delivered in the response.

            Absent when a build simply died: on the inline path the rows were
            validated before any version existed, so an `invalid` version there
            has nothing to say about the data.
          $ref: '#/components/schemas/ProblemDetails'

    VersionCreateRequest:
      type: object
      description: |
        **Exactly one of `contracts` or `uploadId`.** Inline rows, or a workbook
        you have already `PUT` to a URL from `POST /v1/uploads`.

        Sending both is a `422` rather than a choice made for you, because the
        alternative is silently ignoring data you sent and only finding out from
        the row count. Sending neither is the same `422` — it is a request with
        no data in it.

        There is no `revenueEvents` and no `customerMonths`. Contract rows are
        the only shape this endpoint reads, whatever the dataset's `inputModel`
        says.
      properties:
        uploadId:
          type: string
          description: |
            An `.xlsx` workbook already uploaded, INSTEAD of an inline array.
            Mutually exclusive with `contracts`.

            The first row is the header. Column names are matched to the contract
            fields case-insensitively and through separators — `Contract ID`,
            `contract_id` and `contractId` are the same column — and a name that
            matches nothing is an error rather than an ignored column, because
            dropping a misspelled `tcv` silently would compute a grid from values
            you believe you sent. `contractId` and `customer` are required; every
            other contract field is optional. Only the FIRST sheet is read.

            **This path always answers `202`, and reports row errors through the
            version's terminal state rather than in the response** — it has to,
            because the workbook is fetched and parsed after the response is
            sent. Poll the version: `invalid` carries `problem`, which is the
            same document, with the same complete `errors` array, that an inline
            upload's `422` would have returned. Row numbers refer to rows in your
            spreadsheet.
        contracts:
          type: array
          description: |
            The rows, inline. Mutually exclusive with `uploadId`. An empty array
            is a `422` (`empty_batch`), not an empty version.

            At **2,000 rows or more** this answers `202` and builds off the
            request thread; below it, `201` with the version already active.
          items: { $ref: '#/components/schemas/ContractInput' }

    ContractInput:
      type: object
      description: |
        One contract.

        **Only `contractId` and `customer` are required, and the rest being
        optional has consequences worth knowing rather than discovering.** A row
        is validated for *shape* — dates parse, numbers are finite and
        non-negative, `endDate` is not before `startDate`, `startDate` is not
        before `signedDate`, `contractId` is unique within the upload — and
        never for completeness. A sparse row is accepted.

        What a sparse row then does, in the engine:

        - **No `term`, or a `revenueModel` containing `ONE_TIME`** → the row still
          produces **bookings TCV and recognized revenue**, and produces **zero**
          for every recurring measure: ARR, MRR, CARR, CMRR, logos, users. This
          is the failure worth guarding against, because the grid comes back
          populated and is wrong only in the part you were reading.
        - **No `tcv`, or no `signedDate`** → the contract expands to **no facts at
          all** and appears in no metric. A missing `signedDate` also moves the
          axis start, which is derived from the earliest signature. A version of
          nothing but such rows is a legitimate upload that answers an empty
          grid.
        - **No `licenses`** → it reads as `0`, so the contract contributes to
          revenue but nothing to user counts.
        - **No `startDate` or no `endDate`** → the contract is *open-ended*: it
          has no live window, so it contributes to contracted measures (CMRR,
          CARR) through the query's `horizon` and to live measures not at all.

        None of these is an error and none is reported. If a metric comes back
        empty, this is the first thing to check.
      required: [contractId, customer]
      properties:
        contractId: { type: string, description: Your identifier for the contract. }
        customer:
          type: string
          description: |
            The end customer's name or identifier. Cohort vintage, logo counts
            and retention are all derived by grouping on this, so it must be
            stable across versions.
        counterpartyRole:
          type: string
          description: |
            Reserved. Carried from day one so a two-sided marketplace waterfall
            can be added later without a columnar migration. Leave it unset.
        product: { type: string }
        productCategory: { type: string }
        revenueModel:
          type: string
          description: How the contract's value is recognised.
          examples: [SUBSCRIPTION]
        signedDate: { type: string, format: date, description: Drives bookings. }
        startDate: { type: string, format: date }
        endDate: { type: string, format: date }
        term: { type: integer, description: 'Months. Zero or absent keeps bookings TCV and recognized revenue and zeroes every recurring measure — ARR, MRR, CARR, CMRR, logos, users.' }
        tcv: { type: number, description: 'Total contract value, in the dataset''s currency. Zero or absent removes the contract from every metric.' }
        licenses: { type: integer, description: 'Seats. Absent reads as 0 — the contract still contributes revenue, just no users.' }
        pricePerLicense:
          type: number
          description: >-
            Validated (finite, non-negative) and stored, but not read by the
            engine — MRR is `tcv / term`, not `licenses × pricePerLicense`.

    # There is no `dimensions` field, and there was one in this document until
    # it was checked against the code. `ContractRow`'s constructor reads twelve
    # named keys and never looks at `dimensions`, so anything sent under it was
    # accepted, silently discarded, and never queryable -- while this document
    # described it as "a filterable and sweepable dimension". Product and
    # productCategory are the only segmentation that survives ingest, and there
    # is no filtering endpoint to use them with yet either. It is removed rather
    # than documented-as-ignored because a field that does nothing is one a
    # reader will still populate.

    GridRequest:
      type: object
      description: |
        **One field, and it is optional.** The whole body may be omitted.

        There is no `versionId`, `periodType`, `startMonth`, `endMonth`,
        `metricGroups` or `filters` — earlier drafts of this document described
        all six, and the service reads none of them. It runs the dataset's
        active version over its whole axis, monthly, across every section.
        Sending them is not an error; it simply has no effect, which is worse,
        so they are named here rather than left to be inferred from silence.
      properties:
        horizon:
          type: string
          pattern: '^\d{4}-\d{2}$'
          examples: ['2026-12']
          description: |
            The calculation's "now": the month open-ended contracts are
            projected through, and the boundary between actuals and
            forward-looking periods.

            **Send it.** Omitted, it is the current month by the server's clock,
            which makes the same request answer differently once the month rolls
            over — a partner diffing today against yesterday would see the
            engine move for reasons unrelated to their data. Pinning it is what
            makes a grid reproducible.

            Present and unparseable is a `422`. The export endpoint requires it
            outright.

    FormatType:
      type: string
      description: |
        Presentation metadata for the row. **The wire carries raw numbers at full
        precision** — this tells your client how to render them, and nothing
        more. These four are what the grid emits.
      enum:
        - ACCURATE_CURRENCY_VALUE
        - PERCENTAGE_VALUE
        - INT_VALUE
        - ACCURATE_DOUBLE_VALUE

    RowPreset:
      type: [string, 'null']
      description: |
        An optional visual hint for the row's place in a waterfall. **Present on
        every row and `null` when absent** — this service serializes rows with
        no null-omission policy, so there is no missing key to distinguish from
        a null one. Treat them the same.
      enum: [INDENT, DANGER, BOLD, TOTAL, null]

    MetricRow:
      type: object
      # Every component of the engine's MetricRow record, including the three
      # that are usually null. ptx-api writes rows through a plain mapper -- the
      # NON_NULL policy the app applies at its own boundary is not applied here
      # -- so all eight keys are on the wire on every row.
      required: [rowId, metric, displayName, formatType, preset, values, secondValues, formatType2]
      properties:
        rowId:
          type: string
          description: |
            Stable across versions and releases. Key your integration on this,
            never on the row's position in the section.
          examples: [arr_waterfall.closing_live_arr]
        metric:
          type: string
          description: The engine's metric identifier.
          examples: [CLOSING_LIVE_ARR]
        displayName:
          type: string
          description: A human label. Presentation only — do not key on it.
          examples: [Closing ARR]
        formatType: { $ref: '#/components/schemas/FormatType' }
        preset: { $ref: '#/components/schemas/RowPreset' }
        values:
          type: array
          description: |
            Dense and index-aligned to the response's `periods`. `values[i]`
            belongs to `periods[i]`.

            **`null` means not-computable. It never means zero.** Insufficient
            history for a trailing window and a ratio with no denominator both
            arrive as `null`. A measured zero arrives as `0`.

            **Two rows are exceptions.**
            `financial.contracted_burn_multiple_t3m` and `..._ttm` carry
            `2147483647` (`Integer.MAX_VALUE`) where they mean undefined, not
            `null` — held that way for parity with a persisted representation
            the application renders as "N/A". Treat that value as absent on
            those two rows.
          items:
            type: [number, 'null']
        secondValues:
          type: [array, 'null']
          description: |
            A second series for the same row, in the unit `formatType2` names —
            a count beside a currency, say. `null` on virtually every row the
            grid emits, and present as `null` rather than absent.
          items:
            type: [number, 'null']
        formatType2:
          type: [string, 'null']
          description: The unit for `secondValues`. `null` whenever that is.

    MetricSection:
      type: object
      required: [sectionId, title, rows]
      properties:
        sectionId: { type: string, examples: [arr_waterfall] }
        title: { type: string, examples: [Live ARR] }
        rows:
          type: array
          items: { $ref: '#/components/schemas/MetricRow' }

    GridResponse:
      type: object
      description: |
        The grid, and **these six fields are the whole of it**. The service
        returns the engine's own `Grid` record verbatim, with nothing wrapped
        around it.

        This document previously promised nine more — `v`, `datasetId`,
        `versionId`, `livemode`, `measure`, `periodType`, `asOfMonth`,
        `contractCount`, `factCount` and `notes`. None is sent. They were the
        shape the internal deep-dive payload has, designed in here on the
        assumption that ptx-api would wrap the engine's output; it does not.

        Two consequences a client should plan for rather than discover:

        - **This response does not carry `livemode`,** which contradicts the
          "every response carries `livemode`" rule stated elsewhere in this
          document. That rule is true of dataset, version and export responses,
          and false of this one. To know which mode a grid came from, key it to
          the credential you sent or read the dataset.
        - **Nor does it carry `versionId`,** so a grid cannot be traced from its
          own payload to the immutable version that produced it. Fetch the
          dataset if you need that.
      required: [horizon, axisStart, axisEnd, months, periods, sections]
      properties:
        horizon:
          type: string
          description: >-
            Echoed from the request — the calculation's "now", or the current
            month if you did not send one. Read it back to learn which you got.
          examples: ['2026-12']
        axisStart:
          type: [string, 'null']
          description: |
            First month present in the dataset. `null` — along with `axisEnd` —
            when every contract expanded to nothing, which is an empty grid
            rather than an error: `months` is `0` and `periods` and `sections`
            are empty.
          examples: ['2024-11']
        axisEnd:
          type: [string, 'null']
          description: Last month present in the dataset. `null` on an empty grid.
          examples: ['2028-12']
        months:
          type: integer
          description: Months spanned by the axis. `0` on an empty grid.
          examples: [50]
        periods:
          type: array
          description: >-
            The period axis, always monthly and always the dataset's whole
            range. Labels are `YYYY-MM`.
          items: { type: string }
          examples: [['2028-04', '2028-05', '2028-06']]
        sections:
          type: array
          description: Every section the engine could compute. Not selectable.
          items: { $ref: '#/components/schemas/MetricSection' }

    MetricCatalog:
      type: object
      description: Both capability axes for a dataset's active version.
      required: [datasetId, versionId, inputModel, computable, cohortable]
      properties:
        datasetId: { type: string, format: uuid, examples: [9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77] }
        versionId:
          type: string
          format: uuid
          description: >-
            The active version this was computed over. The catalog is the one
            place a version id appears on a metrics response — the grid carries
            none.
          examples: [3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b]
        inputModel:
          allOf: [{ $ref: '#/components/schemas/InputModel' }]
          description: >-
            Echoed from the dataset. **Nothing below varies with it** — the same
            computation runs for either value. See `InputModel`.
        computable:
          type: object
          description: |
            What this dataset's active version actually computed, measured
            rather than predicted: the grid is built and its sections counted.
            So it reflects the data you uploaded, not a capability table.
          required: [sections, metricCount]
          properties:
            sections:
              type: array
              items:
                type: object
                required: [sectionId, title, rowCount]
                properties:
                  sectionId: { type: string, examples: [arr_waterfall] }
                  title: { type: string, examples: [Live ARR] }
                  rowCount: { type: integer, description: Rows this section produced. }
            metricCount:
              type: integer
              description: >-
                The sum of every section's `rowCount` — rows produced, not
                metric definitions in the knowledge base.
        cohortable:
          type: object
          description: |
            The cohort axis, and the exclusions from it. Note that
            `unavailable` lives **here**, inside `cohortable`, rather than
            beside it: everything in it is a statement about cohorting, never
            about computability.
          required: [measures, unavailable]
          properties:
            measures:
              type: array
              description: |
                **Bare measure names, not objects.** There is no `displayName`
                and no `formatType` on this list — it is derived from the
                engine's own `CohortMatrix.Measure` enum rather than written out
                here, so if an eighth measure is ever added this endpoint
                reports it without anyone editing a second list.

                Today that enum holds seven, and the exclusions that keep it at
                seven are structural rather than a backlog.
              items:
                type: string
                enum: [MRR, CMRR, ARR, CARR, USERS, CONTRACTED_USERS, LOGOS]
            unavailable:
              type: array
              description: |
                Metrics that are computable but refuse to be cohorted — so a
                client can explain the gap to a user instead of silently
                omitting a menu item.

                **The same six entries for every dataset,** every time. This is
                the settled list of structural exclusions, not a finding about
                your data, and it does not vary with `inputModel` or with what
                you uploaded.
              items:
                type: object
                required: [metric, reason]
                properties:
                  metric:
                    type: string
                    description: The engine metric name.
                    examples: [BURN_MULTIPLE]
                  reason:
                    type: string
                    description: |
                      **Free text for a human, not a machine-readable slug.** An
                      earlier draft of this document promised an enum here
                      (`composite_over_company_period` and four others); the
                      service sends prose, and the wording is not stable. Branch
                      on `metric`; render `reason`.
                    examples: ['a ratio defined over a company-period, not a customer-month']

    ExportFormat:
      type: string
      description: |
        `xlsx` is a real workbook, not a CSV with a different extension. `pdf`
        renders the grid with charts. `json` is the grid payload, unchanged.
      enum: [xlsx, json, pdf]

    ExportStatus:
      type: string
      description: '`succeeded` and `failed` are terminal. Stop polling at either.'
      enum: [queued, running, succeeded, failed]

    ExportCreateRequest:
      type: object
      required: [datasetId, format, query]
      properties:
        datasetId:
          type: string
          format: uuid
          description: >-
            Checked only for being a UUID. Whether it names a dataset, and
            whether that dataset is yours, is not asked until the job runs.
          examples: [9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77]
        format: { $ref: '#/components/schemas/ExportFormat' }
        query:
          allOf: [{ $ref: '#/components/schemas/GridRequest' }]
          description: >-
            The grid query. **`horizon` is required here**, unlike on the
            metrics endpoint where it defaults to the current month; omitting it
            is a `422` before anything is queued. Nothing else inside is read.

    Upload:
      type: object
      description: |
        A place to PUT one workbook, and the id that names it afterwards. There
        is no endpoint to read this back — it is not a resource with a state.
      required: [uploadId, url, expiresAt]
      properties:
        uploadId:
          type: string
          description: |
            Send this as `uploadId` on `POST /v1/datasets/{datasetId}/versions`.
            It is scoped to your partner account: another account's id addresses
            nothing here, and is answered `404` like any id that never existed.
        url:
          type: string
          format: uri
          description: |
            PUT the workbook here, with no `Authorization` header — the signature
            in the query string is the credential. One object only.
        expiresAt:
          type: string
          format: date-time
          description: When `url` stops accepting a PUT. Ten minutes after issue.
        maxBytes:
          type: integer
          description: |
            The largest workbook this endpoint will read. Anything above it is
            refused when you name the upload, not silently truncated.
          examples: [20971520]

    ExportDownload:
      type: object
      description: |
        The body of a `302` from `GET /v1/exports/{exportId}/download`. Same URL
        as the `Location` header, and the one to prefer: following the redirect
        with an `Authorization` header still attached is rejected by object
        storage.
      required: [url, expiresAt]
      properties:
        exportId: { type: string, examples: [7c4d2e18-5b60-4a39-9d81-0f3a6c25be84] }
        url:
          type: string
          format: uri
          description: A signed URL, minted for this request. Valid for ten minutes.
        expiresAt:
          type: string
          format: date-time
          description: When `url` stops working. Request the download link again for a fresh one.

    ExportJob:
      type: object
      properties:
        exportId: { type: string, format: uuid, examples: [7c4d2e18-5b60-4a39-9d81-0f3a6c25be84] }
        datasetId: { type: string, format: uuid, examples: [9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77] }
        format: { $ref: '#/components/schemas/ExportFormat' }
        status: { $ref: '#/components/schemas/ExportStatus' }
        createdAt: { type: string, format: date-time }
        completedAt:
          type: [string, 'null']
          format: date-time
          description: >-
            When the job reached a terminal state. `null` while `queued` or
            `running`. Unlike a version's `activatedAt`, this IS set on failure.
        url:
          type: [string, 'null']
          format: uri
          description: |
            A short-TTL signed URL, present only on `succeeded`. Fetch it, do not
            store it. If it expires, poll again for a fresh one.

            Every poll mints a NEW url; none is stored server-side. That is what
            makes "never sent anywhere" true rather than aspirational.
        expiresAt:
          type: [string, 'null']
          format: date-time
          description: When `url` stops working. Typically minutes after issue.
        byteSize:
          type: [integer, 'null']
          description: Size of the artifact. `null` until it has been rendered.
        problem:
          description: |
            Present only on `failed` — this one key really is omitted rather
            than null. The RFC 9457 document the job stored, verbatim, which is
            why its own `status` is `409` or `500` rather than the status of the
            response carrying it.

            The commonest is `no-active-version`, and read its `detail`
            carefully: it also covers a dataset that does not exist or is not
            yours, because `POST /v1/exports` never checked.
          $ref: '#/components/schemas/ProblemDetails'

    # ── OAuth wire shapes ───────────────────────────────────────────────────
    # These are snake_case and stay that way. The rest of this API is camelCase
    # and these are not exceptions made carelessly: RFC 7591, 6749 and 7662 fix
    # every member name below, and a generic OAuth client reads exactly those
    # spellings. The house style stops at the /oauth boundary.

    OAuthError:
      type: object
      description: |
        The RFC 6749 §5.2 error object. **Not** an RFC 9457 problem document,
        and the difference is deliberate: a generic OAuth client parses this
        shape and reports "unknown error" for anything else. Returned by
        `/register`, `/token` and `/introspect`.
      required: [error]
      properties:
        error:
          type: string
          description: The machine-readable code. Branch on this.
          examples: [invalid_grant]
        error_description:
          type: string
          description: A sentence for a human. Wording is not stable.

    ClientRegistrationRequest:
      type: object
      required: [redirect_uris]
      properties:
        client_name:
          type: string
          description: >-
            Shown on the consent screen. Truncated to 200 characters; blank or
            absent becomes `Unnamed client`.
        redirect_uris:
          type: array
          description: |
            At least one, at most ten. Every entry must be an `https` URL on
            `claude.ai` or `claude.com` — this server registers Claude
            connectors and nothing else. One bad entry refuses the whole
            request.
          items: { type: string, format: uri }

    ClientRegistrationResponse:
      type: object
      description: >-
        The metadata as recorded, echoed back — which is what a registration
        response is. There is no `client_secret`: the clients are public.
      required: [client_id, client_id_issued_at, redirect_uris]
      properties:
        client_id: { type: string, examples: [ptxc_4f9c1e7a2b6d4e8f9a0c3b5d7e1f2a48] }
        client_id_issued_at: { type: integer, description: Seconds since the epoch. }
        client_name: { type: string }
        redirect_uris: { type: array, items: { type: string, format: uri } }
        grant_types: { type: array, items: { type: string }, examples: [[authorization_code, refresh_token]] }
        response_types: { type: array, items: { type: string }, examples: [[code]] }
        token_endpoint_auth_method: { type: string, examples: [none] }

    TokenRequest:
      type: object
      description: >-
        Form-encoded. Which fields matter depends on `grant_type`, and there is
        no `client_secret` in either case — the client is public and PKCE is
        what proves possession.
      required: [grant_type]
      properties:
        grant_type:
          type: string
          description: >-
            `authorization_code` or `refresh_token`. Anything else — including
            `client_credentials` — is `unsupported_grant_type`.
          enum: [authorization_code, refresh_token]
        code: { type: string, description: '`authorization_code` only. Single use, valid sixty seconds.' }
        redirect_uri: { type: string, format: uri, description: '`authorization_code` only. Must match the one authorized.' }
        code_verifier: { type: string, description: '`authorization_code` only. 43–128 characters; its S256 digest must equal the recorded challenge.' }
        refresh_token: { type: string, description: '`refresh_token` only. Rotated on every use — the one you send stops working.' }
        client_id: { type: string }

    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in, refresh_token, scope]
      properties:
        access_token:
          type: string
          description: Prefixed `ptx_oauth_`. Present this as the bearer credential on `/v1`.
        token_type: { type: string, examples: [Bearer] }
        expires_in: { type: integer, description: Seconds. One hour. }
        refresh_token:
          type: string
          description: >-
            Prefixed `ptx_oauth_rt_`. Thirty days, rotated on every use, and
            refused by shape if presented as a bearer credential.
        scope:
          type: string
          description: Space-separated. What was actually granted, which may be less than you asked for.
          examples: ['plantactic.read plantactic.write']

    IntrospectionResponse:
      type: object
      description: |
        RFC 7662. `active` is the only guaranteed member: when it is `false`
        that is the entire document, and it means unknown, expired **or**
        revoked without saying which.

        An API key introspects too, and comes back without `client_id` and
        without `exp` — it belongs to no client and does not expire.
      required: [active]
      properties:
        active: { type: boolean }
        token_type: { type: string, examples: [Bearer] }
        scope: { type: string, examples: ['plantactic.read plantactic.write'] }
        client_id: { type: string, description: Absent for an API key. }
        exp: { type: integer, description: Seconds since the epoch. Absent for an API key. }
        partnerId: { type: string, format: uuid, description: 'Non-standard, and the reason this endpoint exists: who the credential resolves to.' }
        livemode: { type: boolean }

    ConsentRequestView:
      type: object
      description: What the app needs to render a consent screen. camelCase, because its caller is ours.
      required: [requestId, clientId, redirectHost, scope, expiresAt, decided, partners]
      properties:
        requestId: { type: string, format: uuid }
        clientName: { type: string }
        clientId: { type: string }
        redirectHost:
          type: string
          description: >-
            The host only. It is the part a person can meaningfully check; a
            full URL on a consent screen is a string nobody reads.
          examples: [claude.ai]
        scope: { type: string, examples: ['plantactic.read plantactic.write'] }
        expiresAt: { type: string, format: date-time, description: Ten minutes after the request started. }
        decided: { type: boolean }
        partners:
          type: array
          description: Every Plantactic account this person holds. More than one means a picker.
          items:
            type: object
            properties:
              partnerId: { type: string, format: uuid }
              name: { type: string }
              email: { type: string }

    ConsentDecision:
      type: object
      required: [requestId]
      properties:
        requestId: { type: string, format: uuid }
        approved:
          type: boolean
          description: Anything other than `true` is a denial. Absent is a denial.
        partnerId:
          type: string
          format: uuid
          description: >-
            Only on the second call, after a `choose_partner` outcome. The
            **user** is never sent — it comes from the verified session token.

    ConsentOutcome:
      type: object
      description: >-
        Two shapes, both `200`, distinguished by `status`. Branch on it before
        reading anything else.
      required: [status]
      properties:
        status: { type: string, enum: [redirect, choose_partner] }
        redirectUrl:
          type: string
          format: uri
          description: >-
            On `redirect`. Carries the authorization code, or
            `error=access_denied` when the person refused.
        partners:
          type: array
          description: On `choose_partner`. Show a picker and call again with `partnerId`.
          items:
            type: object
            properties:
              partnerId: { type: string, format: uuid }
              name: { type: string }
              email: { type: string }
