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"

  /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.
      operationId: renewToken
      tags: [Auth]
      responses:
        "200":
          description: Token renewed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /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, or sent is_paused without write on dags or, with a scoped token, without dexaflow:run
          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, or the register would refuse is_paused or a tenant limit
          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, or the version exceeds a tenant limit
          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.
          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: >-
            run_on_latest_version would re-bind the run to a current version
            whose tasks differ from the run's task instances (a task added or
            removed). The detail names the tasks and the ways out: clear without
            run_on_latest_version, or trigger a new run. A dry run answers the
            same refusal.
          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: List of cleared task instances
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskInstanceCollection"
        "409":
          description: >-
            run_on_latest_version would re-bind the run to a current version
            whose tasks differ from the run's task instances (a task added or
            removed). The detail names the tasks and the ways out: clear without
            run_on_latest_version, or trigger a new run. A dry run answers the
            same refusal.
          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 and base image the operator set
        in the server's deploy.defaults. dexaflow deploy applies them to
        whatever the project, its workspace, its target and the CLI config
        leave unset. The values are the cluster's, the same for every tenant,
        and none is a secret. 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)
    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" }

    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.

    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:
        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, 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.

    TaskInstanceCollection:
      type: object
      properties:
        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 re-binds the run to the DAG's current
            registered version. A re-bind onto a version whose tasks differ from
            the run's task instances is refused with 409.
        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. A re-bind onto a version that adds or removes a
            task is refused with 409 (the clear does not yet create or remove task
            instances), naming the tasks.
        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 }
