openapi: 3.0.3
info:
  title: Dexaflow Public API
  description: |
    Airflow-3.2.x-compatible subset of the public API.
    This is the surface consumed by the Airflow UI when configured to talk to a Dexaflow Control Plane.
  version: "1.0.0"
  license:
    name: Apache 2.0
    url: https://www.apache.org/licenses/LICENSE-2.0

servers:
  - url: http://localhost:8080
    description: Local development

security:
  - bearerAuth: []

paths:
  /auth/token:
    post:
      summary: Issue a JWT for username/password credentials
      operationId: issueToken
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TokenRequest"
      responses:
        "200":
          description: Token issued
          headers:
            Set-Cookie:
              description: >-
                The browser session cookie (`_token`), set server-side with
                HttpOnly, Secure and SameSite=Lax, path `/`, and the token's TTL
                as its Max-Age. It is the same cookie the OIDC callback sets, so
                a password login replaces an SSO session rather than layering
                under it. An API client can ignore it entirely and read
                `access_token` from the body; a browser is signed in by it from
                this response onward and never has to write it from script.
                Secure is dropped only when the operator sets
                `auth.session_cookie_insecure`. It is omitted when the browser
                reports the request as cross-origin (`Sec-Fetch-Site` other than
                `same-origin` or `none`), so a page on another origin cannot
                sign this browser in as an account it holds credentials for; the
                body is unchanged in that case. A caller that sends no
                `Sec-Fetch-Site` at all always receives it: that is every
                non-browser client, which keeps no cookie jar, and also a
                browser on a plain-http origin that is not loopback, since
                fetch metadata is only sent to potentially trustworthy URLs.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/AuthUnavailable"

  /api/v2/auth/token/renew:
    post:
      summary: Renew a still-valid JWT into a fresh short-lived token
      description: >-
        Transparent renewal: given a still-valid user bearer, re-mints the same
        identity with a fresh short TTL, bounded by a server-side max_lifetime
        measured from first login. Lets a long CLI/dev session avoid re-logging in
        every token TTL while keeping the access token short-lived. Returns 401
        when the presented token is invalid, expired, or past max_lifetime, in
        which case the client must log in again. Returns 503 with Retry-After
        when the token could not be checked because the user store is
        unreachable; the token may still be good, so the client waits and
        retries instead of logging in again.
      operationId: renewToken
      tags: [Auth]
      responses:
        "200":
          description: Token renewed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/AuthUnavailable"

  /api/v2/auth/ciToken:
    post:
      summary: Exchange a CI provider's OIDC token for a deploy session (Dexaflow)
      description: >-
        A CI job (GitHub Actions, GitLab CI, Google Cloud Build) posts the
        OIDC token its provider minted for it. The token is checked against
        the issuer entry of the server's auth.ci_oidc whose issuer and
        audience it names (signature, issuer, audience, expiry, lifetime),
        and the entry's rules map its claims to a role of the entry's tenant;
        the first matching rule decides (ADR 0092). The tenant is the
        entry's, never a claim's. The answer is a dxci_ session token for
        that role, narrowed to the rule's scopes (dexaflow:deploy by
        default), for the Authorization header only. Public: the token in
        the body authenticates the caller. Served only when an issuer is
        configured; 404 otherwise. Refused exchanges are limited per client
        address.
      operationId: exchangeCIToken
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CITokenRequest" }
      responses:
        "200":
          description: A session for the role the matching rule names
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CITokenResponse" }
        "400":
          description: The body is not a JSON object with a token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: The token was not minted by a trusted CI issuer for this server, or is expired
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: No rule of the issuer admits the job, or the role it names does not exist in the tenant
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Too many refused exchanges from this address
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: The provider's signing keys or the database could not be read
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/auth/methods:
    get:
      summary: Sign-in methods this server offers (Dexaflow)
      description: >-
        What dexaflow auth login reads before it chooses how to sign in. sso
        is true when the OIDC single sign-on flow is served; password is true
        when a password login can succeed for someone (always without SSO,
        and only for break-glass accounts with it); device is true when the
        device sign-in (POST /api/v2/auth/cli/device) is served too. The
        values come from the same settings the sign-in page renders from.
        Public. A server without this route answers 404, and the CLI then
        uses a password; a server from before the device sign-in omits
        device.
      operationId: getAuthMethods
      tags: [Auth]
      security: []
      responses:
        "200":
          description: The sign-in methods
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthMethods" }

  /api/v2/auth/cli/device:
    post:
      summary: Start a device sign-in of the CLI (Dexaflow)
      description: >-
        The first step of dexaflow auth login --device, for a machine with no
        browser (RFC 8628 shaped, served by Dexaflow, so the server stays the
        only OIDC client). It answers a device_code the CLI keeps secret and
        polls with, a user_code of eight consonants (XXXX-XXXX) the person
        enters at verification_uri (verification_uri_complete has it filled
        in), expires_in (600) and interval (5) in seconds. The page, GET and
        POST /api/v2/auth/device, starts the server's own OIDC sign-in, the
        UI's, and only after it asks for the code; a live code leads to a
        consent page that shows the code and asks the person to confirm it
        matches the terminal before approving. Only hashes of the codes are
        stored. The body is ignored. Served only when single sign-on is
        configured. Rate limited to three starts a minute per client address
        (IPv6 by /64); 429 also when the address (IPv6 by /56) holds five
        sign-ins in progress, 503 when the cluster holds its cap or starts
        are busy.
      operationId: startCLIDeviceLogin
      tags: [Auth]
      security: []
      responses:
        "200":
          description: The device sign-in started
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CLIDeviceAuthorization" }
        "429":
          description: Too many starts, or sign-ins in progress, from this address
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Too many device sign-ins are in progress
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/auth/cli/token:
    post:
      summary: Exchange a CLI single sign-on code, or poll a device sign-in, for a token (Dexaflow)
      description: >-
        The last step of dexaflow auth login with single sign-on. The CLI
        opens GET /api/v2/auth/oidc/login with a loopback redirect_uri
        (http://127.0.0.1:<port>/... or http://[::1]:<port>/...), its own
        state, and code_challenge with code_challenge_method S256. After the
        IdP round trip, the callback resolves the user exactly as for the UI
        and shows a consent page; only its same-origin form (POST
        /api/v2/auth/cli/consent, with the CSRF token bound to its
        SameSite=Strict cookie) redirects the browser to the loopback with a
        one-time code. This route redeems that code, once per replica and
        within 60 seconds, for the PKCE verifier (43 to 128 unreserved
        characters) the challenge was derived from and the same redirect_uri,
        and answers what POST /auth/token answers, with expires_in the
        token's remaining lifetime. Every refusal (unknown,
        expired, used, tampered, wrong verifier or redirect_uri) is the same
        400, with error invalid_grant. Served only when single sign-on is
        configured, and rate limited per client address with the other OIDC
        routes.


        With grant_type urn:ietf:params:oauth:grant-type:device_code and a
        device_code from POST /api/v2/auth/cli/device, it polls a device
        sign-in instead (RFC 8628 section 3.4): 400 with error
        authorization_pending until the person approves, slow_down when
        polled sooner than the interval (which grows by five seconds),
        access_denied once when the person canceled, expired_token after
        expires_in, and invalid_grant for any device code that is not live
        (unknown, malformed or already redeemed). The approved sign-in is
        redeemed once across every replica for the token POST /auth/token
        answers. Polls have a per-address budget of their own. Another
        grant_type is unsupported_grant_type.
      operationId: exchangeCLILoginCode
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CLITokenRequest" }
      responses:
        "200":
          description: The sign-in completed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "400":
          description: The code was refused, or the device sign-in is not approved
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/CLITokenError" }
        "429":
          description: Too many requests from this address

  /api/v2/users:
    get:
      summary: List users
      description: |
        Lists the tenant's control-plane accounts, newest first. Each entry
        carries the full set of roles the user holds; the password and its hash
        are write-only and never returned. Requires the read:user permission.
      operationId: listUsers
      tags: [Users]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of users
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserCollection" }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      summary: Create a user
      description: |
        Admin-only. Creates a control-plane account with the given email and
        password and grants the requested roles. The password is write-only and
        is never returned. Requires the write:user permission.
      operationId: createUser
      tags: [Users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserRequest"
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "400":
          description: Invalid input (missing fields or unknown role)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: A user with this email already exists in the tenant
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/apiTokens:
    get:
      summary: List API tokens (Dexaflow)
      description: >-
        Lists the caller's unrevoked API tokens, newest first, expired ones
        included. A caller who may manage accounts (write:user) sees every
        token of the tenant. The token itself and its hash are never returned.
        Not callable with an API token or any other scoped token (ADR 0074).
      operationId: listAPITokens
      tags: [API Tokens]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of API tokens
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APITokenCollection" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The caller authenticated with a scoped token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    post:
      summary: Create an API token (Dexaflow)
      description: >-
        Creates a long-lived, named API token owned by the caller (ADR 0074).
        The response carries the token once; the server keeps only its
        SHA-256 hash. The token authenticates as its owner, with the owner's
        current roles narrowed to the token's scopes: deploy carries
        dexaflow:deploy alone (it registers versions, sets the current
        version, and reads the DAG list, DAGs, their details and versions,
        and nothing else), read carries dexaflow:read. The
        caller's roles must allow what the scope does (deploy needs deploy or
        write on dags, read needs read on dags). An account holds at most 50
        live tokens. Audited as api_token.create. Reusing the name of an
        expired token retires that token, audited as api_token.retire_expired.
        Not callable with an API token or any other scoped token.
      operationId: createAPIToken
      tags: [API Tokens]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateAPITokenRequest" }
      responses:
        "201":
          description: The token was created; this is the only time it is shown
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedAPIToken" }
        "400":
          description: Invalid name, scope or lifetime, or too many live tokens
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The caller's roles do not allow the scope, or the caller used a scoped token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: The caller already holds a live token with this name
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/apiTokens/{token_id}:
    parameters:
      - name: token_id
        in: path
        required: true
        schema: { type: string }
    delete:
      summary: Revoke an API token (Dexaflow)
      description: >-
        Revokes one of the caller's API tokens, or any token of the tenant for
        a caller who may manage accounts (write:user). The token stops working
        on its next request. Revoking a revoked token succeeds. Audited as
        api_token.revoke. Not callable with an API token or any other scoped
        token.
      operationId: revokeAPIToken
      tags: [API Tokens]
      responses:
        "204":
          $ref: "#/components/responses/NoContent"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The caller authenticated with a scoped token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v2/connections:
    get:
      summary: List connections
      description: |
        Lists the tenant's Airflow-style connections. Passwords are write-only and
        never returned; secret-bearing keys inside `extra` are masked server-side.
        Requires the read:connection permission.
      operationId: listConnections
      tags: [Connections]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of connections
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectionCollection" }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      summary: Create or replace a connection (upsert)
      description: |
        Upserts a connection: creates it, or replaces an existing one with the same
        connection_id. The password is write-only and never returned. Requires the
        write:connection permission and a configured encryption key.
      operationId: createConnection
      tags: [Connections]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConnectionBody" }
      responses:
        "201":
          description: Connection created or replaced
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Connection" }
        "400":
          description: Invalid input (missing connection_id or conn_type)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/EncryptionUnavailable"

  /api/v2/connections/{connection_id}:
    parameters:
      - $ref: "#/components/parameters/ConnectionID"
    get:
      summary: Get a connection
      operationId: getConnection
      tags: [Connections]
      responses:
        "200":
          description: Connection detail (password omitted, extra masked)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Connection" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      summary: Update a connection
      operationId: updateConnection
      tags: [Connections]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConnectionBody" }
      responses:
        "200":
          description: Updated connection
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Connection" }
        "400":
          description: Invalid input (missing conn_type)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          $ref: "#/components/responses/EncryptionUnavailable"
    delete:
      summary: Delete a connection
      operationId: deleteConnection
      tags: [Connections]
      responses:
        "204":
          $ref: "#/components/responses/NoContent"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v2/variables:
    get:
      summary: List variables
      description: |
        Lists the tenant's Airflow-style variables. Values of secret-ish keys are
        masked server-side. Requires the read:variable permission.
      operationId: listVariables
      tags: [Variables]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of variables
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VariableCollection" }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      summary: Create or replace a variable (upsert)
      description: |
        Upserts a variable: creates it, or replaces an existing one with the same
        key. Requires the write:variable permission.
      operationId: createVariable
      tags: [Variables]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VariableBody" }
      responses:
        "201":
          description: Variable created or replaced
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Variable" }
        "400":
          description: Invalid input (missing key)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v2/variables/{variable_key}:
    parameters:
      - $ref: "#/components/parameters/VariableKey"
    get:
      summary: Get a variable
      operationId: getVariable
      tags: [Variables]
      responses:
        "200":
          description: Variable detail (value masked when the key looks sensitive)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Variable" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      summary: Delete a variable
      operationId: deleteVariable
      tags: [Variables]
      responses:
        "204":
          $ref: "#/components/responses/NoContent"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v2/dags:
    get:
      summary: List DAGs
      operationId: listDags
      tags: [DAGs]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - name: tags
          in: query
          schema: { type: array, items: { type: string } }
          style: form
          explode: true
        - name: only_active
          in: query
          schema: { type: boolean, default: true }
        - name: paused
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: A page of DAGs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DAGCollection"

  /api/v2/dags/{dag_id}:
    parameters:
      - $ref: "#/components/parameters/DagID"
    get:
      summary: Get a DAG
      operationId: getDag
      tags: [DAGs]
      responses:
        "200":
          description: DAG detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DAG"
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      summary: Update DAG (typically pause/unpause)
      description: >-
        Sets is_paused. A change is audited as dag.pause or dag.unpause with
        the acting user; asking for the state the DAG is already in records
        nothing.
      operationId: updateDag
      tags: [DAGs]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DAGUpdate"
      responses:
        "200":
          description: Updated DAG
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DAG" }
        "400":
          description: The body does not set is_paused, the only field this route changes
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/dags/{dag_id}/dagVersions:
    parameters:
      - $ref: "#/components/parameters/DagID"
    get:
      summary: List a DAG's registered versions
      operationId: listDagVersions
      tags: [DAGs]
      responses:
        "200":
          description: A page of DAG versions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DagVersionCollection"

  /api/v2/dags/{dag_id}/dagVersions/{version_number}:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - name: version_number
        in: path
        required: true
        schema: { type: integer, minimum: 1 }
    get:
      summary: Get a specific registered DAG version
      operationId: getDagVersion
      tags: [DAGs]
      responses:
        "200":
          description: DAG version detail
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DagVersion" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dags/{dag_id}/versions:
    parameters:
      - $ref: "#/components/parameters/DagID"
    post:
      summary: Register a compiled DAG version (Dexaflow)
      description: >-
        Registers a compiled dag.json as a new version of the DAG and makes it
        current. Re-registering content that is already registered (same spec
        hash) is a no-op answered with 200 and created false, and leaves the
        current version as it is. The same label with other content is
        refused with 409. Needs deploy or write on dags (scope dexaflow:deploy).

        Without is_paused, a DAG the register creates starts paused per its
        is_paused_upon_creation, else the server's dags_are_paused_at_creation,
        and an existing DAG keeps its state. With is_paused, the DAG ends the
        register in that state whether it is created, gets a new version or
        already had this content; a change to an existing DAG is audited as
        dag.pause or dag.unpause in the same transaction. Pausing is a
        run-state write: sending is_paused also needs write on dags, and a
        scoped token needs dexaflow:run as well as dexaflow:deploy, else the
        register is refused with 403.

        Under the server setting deploy.require_digest, an image that is not
        pinned by a full digest (repository@sha256: and 64 hex characters)
        is registered with a warning in warnings (warn), or refused with 400
        (enforce). Off, the default, accepts any image.

        POST /api/v2/dags/{dag_id}/versions/dry_run runs the same checks and
        writes nothing.
      operationId: registerDagVersion
      tags: [DAGs]
      parameters:
        - name: is_paused
          in: query
          required: false
          description: >-
            Sets the DAG's is_paused for this register (true or false). Any
            other value is refused with 400. Sending it needs write on dags,
            and a scoped token also needs the dexaflow:run scope (403
            otherwise).
          schema: { type: boolean }
        - name: promoted_from
          in: query
          required: false
          description: >-
            Marks the register as a promotion (`dexaflow promote`): the name
            of the target the version was read from, recorded with
            promoted_from_server under promoted_from in the register's audit
            entry. It is the caller's claim, recorded as sent and not
            verified. A target name: lower case letters, digits, _ and -,
            starting with a letter or digit, at most 63 characters; anything
            else is refused with 400.
          schema: { type: string, pattern: "^[a-z0-9][a-z0-9_-]{0,62}$" }
        - name: promoted_from_server
          in: query
          required: false
          description: >-
            The URL of the control plane the version was promoted from, as
            the caller claims it (not verified): an absolute http or https
            URL without credentials and with no control or format character,
            at most 2048 bytes. It needs promoted_from (400 otherwise).
          schema: { type: string, maxLength: 2048 }
        - name: build_commit
          in: query
          required: false
          description: >-
            Where the version was built (#1702), with the other build_*
            parameters: the git commit of its sources, 7 to 64 lower case hex
            characters. The build_* parameters are metadata of the register,
            not part of dag.json: they change neither the spec, its hash nor
            the version label. They are stored with the version the register
            creates (build_info in DagVersion) and recorded in its audit
            entry; a register of content already registered keeps the build
            info of its first register. They are the caller's claim, recorded
            as sent and not verified. A value that is empty or breaks its rule
            is refused with 400.
          schema: { type: string, pattern: "^[0-9a-f]{7,64}$" }
        - name: build_repo
          in: query
          required: false
          description: >-
            The repository of the sources: an absolute http or https URL
            without credentials, query string or fragment, valid UTF-8 with no
            control or format character, at most 2048 bytes.
          schema: { type: string, maxLength: 2048 }
        - name: build_pipeline_url
          in: query
          required: false
          description: >-
            The CI pipeline run that built the version: an absolute http or
            https URL without credentials, query string or fragment, valid
            UTF-8 with no control or format character, at most 2048 bytes.
          schema: { type: string, maxLength: 2048 }
        - name: build_builder
          in: query
          required: false
          description: >-
            The builder that produced the image, at most 255 bytes, with no
            control or format character.
          schema: { type: string, maxLength: 255 }
        - name: build_base_image
          in: query
          required: false
          description: >-
            The base image the DAG image was built from (it carries the task
            agent), at most 512 bytes, with no space, control or format
            character.
          schema: { type: string, maxLength: 512 }
        - name: build_cli_version
          in: query
          required: false
          description: >-
            The version of the CLI that built and registered the version, at
            most 128 bytes, with no space, control or format character.
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DagSpec" }
      responses:
        "200":
          description: The content was already registered; nothing changed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VersionRegistration" }
        "201":
          description: A new version was registered and made current
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VersionRegistration" }
        "400":
          description: The spec is invalid or refused by the operator's policy
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The caller may not register (with a deploy audience set, only the trusted issuer's deploy-audience bearer may), or sent is_paused without write on dags or, with a scoped token, without dexaflow:run
          headers:
            Dexaflow-Deploy-Via:
              description: >-
                trusted-issuer when auth.trusted_issuer.deploy_audience is set
                and the caller is not the trusted issuer's bearer for that
                audience: deploys on this server go through the issuer.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: The version label is registered with other content
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/dags/{dag_id}/versions/dry_run:
    parameters:
      - $ref: "#/components/parameters/DagID"
    post:
      summary: Check the register of a compiled DAG version without registering it (Dexaflow)
      description: >-
        Runs every check POST /api/v2/dags/{dag_id}/versions runs for this
        spec, as the same caller in the same tenant (permission and scope,
        spec validation, the resource unit, source mode, the image, service
        account and field allowlists, the tenant's limits, the declared
        connections and variables, the version label, is_paused), and writes
        nothing, audit included. It answers 200 with what the register would
        do (created, dag_created, is_paused), dry_run true and the warnings it
        would raise, or the refusal the register would give. Every answer past
        the permission check carries the header Dexaflow-Dry-Run: true. A
        server that predates this route answers it with 404 and registers
        nothing. Needs deploy or write on dags (scope dexaflow:deploy).
      operationId: dryRunDagVersion
      tags: [DAGs]
      parameters:
        - name: is_paused
          in: query
          required: false
          description: >-
            The is_paused the register would be sent, checked as the register
            checks it (400 for a value other than true or false, 403 without
            write on dags or, for a scoped token, dexaflow:run).
          schema: { type: boolean }
        - name: promoted_from
          in: query
          required: false
          description: >-
            The promoted_from the register would be sent, checked as the
            register checks it (a target name; 400 otherwise).
          schema: { type: string, pattern: "^[a-z0-9][a-z0-9_-]{0,62}$" }
        - name: promoted_from_server
          in: query
          required: false
          description: >-
            The promoted_from_server the register would be sent, checked as
            the register checks it.
          schema: { type: string, maxLength: 2048 }
        - name: build_commit
          in: query
          required: false
          description: >-
            The build_commit the register would be sent, checked as the register
            checks it (400 otherwise).
          schema: { type: string, pattern: "^[0-9a-f]{7,64}$" }
        - name: build_repo
          in: query
          required: false
          description: >-
            The build_repo the register would be sent, checked as the register
            checks it (400 otherwise).
          schema: { type: string, maxLength: 2048 }
        - name: build_pipeline_url
          in: query
          required: false
          description: >-
            The build_pipeline_url the register would be sent, checked as the register
            checks it (400 otherwise).
          schema: { type: string, maxLength: 2048 }
        - name: build_builder
          in: query
          required: false
          description: >-
            The build_builder the register would be sent, checked as the register
            checks it (400 otherwise).
          schema: { type: string, maxLength: 255 }
        - name: build_base_image
          in: query
          required: false
          description: >-
            The build_base_image the register would be sent, checked as the register
            checks it (400 otherwise).
          schema: { type: string, maxLength: 512 }
        - name: build_cli_version
          in: query
          required: false
          description: >-
            The build_cli_version the register would be sent, checked as the register
            checks it (400 otherwise).
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DagSpec" }
      responses:
        "200":
          description: What the register would do; nothing was written
          headers:
            Dexaflow-Dry-Run:
              description: Always true on an answer of this route.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VersionRegistration" }
        "400":
          description: The register would refuse the spec as invalid or outside the operator's policy
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The caller may not register (with a deploy audience set, only the trusted issuer's deploy-audience bearer may), or the register would refuse is_paused or a tenant limit
          headers:
            Dexaflow-Deploy-Via:
              description: >-
                trusted-issuer when auth.trusted_issuer.deploy_audience is set
                and the caller is not the trusted issuer's bearer for that
                audience: deploys on this server go through the issuer.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: The version label is registered with other content
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/dags/{dag_id}/currentVersion:
    parameters:
      - $ref: "#/components/parameters/DagID"
    put:
      summary: Make a registered version current (Dexaflow)
      description: >-
        Makes a registered version of the DAG the one new runs are created
        with, by label or by version_number: a rollback, or a roll forward.
        The DAG's schedule and metadata become that version's, in the same
        transaction. Runs that already exist keep their version, and
        is_paused is never changed. The version must pass the checks a
        register of it would pass today. Naming the current version changes
        nothing (changed false). Audited as dag.version.set_current. Needs
        deploy or write on dags (scope dexaflow:deploy). Under the server
        setting deploy.require_digest, a version whose image is not pinned by
        a full sha256 digest is made current with a warning in warnings
        (warn), or refused with 400 (enforce).
      operationId: setDagCurrentVersion
      tags: [DAGs]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DagCurrentVersionRequest" }
      responses:
        "200":
          description: The version is current
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DagCurrentVersionChange" }
        "400":
          description: The body is invalid, or the version's spec is refused by the current checks
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The caller may not deploy (with a deploy audience set, only the trusted issuer's deploy-audience bearer may), or the version exceeds a tenant limit
          headers:
            Dexaflow-Deploy-Via:
              description: >-
                trusted-issuer when auth.trusted_issuer.deploy_audience is set
                and the caller is not the trusted issuer's bearer for that
                audience: deploys on this server go through the issuer.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dags/{dag_id}/dagRuns:
    parameters:
      - $ref: "#/components/parameters/DagID"
    get:
      summary: List DAG runs
      operationId: listDagRuns
      tags: [DAG Runs]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - $ref: "#/components/parameters/Cursor"
        - name: state
          in: query
          schema:
            type: array
            items: { type: string, enum: [queued, running, success, failed] }
          style: form
          explode: true
      responses:
        "200":
          description: A page of DAG runs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DAGRunCollection"
    post:
      summary: Trigger a DAG run
      operationId: triggerDagRun
      tags: [DAG Runs]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DAGRunCreate"
      responses:
        "200":
          description: Created DAG run
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DAGRun" }

  /api/v2/dags/{dag_id}/dagRuns/{dag_run_id}:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
    get:
      summary: Get a DAG run
      operationId: getDagRun
      tags: [DAG Runs]
      responses:
        "200":
          description: DAG run detail
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DAGRun" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/clear:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
    post:
      summary: Clear a whole DAG run (queue its task instances for re-run)
      description: >-
        The run page's Clear action. A dry run (the default) answers the task
        instances the clear would reset. The clear itself resets them,
        re-queues the run and answers the run.
      operationId: clearDagRun
      tags: [DAG Runs]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DAGRunClearRequest" }
      responses:
        "200":
          description: >-
            With dry_run (the default), the task instances the clear would
            reset; otherwise the cleared run. Either carries
            run_on_latest_version_not_applied when run_on_latest_version was
            asked and the run keeps its version.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/TaskInstanceCollection"
                  - $ref: "#/components/schemas/DAGRun"
        "400":
          description: The body is not valid JSON, or only_new is true
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: The caller may not change task instances
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >-
            Not answered since ADR 0086, which replaced the refusal of a clear
            onto a version with other tasks by the full re-bind. Kept declared
            because Airflow declares it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
    get:
      summary: List task instances of a DAG run
      operationId: listTaskInstances
      tags: [Task Instances]
      description: >-
        Pages with the same limit, offset and cursor query parameters as the
        DAG run list (they are not declared on this operation so the
        generated client keeps its signature). A request that carries cursor
        gets next_cursor and previous_cursor in the body; only the offset
        cursors this list issues are accepted, any other is refused with 400.
      responses:
        "200":
          description: A page of task instances
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskInstanceCollection"

  /api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
      - $ref: "#/components/parameters/TaskID"
    get:
      summary: Get a task instance
      operationId: getTaskInstance
      tags: [Task Instances]
      responses:
        "200":
          description: Task instance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaskInstance" }
    patch:
      summary: Mark a task instance as success, failed or skipped
      description: >-
        Sets the task instance's state, as the UI's Mark as action does. The
        answer is the affected set, a collection holding the updated task
        instance, as Apache Airflow declares it.
      operationId: patchTaskInstance
      tags: [Task Instances]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TaskInstanceStateUpdate" }
      responses:
        "200":
          description: The updated task instance, as a one-entry collection
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaskInstanceCollection" }
        "400":
          description: new_state is not success, failed or skipped
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/dry_run:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
      - $ref: "#/components/parameters/TaskID"
    patch:
      summary: Preview marking a task instance
      description: >-
        Answers the task instance with the state the PATCH would set, without
        changing anything.
      operationId: patchTaskInstanceDryRun
      tags: [Task Instances]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TaskInstanceStateUpdate" }
      responses:
        "200":
          description: The task instance as the PATCH would leave it, as a one-entry collection
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaskInstanceCollection" }
        "400":
          description: new_state is not success, failed or skipped
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/logs/{try_number}:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
      - $ref: "#/components/parameters/TaskID"
      - name: try_number
        in: path
        required: true
        schema: { type: integer, minimum: 1 }
    get:
      summary: Get task logs
      operationId: getTaskLogs
      tags: [Task Instances]
      responses:
        "200":
          description: Log content
          content:
            text/plain:
              schema: { type: string }

  /api/v2/dags/{dag_id}/clearTaskInstances:
    parameters:
      - $ref: "#/components/parameters/DagID"
    post:
      summary: Clear task instances (queue them for re-run)
      operationId: clearTaskInstances
      tags: [Task Instances]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClearTaskInstancesRequest"
      responses:
        "200":
          description: >-
            The task instances cleared, or with dry_run (the default) the ones
            the clear would reset. Carries run_on_latest_version_not_applied
            when run_on_latest_version was asked and a run keeps its version.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskInstanceCollection"
        "409":
          description: >-
            Not answered since ADR 0086, which replaced the refusal of a clear
            onto a version with other tasks by the full re-bind. Kept declared
            because Airflow declares it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/xcoms/{dag_id}/{dag_run_id}/{task_id}/{key}:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
      - $ref: "#/components/parameters/TaskID"
      - name: key
        in: path
        required: true
        schema: { type: string }
    get:
      summary: Read XCom value (read-only proxy for the Redis backend)
      operationId: getXcomEntry
      tags: [XCom]
      responses:
        "200":
          description: XCom value
          content:
            application/json:
              schema: { $ref: "#/components/schemas/XComEntry" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dagSources/{dag_id}:
    parameters:
      - $ref: "#/components/parameters/DagID"
    get:
      summary: Get a DAG's source (the dag.py text)
      description: >-
        The source registered with one version of the DAG, as in Airflow 3.2:
        version_number selects the version, and without it (or with 0) the
        newest version is served. After a rollback the newest version is not
        the current one. A version the DAG does not have is a 404, and a
        version_number that is not an integer is a 400.
      operationId: getDagSource
      tags: [DAGs]
      parameters:
        - name: version_number
          in: query
          required: false
          description: The version_number of a version from the dagVersions listing; 0 is the newest.
          schema: { type: integer }
      responses:
        "200":
          description: DAG source
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DagSource" }
        "400":
          description: version_number is not an integer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dags/{dag_id}/spec:
    parameters:
      - $ref: "#/components/parameters/DagID"
    get:
      summary: Get a DAG's compiled spec (the dag.json artifact)
      operationId: getDagSpec
      tags: [DAGs]
      responses:
        "200":
          description: The compiled dag.json (the structured graph the scheduler runs)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: The compiled dag.json artifact (DAGSpec shape).
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dags/{dag_id}/dagVersions/{version_number}/spec:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - name: version_number
        in: path
        required: true
        description: The version_number of a version from the dagVersions listing.
        schema: { type: integer }
    get:
      summary: Get the compiled spec registered with one version of a DAG (Dexaflow)
      description: >-
        The dag.json registered with this version, current or not, as GET
        /api/v2/dags/{dag_id}/spec serves the current one. `dexaflow
        promote` reads it to register the same version on another control
        plane. The spec holds the DAG's source, environment literals and
        params, so it needs read on dags, and a scoped token dexaflow:read
        (dexaflow:deploy alone is refused).
      operationId: getDagVersionSpec
      tags: [DAGs]
      responses:
        "200":
          description: The compiled dag.json of the version
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: The compiled dag.json artifact (DAGSpec shape).
        "400":
          description: version_number is not an integer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/monitor/health:
    get:
      summary: Control-plane health (Airflow HealthInfoResponse shape)
      operationId: getMonitorHealth
      tags: [Monitor]
      responses:
        "200":
          description: Component health
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthInfo" }

  /api/v2/monitor/executor:
    get:
      summary: Executor capability and configuration
      operationId: getMonitorExecutor
      tags: [Monitor]
      responses:
        "200":
          description: Executor info
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExecutorInfo" }

  /api/v2/deployDefaults:
    get:
      summary: Cluster deploy defaults (Dexaflow)
      description: >-
        The registry prefix, build platforms, base image and remote builder
        the operator set in the server's deploy.defaults, the source mode
        runtime image when deploy.defaults.source_mode advertises it, and the
        task service account and warm pools when their switches advertise
        them. dexaflow deploy applies the build values to whatever the
        project, its workspace, its target and the CLI config leave unset,
        and reports the task service account and warm pools. The values are the cluster's, the same for every tenant,
        and none is a secret, except ci_oidc, the CI OIDC issuers of the
        caller's tenant. Every field is always present; an unset one is
        empty. A server without this route answers 404, and the CLI then
        deploys with its local settings only. Needs read on dags (scope
        dexaflow:read or dexaflow:deploy).
      operationId: getDeployDefaults
      tags: [DAGs]
      responses:
        "200":
          description: The cluster's deploy defaults
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeployDefaults" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The caller may not read DAGs
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/version:
    get:
      summary: Control-plane version (Airflow VersionInfo shape)
      operationId: getVersion
      tags: [Monitor]
      responses:
        "200":
          description: Version info
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VersionInfo" }

  /healthz:
    get:
      summary: Liveness probe
      operationId: getHealthz
      tags: [Health]
      security: []
      responses:
        "200":
          description: OK
          content: { text/plain: { schema: { type: string } } }

  /readyz:
    get:
      summary: Readiness probe
      operationId: getReadyz
      tags: [Health]
      security: []
      responses:
        "200":
          description: OK
        "503":
          description: Not ready

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
    Offset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, default: 0 }
    Cursor:
      name: cursor
      in: query
      description: >-
        Opaque cursor, as Apache Airflow 3.3 pages its lists. Send it empty
        for the first page, then the next_cursor or previous_cursor the
        previous body named. Whenever the request carries cursor, the body
        names next_cursor (absent on the last page) and previous_cursor
        (absent on the first page); a request without cursor gets the body
        without those fields. A cursor that is not one this server issued is
        refused with 400. Dexaflow also accepts the keyset cursor of the
        Dexaflow-Next-Cursor response header on the DAG run list: the page
        then holds the runs after the cursor in the same order, at the cost of
        the first page whatever the depth. With a state filter, offset paging
        filters the newest 10000 runs and counts total_entries within them,
        while keyset paging filters and counts every run of the DAG, so the
        two modes can report different totals for a DAG with more runs than
        that.
      schema: { type: string }
    DagID:
      name: dag_id
      in: path
      required: true
      schema: { type: string }
    DagRunID:
      name: dag_run_id
      in: path
      required: true
      schema: { type: string }
    TaskID:
      name: task_id
      in: path
      required: true
      schema: { type: string }
    ConnectionID:
      name: connection_id
      in: path
      required: true
      schema: { type: string }
    VariableKey:
      name: variable_key
      in: path
      required: true
      schema: { type: string }

  responses:
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NoContent:
      description: Deleted (no content)
    AuthUnavailable:
      description: >-
        The credential could not be checked because a store or key source the
        control plane needs is unreachable. It says nothing about the
        credential, so the client should not sign in again: it waits the
        Retry-After and retries.
      headers:
        Retry-After:
          description: Seconds to wait before retrying. A small fixed value.
          schema:
            type: integer
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Error" }
    EncryptionUnavailable:
      description: >-
        No encryption key configured, so secrets cannot be stored. Set
        LEOFLOW_SECRET_KEY on the control plane to manage connections.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      properties:
        type: { type: string }
        title: { type: string }
        detail: { type: string }
        status: { type: integer }
        instance: { type: string }

    TokenRequest:
      type: object
      required: [username, password]
      properties:
        username: { type: string }
        password: { type: string }

    TokenResponse:
      type: object
      properties:
        access_token: { type: string }
        token_type: { type: string, example: "bearer" }
        expires_in: { type: integer, example: 3600 }

    AuthMethods:
      type: object
      required: [password, sso, device]
      properties:
        password: { type: boolean }
        sso: { type: boolean }
        device: { type: boolean, description: Whether the device sign-in of the CLI is served }

    CLITokenRequest:
      type: object
      description: >-
        A one-time code exchange (code, code_verifier and redirect_uri, with
        grant_type absent or authorization_code), or a device poll
        (grant_type urn:ietf:params:oauth:grant-type:device_code and
        device_code).
      properties:
        grant_type:
          type: string
          description: Absent or authorization_code for a code exchange; the device grant for a poll
        code: { type: string, description: The one-time code the loopback received }
        code_verifier: { type: string, description: The PKCE verifier of the login's code_challenge }
        redirect_uri: { type: string, description: The redirect_uri the login was started with }
        device_code: { type: string, description: The device_code of a device sign-in }

    CLITokenError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            error:
              type: string
              description: The OAuth error code a client acts on
              enum: [invalid_grant, unsupported_grant_type, authorization_pending, slow_down, access_denied, expired_token]

    CLIDeviceAuthorization:
      type: object
      required: [device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval]
      properties:
        device_code: { type: string, description: The secret the CLI polls with; never shown }
        user_code: { type: string, example: "BCDF-GHJK", description: The code the person enters and confirms }
        verification_uri: { type: string, description: The page the person enters the code on }
        verification_uri_complete: { type: string, description: That page with the code filled in }
        expires_in: { type: integer, example: 600 }
        interval: { type: integer, example: 5, description: Minimum seconds between polls }

    CreateUserRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          description: >-
            Login email. Normalized to lowercase, so it is unique
            case-insensitively within the tenant.
        password:
          type: string
          format: password
          writeOnly: true
          minLength: 8
          description: >-
            Plaintext password (write-only; never returned). Must be at least 8
            characters — the server rejects anything shorter with 400.
        roles:
          type: array
          items: { type: string }
          description: >-
            Names of existing roles to grant; omit or empty to grant none.

    User:
      type: object
      required: [id, email, roles, is_active, created_at]
      properties:
        id: { type: string }
        email: { type: string }
        roles:
          type: array
          items: { type: string }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }

    UserListItem:
      type: object
      required: [id, email, roles, is_active, created_at]
      description: >-
        One account in the user list. Dexaflow accounts are email-keyed and carry
        a set of RBAC roles, so this diverges from the Airflow FAB users API
        (username-keyed with first_name/last_name).
      properties:
        id: { type: string }
        email: { type: string }
        roles:
          type: array
          items: { type: string }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }

    UserCollection:
      type: object
      properties:
        users:
          type: array
          items: { $ref: "#/components/schemas/UserListItem" }
        total_entries: { type: integer }

    CreateAPITokenRequest:
      type: object
      required: [name, scope]
      additionalProperties: false
      properties:
        name:
          type: string
          pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
          description: >-
            A name for the token, unique among the caller's live tokens: 1 to
            64 letters, digits, dots, dashes or underscores, starting with a
            letter or digit.
        scope:
          type: string
          enum: [deploy, read]
          description: >-
            deploy registers versions, sets the current version and reads
            DAGs; read only reads.
        expires_in_seconds:
          type: integer
          format: int64
          minimum: 3600
          maximum: 31536000
          description: Lifetime of the token. Defaults to 7776000 (90 days).

    APIToken:
      type: object
      required: [id, name, scopes, created_at, expires_at]
      properties:
        id: { type: string }
        name: { type: string }
        scopes:
          type: array
          items: { type: string }
        owner_email:
          type: string
          description: Email of the token's owner (in lists).
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: >-
            When the token last authenticated a request, recorded at most once
            every five minutes; null when it was never used.

    CreatedAPIToken:
      allOf:
        - $ref: "#/components/schemas/APIToken"
        - type: object
          required: [token]
          properties:
            token:
              type: string
              description: >-
                The token, starting with dxf_. It is shown only in this
                response and cannot be read again.

    APITokenCollection:
      type: object
      properties:
        api_tokens:
          type: array
          items: { $ref: "#/components/schemas/APIToken" }
        total_entries: { type: integer }

    Connection:
      type: object
      required: [connection_id, conn_type]
      description: >-
        An Airflow-style connection. The password is write-only and never
        returned; secret-bearing keys inside `extra` are masked server-side.
      properties:
        connection_id: { type: string }
        conn_type: { type: string }
        description: { type: string, nullable: true }
        host: { type: string, nullable: true }
        login: { type: string, nullable: true }
        schema: { type: string, nullable: true }
        port: { type: integer, nullable: true }
        extra: { type: string, nullable: true }
        extra_unreadable:
          type: boolean
          description: >-
            Present and true in a listing when the extra is encrypted with a
            secret key the server does not have (for example after a key
            rotation that dropped the old key). The extra is then null, so a
            client that parses it as JSON still can. Reading the connection
            alone, or updating it, answers 500 with a message naming it, and the
            stored extra is kept.

    ConnectionBody:
      type: object
      required: [conn_type]
      description: >-
        Connection create/replace payload. connection_id is required on POST
        (taken from the path on PATCH). password and extra are write-only.
        Every optional field is tri-state: omit the key to preserve the stored
        value (a partial write never wipes a field it does not mention, so the
        unreadable password survives a `--host`-only edit), send an empty string
        to clear the field, or send a value to set it. A password equal to the
        mask `***`, and any key inside `extra` whose value is exactly `***`, is
        treated as "unchanged" — so re-submitting a connection read back from GET
        (whose secrets are masked) never overwrites the real secret with the mask.
      properties:
        connection_id: { type: string }
        conn_type: { type: string }
        description: { type: string }
        host: { type: string }
        login: { type: string }
        password:
          type: string
          format: password
          writeOnly: true
        schema: { type: string }
        port: { type: integer, nullable: true }
        extra: { type: string }

    ConnectionCollection:
      type: object
      properties:
        connections:
          type: array
          items: { $ref: "#/components/schemas/Connection" }
        total_entries: { type: integer }

    Variable:
      type: object
      required: [key, value, is_encrypted]
      description: >-
        An Airflow-style variable. The value is masked server-side when the key
        looks sensitive (secret/password/token/...).
      properties:
        key: { type: string }
        value: { type: string }
        description: { type: string, nullable: true }
        is_encrypted: { type: boolean }
        team_name: { type: string, nullable: true }
        value_unreadable:
          type: boolean
          description: >-
            Present and true in a listing when the value is encrypted with a
            secret key the server does not have (for example after a key
            rotation that dropped the old key). The value is then masked.
            Reading the variable alone answers 500 with a message naming it.

    VariableBody:
      type: object
      required: [key]
      description: >-
        Variable create/replace payload. value and description are tri-state:
        omit the key to preserve the stored value, send an empty string to clear
        it, or send a value to set it. A value equal to the mask `***` for a
        sensitive-looking key (secret/password/token/...) is treated as
        "unchanged", so re-submitting a variable read back from GET (whose
        sensitive value is masked) never overwrites the real value with the mask.
      properties:
        key: { type: string }
        value: { type: string }
        description: { type: string }

    VariableCollection:
      type: object
      properties:
        variables:
          type: array
          items: { $ref: "#/components/schemas/Variable" }
        total_entries: { type: integer }

    DAG:
      type: object
      properties:
        dag_id: { type: string }
        dag_display_name: { type: string }
        description: { type: string }
        is_paused: { type: boolean }
        is_active: { type: boolean }
        owners: { type: array, items: { type: string } }
        tags:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
        schedule_interval:
          type: object
          nullable: true
        timetable_description: { type: string }
        next_dagrun: { type: string, format: date-time, nullable: true }
        last_parsed_time: { type: string, format: date-time, nullable: true }
        max_active_runs: { type: integer }
        catchup: { type: boolean }
        has_task_concurrency_limits: { type: boolean }
        fileloc: { type: string }

    DAGCollection:
      type: object
      properties:
        dags:
          type: array
          items: { $ref: "#/components/schemas/DAG" }
        total_entries: { type: integer }

    DagVersion:
      type: object
      properties:
        id: { type: string }
        version_number: { type: integer }
        dag_id: { type: string }
        dag_display_name: { type: string }
        bundle_name: { type: string }
        bundle_version: { type: string, nullable: true }
        bundle_url: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        image:
          type: string
          description: Dexaflow. The image the version runs, pinned by digest when the deploy resolved one.
        spec_hash:
          type: string
          description: Dexaflow. The canonical hash of the version's dag.json.
        is_current:
          type: boolean
          description: Dexaflow. Whether new runs are created with this version.
        created_by:
          type: string
          nullable: true
          description: Dexaflow. Id of the user who registered the version (not an email, since every reader of the DAG sees it); null when none was recorded.
        build_info:
          description: Dexaflow. Where the version was built, as its register sent it in the build_* parameters; null when it sent none. Served by the dagVersions routes and the currentVersion answer; the dag_version copies embedded in task instance, run and DAG details answers leave it out.
          type: object
          nullable: true
          allOf: [{ $ref: "#/components/schemas/BuildInfo" }]

    BuildInfo:
      type: object
      description: >-
        Dexaflow. Where a DAG version was built (#1702), as the register
        claimed it, not verified. Every field is optional and absent when it
        was not sent.
      properties:
        commit: { type: string, description: The git commit of the sources. }
        repo: { type: string, description: The repository of the sources (http or https URL). }
        pipeline_url: { type: string, description: The CI pipeline run that built the version (http or https URL). }
        builder: { type: string, description: The builder that produced the image. }
        base_image: { type: string, description: The base image the DAG image was built from; it carries the task agent. }
        cli_version: { type: string, description: The version of the CLI that built and registered the version. }

    DagCurrentVersionRequest:
      type: object
      description: Exactly one of version and version_number.
      properties:
        version: { type: string, minLength: 1, maxLength: 255, description: The version label (bundle_version). }
        version_number: { type: integer, minimum: 1 }

    DagCurrentVersionChange:
      type: object
      required: [dag_id, changed, from, to]
      properties:
        dag_id: { type: string }
        changed: { type: boolean, description: False when the version was already current. }
        from:
          description: The version that was current before; null when none was.
          type: object
          nullable: true
          allOf: [{ $ref: "#/components/schemas/DagVersion" }]
        to: { $ref: "#/components/schemas/DagVersion" }
        warnings:
          type: array
          items: { type: string }
          description: What the change let through with a warning (a task that does not fit its size under executor.unit.enforce=warn, an image not pinned by digest under deploy.require_digest=warn). Absent when there is none.

    DagSpec:
      type: object
      description: A compiled dag.json, as described by docs/api/dag-schema.json.
      additionalProperties: true
      required: [schema_version, dag_id, dag_version, image, tasks]
      properties:
        schema_version: { type: string }
        dag_id: { type: string }
        dag_version: { type: string }
        image: { type: string }
        tasks: { type: array, items: { type: object, additionalProperties: true } }

    VersionRegistration:
      type: object
      properties:
        dag_id: { type: string }
        version: { type: string }
        spec_hash: { type: string }
        created: { type: boolean, description: False when the content was already registered. }
        dag_created: { type: boolean, description: True when this register created the DAG (its first version); false for a new version of an existing DAG. }
        is_paused: { type: boolean, description: The DAG's is_paused after the register. }
        dry_run: { type: boolean, description: "True on an answer of the dry run route: nothing was written, and the other fields say what the register would do. Absent from a register." }
        warnings:
          type: array
          items: { type: string }
          description: What the register lets through with a warning (a task that does not fit its size under executor.unit.enforce=warn, an image not pinned by digest under deploy.require_digest=warn). Always sent by the dry run route; sent by a register only when it has a warning to give.
        execution: { $ref: "#/components/schemas/ExecutionReport" }
        task_pods: { $ref: "#/components/schemas/TaskPodsReport" }

    TaskPodsReport:
      type: object
      description: >-
        What the version's task pods would run as (Dexaflow), sent by the dry
        run route only, absent from a register. A server that starts no task
        pods sends run_as_non_root false and no service account.
      required: [run_as_non_root, service_accounts]
      properties:
        run_as_non_root:
          type: boolean
          description: >-
            True when task pods refuse an image that runs as root or as a user
            name rather than a number (executor.run_tasks_as_non_root).
        service_accounts:
          type: array
          description: >-
            Each ServiceAccount a task pod of the version would run as (the
            task's execution.service_account, else executor.task_service_account,
            else the namespace's default), sorted by name. A dry run looks up at
            most 8.
          items: { $ref: "#/components/schemas/TaskServiceAccountCheck" }

    TaskServiceAccountCheck:
      type: object
      required: [name, tasks, state]
      properties:
        name: { type: string }
        tasks: { type: array, items: { type: string }, description: The tasks whose pods run as it. }
        state:
          type: string
          description: >-
            One of found, missing or unchecked. found: it exists in the task namespace. missing: it does not, and
            the pods of these tasks would not be created; when its name is not a valid ServiceAccount name (a
            DNS-1123 subdomain), it is not looked up and detail says so. unchecked: the server
            could not look it up (detail says why).
        image_pull_secrets: { type: integer, description: "How many imagePullSecrets it carries; sent with state found." }
        detail: { type: string, description: "Why it is unchecked, or why a missing name is not valid." }

    ExecutionReport:
      type: object
      description: >-
        Where the version's tasks run (Dexaflow), from the same decision
        dispatch places an attempt with: on a warm worker of the version
        (warm pools, ADR 0058) or on a dedicated pod. Sent by the register and
        its dry run. A task is warm-eligible; it still takes a dedicated pod
        when no warm worker is free. A task the dispatcher would offer to a
        warm worker is reported dedicated when no warm worker of its version
        can exist (no idle worker kept, or a server that runs none).
      required: [warm_pools, tasks, pool]
      properties:
        warm_pools: { type: boolean, description: "execution.warm_pools_enabled on the server." }
        tasks:
          type: array
          items: { $ref: "#/components/schemas/TaskExecution" }
        pool: { $ref: "#/components/schemas/WarmPoolReport" }

    TaskExecution:
      type: object
      required: [task_id, mode]
      properties:
        task_id: { type: string }
        mode:
          type: string
          enum: [warm, dedicated]
          x-enum-varnames: [TaskExecutionModeWarm, TaskExecutionModeDedicated]
        reason:
          type: string
          description: >-
            Why the task takes a dedicated pod, absent when warm-eligible: warm
            pools off, staging enabled, source mode, service_account set, a
            placement field set (node_selector set, tolerations set, ...), a
            size larger than a warm pod, no idle warm workers kept for this
            DAG, or a server that runs no warm workers.

    WarmPoolReport:
      type: object
      required: [state, detail]
      properties:
        state:
          type: string
          enum: ["off", none, "on"]
          description: >-
            off: warm pools are off. none: this version gets no warm worker
            (staging, source mode, no idle worker kept, or a server that runs
            no warm workers: the subprocess executor, or pod dispatch
            disabled). on: the version
            keeps min_idle_workers idle warm workers, up to max_pool_size,
            while it is the DAG's current version; for a DAG that declares
            min_idle_workers they start at the next reconcile (when a paused
            DAG is unpaused), else with the version's first run.
        detail: { type: string, description: The state in a sentence for a person. }
        min_idle_workers: { type: integer }
        max_pool_size: { type: integer }
        previous_pool:
          type: string
          enum: [draining]
          x-enum-varnames: [WarmPoolReportPreviousPoolDraining]
          description: draining for a new version of an existing DAG when this server keeps warm workers; the previous version's idle warm workers, if it has any, are removed, and each busy one once its attempt ends.

    DagVersionCollection:
      type: object
      properties:
        dag_versions:
          type: array
          items: { $ref: "#/components/schemas/DagVersion" }
        total_entries: { type: integer }

    DagSource:
      type: object
      properties:
        content: { type: string, description: "The dag.py source text (compiled-spec JSON fallback for pre-source-capture versions)." }
        dag_id: { type: string }
        version_number: { type: integer, description: "The version whose source this is." }
        dag_display_name: { type: string }

    ComponentHealth:
      type: object
      properties:
        status: { type: string }
        latest_scheduler_heartbeat: { type: string, nullable: true }
        latest_triggerer_heartbeat: { type: string, nullable: true }
        latest_dag_processor_heartbeat: { type: string, nullable: true }

    HealthInfo:
      type: object
      properties:
        metadatabase: { $ref: "#/components/schemas/ComponentHealth" }
        scheduler: { $ref: "#/components/schemas/ComponentHealth" }
        triggerer: { $ref: "#/components/schemas/ComponentHealth" }
        dag_processor: { $ref: "#/components/schemas/ComponentHealth" }

    ExecutorInfo:
      type: object
      properties:
        pod_dispatch_enabled: { type: boolean }
        task_namespace: { type: string }
        agent_control_plane_addr: { type: string }
        execution_modes: { type: array, items: { type: string } }

    DeployDefaults:
      type: object
      required: [registry, platforms, base_image]
      properties:
        registry:
          type: string
          description: Image prefix a deploy pushes to, such as ghcr.io/acme; empty when unset.
        platforms:
          type: array
          items: { type: string }
          description: Platforms a deploy builds for (os/arch or os/arch/variant); empty when unset.
        base_image:
          type: string
          description: Base image for projects that set none; empty when unset.
        source_mode_image:
          type: string
          description: >-
            Runtime image, pinned by digest, a deploy registers a DAG's dag.py on
            with no build (source mode, ADR 0067), for deploys that name none of
            their own. It is the server's execution.source_mode.image, served
            only when deploy.defaults.source_mode is on; empty otherwise. Absent
            from servers that predate it.
        remote_builder:
          type: object
          nullable: true
          description: >-
            Remote builder (ADR 0083) a deploy uses for builds no daemonless
            path covers, for projects, targets and workspaces that name none:
            the server's deploy.defaults.remote_builder, with one backend.
            Null when unset.
            Absent from servers that predate it.
          properties:
            cloud_build:
              type: object
              properties:
                project: { type: string, description: Google Cloud project the build runs in. }
                region: { type: string, description: Cloud Build region; absent when unset. }
                worker_pool: { type: string, description: "Private pool, projects/<project>/locations/<region>/workerPools/<name>; absent when unset." }
                service_account: { type: string, description: "Account the build runs as, projects/<project>/serviceAccounts/<email>; absent when unset." }
            buildkit:
              type: object
              description: >-
                The in-cluster BuildKit builder the Helm chart runs
                (builder.enabled, #1883), which a deploy reaches with buildctl
                over kubectl exec. Absent from servers that predate it.
              properties:
                namespace: { type: string, description: Namespace of the builder. }
                pod_selector: { type: string, description: "Label selector of the builder pod, key=value pairs separated by commas; absent when unset." }
        task_service_account:
          type: string
          description: >-
            ServiceAccount task pods run as when a task sets no
            execution.service_account: the server's
            executor.task_service_account, served only when
            deploy.defaults.task_service_account is on; empty otherwise. A
            deploy reports it and never writes it into dag.json. Absent from
            servers that predate it.
        warm_pools:
          type: object
          nullable: true
          description: >-
            The server's warm pool settings (ADR 0058), served only when
            deploy.defaults.warm_pools is on; null otherwise. A DAG that
            declares no min_idle_workers gets min_idle_workers, every value
            is capped at max_pool_size, and nothing is kept warm while
            enabled is false. Absent from servers that predate it.
          required: [enabled, min_idle_workers, max_pool_size]
          properties:
            enabled: { type: boolean, description: execution.warm_pools_enabled. }
            min_idle_workers: { type: integer, description: execution.min_idle_workers. }
            max_pool_size: { type: integer, description: execution.max_pool_size; 0 sets no cap. }
        ci_oidc:
          type: object
          nullable: true
          description: >-
            The CI OIDC issuers (auth.ci_oidc, ADR 0092) of the caller's
            tenant, which dexaflow ci init reads to write a pipeline that
            deploys with its provider's token instead of a stored deploy
            token; null when the tenant has none. Unlike the other fields it
            depends on the caller. Absent from servers that predate it.
          required: [issuers]
          properties:
            issuers:
              type: array
              items: { $ref: "#/components/schemas/CIOIDCIssuer" }
    CIOIDCIssuer:
      type: object
      required: [name, provider, issuer, audience]
      properties:
        name: { type: string, description: The entry's name in auth.ci_oidc.issuers. }
        provider: { type: string, enum: [github, gitlab, cloudbuild, other], description: The CI provider. }
        issuer: { type: string, description: The exact iss of the provider's tokens. }
        audience: { type: string, description: The aud a pipeline asks its token for. }
    CITokenRequest:
      type: object
      required: [token]
      additionalProperties: false
      properties:
        token: { type: string, description: The OIDC token the CI provider minted for the job. }
    CITokenResponse:
      type: object
      required: [access_token, token_type, expires_in, expires_at, issuer, tenant, role, scopes]
      properties:
        access_token: { type: string, description: "The session token (dxci_...), for the Authorization header." }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer, description: Seconds the session lives (auth.ci_oidc.session_ttl_seconds). }
        expires_at: { type: string, format: date-time }
        issuer: { type: string, description: The issuer entry that verified the token. }
        tenant: { type: string, description: "The entry's tenant, which the session acts in." }
        role: { type: string, description: The role the matching rule names. }
        scopes:
          type: array
          items: { type: string }
          description: The scopes the session is narrowed to.

    VersionInfo:
      type: object
      properties:
        version: { type: string }
        git_version: { type: string }

    DAGUpdate:
      type: object
      description: >-
        is_paused must be present; a body without it is refused with 400. It
        is not listed under required so the generated Go client keeps the
        field a pointer.
      properties:
        is_paused: { type: boolean }

    DAGRun:
      type: object
      properties:
        run_on_latest_version_not_applied: { $ref: "#/components/schemas/ClearVersionNotApplied" }
        dag_id: { type: string }
        dag_run_id: { type: string }
        logical_date: { type: string, format: date-time }
        data_interval_start: { type: string, format: date-time, nullable: true }
        data_interval_end: { type: string, format: date-time, nullable: true }
        queued_at: { type: string, format: date-time }
        start_date: { type: string, format: date-time, nullable: true }
        end_date: { type: string, format: date-time, nullable: true }
        state:
          type: string
          enum: [queued, running, success, failed]
        run_type:
          type: string
          enum: [scheduled, manual, backfill, dataset_triggered]
        conf: { type: object, additionalProperties: true }
        note: { type: string, nullable: true }

    DAGRunCollection:
      type: object
      properties:
        dag_runs:
          type: array
          items: { $ref: "#/components/schemas/DAGRun" }
        total_entries: { type: integer }
        next_cursor:
          type: string
          description: >-
            Cursor of the next page. Present only when the request carried
            cursor and a next page exists.
        previous_cursor:
          type: string
          description: >-
            Cursor of the previous page. Present only when the request carried
            cursor and the page is not the first.

    DAGRunCreate:
      type: object
      properties:
        dag_run_id: { type: string }
        logical_date: { type: string, format: date-time }
        conf: { type: object, additionalProperties: true }
        note: { type: string }

    TaskInstance:
      type: object
      properties:
        dag_id: { type: string }
        dag_run_id: { type: string }
        task_id: { type: string }
        map_index: { type: integer, default: -1 }
        try_number: { type: integer }
        state:
          type: string
          nullable: true
          enum: [scheduled, queued, running, success, failed, skipped, upstream_failed, up_for_retry, removed, none]
        operator: { type: string }
        start_date: { type: string, format: date-time, nullable: true }
        end_date: { type: string, format: date-time, nullable: true }
        duration: { type: number, nullable: true }
        hostname: { type: string }
        pool: { type: string }
        max_tries: { type: integer }
        failure_reason:
          type: string
          nullable: true
          description: >-
            Dexaflow extension (not part of the Airflow API). A short,
            human-readable cause for a terminal failure, recorded by whichever
            component observed it: the task's own report, the reconciler reading
            the pod (image pull, OOM, exit code), a reaper declaring the pod or
            agent lost, or the agent's classification of a failure that happened
            before it could register. It answers "why did this fail?" for an
            attempt that streamed no logs because its agent never started. Null
            when no cause was observed. Best-effort and diagnostic: it carries a
            classification, never a credential or a raw internal error.
          example: >-
            the control plane rejected this pod's projected ServiceAccount
            token; check the control plane's RBAC for tokenreviews and the
            configured token audience.

    ClearVersionNotApplied:
      type: array
      description: >-
        Dexaflow-only. The runs a clear with run_on_latest_version keeps on
        their own version, each with the reason (ADR 0086): only a finished
        run with no task instance queued or running moves to the current
        version. The clear still applies to these runs, on their version.
        Left out when every run moved or the option was not asked, and on
        every response other than the two clear routes.
      items:
        type: object
        required: [dag_run_id, reason]
        properties:
          dag_run_id: { type: string }
          reason:
            type: string
            description: >-
              Why the run keeps its version, for example "the run is running:
              only a finished run moves to the current version".

    TaskInstanceCollection:
      type: object
      properties:
        run_on_latest_version_not_applied: { $ref: "#/components/schemas/ClearVersionNotApplied" }
        task_instances:
          type: array
          items: { $ref: "#/components/schemas/TaskInstance" }
        total_entries: { type: integer }
        next_cursor:
          type: string
          description: >-
            Cursor of the next page. Present only when the request carried
            cursor and a next page exists.
        previous_cursor:
          type: string
          description: >-
            Cursor of the previous page. Present only when the request carried
            cursor and the page is not the first.

    TaskInstanceStateUpdate:
      type: object
      required: [new_state]
      properties:
        new_state:
          type: string
          enum: [success, failed, skipped]

    DAGRunClearRequest:
      type: object
      properties:
        dry_run:
          type: boolean
          default: true
          description: >-
            Return the task instances that WOULD be cleared, without clearing
            them. Defaults to true, matching Apache Airflow. Send false to
            execute.
        only_failed:
          type: boolean
          default: false
          description: >-
            Restrict the clear to the run's task instances in a failed state.
            false (the default, matching Apache Airflow) takes every task
            instance of the run.
        only_new:
          type: boolean
          default: false
          description: >-
            Not supported. Sending true is refused with 400: Dexaflow cannot yet
            tell which tasks a newer DAG version added.
        run_on_latest_version:
          type: boolean
          nullable: true
          description: >-
            Which version the re-run executes. Unset or false keeps the version
            the run was created with. true moves a finished run (success or
            failed, with no task instance queued or running) to the DAG's
            current registered version, creating the task instances of the
            tasks that version adds and marking removed the ones of the tasks
            it dropped. A run that has not finished keeps its version, as in
            Airflow; the response then lists it in
            run_on_latest_version_not_applied with the reason (ADR 0086).
        note:
          type: string
          nullable: true
          description: Accepted and not stored (run notes are not editable yet).

    ClearTaskInstancesRequest:
      type: object
      properties:
        task_ids:
          type: array
          items: { type: string }
        dag_run_id: { type: string }
        only_failed:
          type: boolean
          default: true
          description: >-
            Restrict the clear to task instances in a failed state, matching
            Apache Airflow. false widens it to every task instance named by the
            request, including ones that succeeded.
        only_running:
          type: boolean
          default: false
          description: >-
            Not supported. Sending true is refused with 400. leoflow has no path
            that clears a running task instance (Apache Airflow sets it to
            RESTARTING and kills it), and honoring the only_failed=false that
            Airflow requires alongside it would widen the clear to every task
            instance named by the request, including ones that succeeded.
        reset_dag_runs:
          type: boolean
          default: true
          description: >-
            Re-open the run so the scheduler looks at it again. Without it a
            terminal run stays terminal and the cleared task instance is never
            scheduled.
        run_on_latest_version:
          type: boolean
          default: false
          description: >-
            Which version the re-run executes. false (the default, matching Apache
            Airflow) keeps the version the run was created with, so the re-run
            executes the image that produced the original attempt. true re-binds
            the run to the DAG's current registered version, so the re-run picks up
            the newest image and config. Use it to re-run a failed task against a
            fix without creating a new run. Only a finished run (success or
            failed, with no task instance queued or running) moves: the clear
            creates the task instances of the tasks the version adds and marks
            removed the ones of the tasks it dropped. A run that has not
            finished keeps its version, as in Airflow, and the response lists
            it in run_on_latest_version_not_applied with the reason (ADR 0086).
        dry_run:
          type: boolean
          default: true
          description: >-
            Return the task instances that WOULD be cleared, without clearing
            them. Defaults to true, matching Apache Airflow, so a request that
            names no flags never destroys state. Send false to execute.
        include_upstream: { type: boolean, default: false }
        include_downstream: { type: boolean, default: false }
        include_past: { type: boolean, default: false }
        include_future: { type: boolean, default: false }

    XComEntry:
      type: object
      properties:
        key: { type: string }
        value: {}
        timestamp: { type: string, format: date-time }
        dag_id: { type: string }
        task_id: { type: string }
        dag_run_id: { type: string }
